Compare commits

...
121 Commits
Author SHA1 Message Date
LeoVasanko 2456730f70 Check max-age only after checking permissions: if neither is passing, we want a 403 error; simply authenticating again won't fix it so don't bother reauth flow. After forbidden flow e.g. account change we are already good with max-age too. 2026-08-11 00:56:01 +00:00
LeoVasanko f74bf3ebe6 Require Python 3.14, ruff formatting for simpler typing. 2026-08-11 00:46:45 +00:00
LeoVasanko 79074dd4f1 OR semantics in perm query arg, strict parsing, segment-aware wildcards
perm=a|b+c now means (a or b) and c; repeated perm args remain ANDed.
Out-of-spec values (empty alternatives, chars outside the scope charset,
stray %2B) are rejected with 400 instead of being silently misparsed;
extra spaces between groups are tolerated. Forward endpoint 400/500
details name /auth/api/forward as origin without echoing query args.
Wildcards are now filename-like: * stays within a :- or /-separated
segment, ** spans segments, partial segments allowed. Slash added to
allowed scope characters for path-based permissions.
2026-08-11 00:44:58 +00:00
LeoVasanko 00ef0ae2e7 Update API and proxy docs 2026-08-10 21:35:46 +00:00
LeoVasanko 6f5287e070 Avoid clearing session.user_agent if a validation request lack this header. Backend-initiated session validations may not have the data. 2026-08-10 14:09:42 +00:00
LeoVasanko 051e1bbb41 Replace paskia.db.logging with kanta's built-in logging (kanta 0.7.0)
The vendored db/logging module duplicated what kanta now provides:
diff formatting, UUID-to-label resolution via logfmt callbacks, unsafe
character filtering and value truncation. Censoring of oidc.key material
moves into the format_log_uuid logfmt callback in db.lifecycle, taking
care to hide only the value, not the 'key' path component itself.
2026-08-09 23:25:16 +00:00
LeoVasanko 4b156b712c Proper handling of auth site runtime change done via web interface, making the change immediately effective. Kept in origins list that is still also visible on the same dialog, where it can be removed if needed. 2026-08-09 23:00:22 +00:00
LeoVasanko df8a7c0026 Cleanup of admin user panel where incorrect toast messages were issued after changes. 2026-08-09 21:45:23 +00:00
LeoVasanko 9b28250391 Upgrade to kanta 0.4.0:
- Make use of its new features and cleanup our interfacing and init/shutdown processes and migrations
- Clean up circular deps, simplify app init
- Add specific pytest for CLI main to cover the changes
2026-06-13 21:59:18 +00:00
LeoVasanko b9aec6bb58 Remove built-in database, replace with kanta package. No disk format changes. 2026-06-12 19:39:30 +00:00
LeoVasanko c79cb497ee Fix profile image path on OIDC. 2026-05-22 02:45:12 +00:00
LeoVasanko 9f50c8c20d Missing file 2026-05-22 01:40:53 +00:00
LeoVasanko 816c7a681e Update E2E tests for new database folder. 2026-05-22 01:40:09 +00:00
LeoVasanko d31c09084e Make rpid.paskiadb a folder containing the database and the files in one. Migrates existing old format rpid.paskiadb file to main.db. 2026-05-22 01:22:35 +00:00
LeoVasanko cc938dd306 Fix a call to loadOrgs when renaming a role, missed in earlier refactoring where we use loadAdminData() for refreshing. 2026-05-22 00:31:06 +00:00
LeoVasanko 36db1e7e56 Fix showing of admin reset link also in devserver where the logging configuration was eating the message. 2026-05-22 00:22:04 +00:00
LeoVasanko 95c163e37a Add profile picture support
- backend avatar storage and OIDC picture claims
- profile and admin UI components
- admin org cards, tests, and docs
2026-05-21 23:57:48 +00:00
LeoVasanko 2d0d17c307 fix remote auth: create session for requesting device host 2026-04-30 21:47:47 +00:00
LeoVasanko 10980ad39b fix type hints: update_session and set_session_host key type 2026-04-30 21:47:46 +00:00
LeoVasanko 42b54cf645 Release 1.4.0 2026-04-29 20:48:12 +00:00
LeoVasanko 232d0e1ae0 Added configurable timeout settings to paskia-js, used in our frontend as well. The default fetch timeout has been changed to 10s from prior 1s, but we maintain 1s for auth endpoints in internal use. 2026-04-29 20:23:38 +00:00
LeoVasanko e97a2b3291 Improved color compatibility across terminals that may have very different ideas of yellow shades. 2026-04-29 16:23:34 +00:00
LeoVasanko cde709e252 Print original METHOD /path on auth/api/forward access log entries. Previously the method was not printed, and nothing was printed for 401 without a session. 2026-04-29 15:47:37 +00:00
LeoVasanko 72d76df35d Log session id from handlers on selected auth routes. Adds request.state.log_extra for handlers to print access log extra. 2026-04-29 03:02:30 +00:00
LeoVasanko 1a742fc0e7 Cleaner websocket access log. 2026-04-29 02:45:20 +00:00
LeoVasanko 0b29654d6f Log original path on forward endpoint. Added logging extra argument for such additions on access logs. 2026-04-29 02:16:48 +00:00
LeoVasanko 76f24a755b Add GET /auth/api/check endpoint for unauthenticated user permission checks
Checks permissions for a user given by ?user=<UUID> query arg without
requiring a session cookie. No cookie is read or written, no DB writes.

- perm= query arg supported (same wildcard semantics as validate/forward)
- Returns valid bool + minimal ctx (user/org/role/permissions)
- Permissions are host-scoped via domain filtering, same as session_ctx
- 404 if UUID not found; valid=false if perm check fails (no 403)
- Add ApiCheckUserResponse struct to apistructs
- Add has_all_scopes() helper to permutil for scope-set-based checks
2026-04-26 05:45:59 +00:00
LeoVasanko 5c452f325a Better error messages on database loading errors. 2026-02-19 21:52:33 +00:00
LeoVasanko e9b6bc7a3d Implement migration for old format listen field in database (re: commit f746085) 2026-02-19 21:26:12 +00:00
LeoVasanko f5545b48f0 Remove dead code. 2026-02-19 21:10:16 +00:00
LeoVasanko c1b2bcf76c Correct alphabetical sort of names in Org Admin panel. Supports Last, First and First Last + variatioons. 2026-02-19 20:54:52 +00:00
LeoVasanko 1806bcab5c A bit more color for light theme; cleaner user badges in admin app. 2026-02-19 20:40:21 +00:00
LeoVasanko be177cbafc Add default value for a(ction) field in change records to keep support for very old versions. 2026-02-19 20:16:16 +00:00
LeoVasanko f5ccc204be Fix adminapp reference after refactoring. 2026-02-19 20:03:58 +00:00
LeoVasanko dd2031eef5 Fix runtime config update by Server Options panel. 2026-02-19 19:57:12 +00:00
LeoVasanko 3cb24bfee9 Refactor to separate admin app modules to subapps, required trailing slashes and plural changes on some of the URLs. 2026-02-19 19:49:13 +00:00
LeoVasanko 3f51d06f13 Admin Server Options panel added for configuring rp-name, auth-host and origins. 2026-02-19 18:53:53 +00:00
LeoVasanko 528a728eb8 README 2026-02-19 18:44:06 +00:00
LeoVasanko 727625ef4f Avoid storing IP and User Agent on OpenID Connect token renewals; preserves the user's information from authentication. 2026-02-19 16:34:44 +00:00
LeoVasanko 5f7a5ed9b1 Added database snapshots, cleanup, better error messages. 2026-02-19 16:29:23 +00:00
LeoVasanko d64e63527b CLI main and RuntimeConfig cleanup. Added a session_ctx wrapper function for easier access and avoiding hostutil import in db. 2026-02-19 14:24:16 +00:00
LeoVasanko 733439b446 Logging cleanup, better color compatibility for Mac Terminal and consistent across DB and FastAPI access logs. 2026-02-19 14:19:54 +00:00
LeoVasanko 4e6f63e9ef Fix auth-host being added to origins even when no origins were wanted. 2026-02-19 01:00:13 +00:00
LeoVasanko d431c75297 Fix request header removal that was causing zstd compressed response when we wanted plain text. 2026-02-19 00:51:11 +00:00
LeoVasanko 6257071efe Cleaned Org Admin styling. 2026-02-19 00:37:44 +00:00
LeoVasanko 7958b6f365 Migrations cleanup by using a MigrationCtx object for meta. 2026-02-19 00:08:38 +00:00
LeoVasanko b3cb540098 Implement database file locking for extra safety. 2026-02-19 00:01:49 +00:00
LeoVasanko 22ba7231b1 Implement read-only database load at startup for CLI to get its settings. Full opening only when server has started. 2026-02-18 23:46:18 +00:00
LeoVasanko 9a9979fb62 Add missing new file. 2026-02-18 23:45:22 +00:00
LeoVasanko 9b7855c0af Faster and simplified hash_secret() that directly produces urlsafe entries. 2026-02-18 23:02:36 +00:00
LeoVasanko dfc4c76d43 Fix static asset serving in devserver mode. 2026-02-18 22:19:18 +00:00
LeoVasanko e1f0fdf664 Fix E2E tests 2026-02-18 22:18:31 +00:00
LeoVasanko f26ac8f33b Simplified My Profile authentication flows, fixed some UX issues with reauth cancelled/accepted leading to incorrect states. 2026-02-18 19:04:02 +00:00
LeoVasanko 880ced3b8c Always load user's theme from API if available, and update the localStorage cache. Previously in various situations the old cached value was being used instead, leading to inconsistent theming or wrong themeselector readout. 2026-02-18 18:35:41 +00:00
LeoVasanko af80b5eefc Make ResetApp Registration layout match the other dialog apps (centered). 2026-02-18 18:01:17 +00:00
LeoVasanko fa1e69d58b Imports to top of file. 2026-02-18 17:47:57 +00:00
LeoVasanko 39000ef831 Remove PASKIA_SITE_URL env, use PASKIA_VITE_URL instead, moving the correct site URL determination to paskia CLI directly. This resolves devserver issues where VITE URL was reported as the external site URL instead of configured auth-host or origins. Main CLI still simplified. Bump deps versions and cleanup pyproject.toml. 2026-02-18 17:21:03 +00:00
LeoVasanko 49119fac81 Fix HostProfile user properties access. 2026-02-18 03:47:08 +00:00
LeoVasanko 14aae74b60 Bump and synchronize paskia-js version. 2026-02-18 02:45:33 +00:00
LeoVasanko 68dccc1378 OAuth2 OpenID Connect provider support, API and DB refactoring (#3)
Allows Paskia to authenticate the user to a client site.
- User friendly client registration flow on the admin app
- Redirect-based authentication flow (per spec)
- Backchannel logout both ways to keep sessions synchronized
- Groups integrated with Paskia's permission system
- Adds email, preferred username and telephone fields on user profile
- All new user basic info layout to show the new information, better looks
- API and DB structures redesigned
- Various unrelated fixes to theming and layout
2026-02-18 02:40:27 +00:00
LeoVasanko 557ffaa0cd Add theme toggles that were missing from forward and reset apps. 2026-02-17 18:36:12 +00:00
LeoVasanko c0aba07326 Show credential UUID in logging as they are, no prettifying. 2026-02-17 18:32:47 +00:00
LeoVasanko f830d7d0ec More minimalistic light theme. UI hint for org admin user/role management. 2026-02-14 18:36:20 +00:00
LeoVasanko 7f52276c23 Use samesite=strict because we don't need the cookie for page loads. 2026-02-13 20:22:05 +00:00
LeoVasanko c1f8020f6b API cleanup, using msgspec structs rather than raw responses. Admin app cleanup, better breadcrumbs. 2026-02-13 20:09:41 +00:00
LeoVasanko 423abb0d1b Move CLI entry point to main module even though it runs the FastAPI app from a submodule. 2026-02-13 16:26:05 +00:00
LeoVasanko ef7a6c8011 Upgrade fastapi-vue-setup 1.0.2 2026-02-11 21:33:21 +00:00
LeoVasanko 97064f7bc7 Remove unused CLI bootstrap entry point. 2026-02-11 19:36:06 +00:00
LeoVasanko fc0541762e Add version indication and link to our site on profile page (bottom right corner). 2026-02-11 01:36:58 +00:00
LeoVasanko cebaa2a757 Less eagerly enable very wide layout for user profile (only if more than 8 items for passkeys or per site sessions). 2026-02-11 01:24:15 +00:00
LeoVasanko 4ebe5ae968 Style overhaul. 2026-02-11 01:18:19 +00:00
LeoVasanko a944224027 Inline get_config, rewrite update_config, DB init Config and rp_id defaults changed. 2026-02-10 23:01:02 +00:00
LeoVasanko aef0e0cb44 ResetToken.hash(phrase) added avoiding code duplication. 2026-02-10 22:56:49 +00:00
LeoVasanko d41cffc03e Remove remaining DB getter functions, inline at call site and add ResetToken.by_passphrase(). 2026-02-10 22:48:31 +00:00
LeoVasanko bad709a3ab Remove unnecessary odd getter from db.operations. 2026-02-10 22:37:57 +00:00
LeoVasanko 1cfde06de9 Refactor DB lifecycle functions init and cleanup to separate db.lifecycle module. 2026-02-10 22:28:38 +00:00
LeoVasanko b0b36e88b1 CRUD store and delete on the DB classes directly. 2026-02-10 22:19:02 +00:00
LeoVasanko 2237e6b5e9 Db operations: bootstrap separated to its own module. 2026-02-10 22:08:54 +00:00
LeoVasanko c2ea01e6d9 Use strictly same now timestamp over a transaction, even for UUIDv7s generated. 2026-02-10 21:45:09 +00:00
LeoVasanko 8b6bdd0f9c Calculate session expiry times in operations, using a common now timestamp for everything. 2026-02-10 21:35:56 +00:00
LeoVasanko 3ca784dc3c Set last seen and increment visits during registration, not only on authentication. 2026-02-10 21:25:20 +00:00
LeoVasanko d8876d9202 README 2026-02-09 19:22:05 +00:00
LeoVasanko aa22b7709f README 2026-02-09 18:52:53 +00:00
LeoVasanko a8222f4bba README 2026-02-09 18:46:03 +00:00
LeoVasanko 16ab111a89 Make config part of bootstrap. 2026-02-09 18:33:29 +00:00
LeoVasanko d826146932 Improved CLI logging of DB transactions. 2026-02-09 18:28:24 +00:00
LeoVasanko fda9b2545e Fix save config running before bootstrap for new databases. 2026-02-09 18:07:51 +00:00
LeoVasanko 0cf551cb28 Cleaner database error handling and fixes. 2026-02-09 17:55:03 +00:00
LeoVasanko 632230d05c Minor fixes to config handling. 2026-02-09 17:37:10 +00:00
LeoVasanko a37198bb4b README 2026-02-09 17:18:55 +00:00
LeoVasanko fb71ea1220 Storing config on database to simplify reloads by CLI. 2026-02-09 17:00:40 +00:00
LeoVasanko 25283eba9b Change database name to (rp-id).paskiadb (previously paskia.jsonl). Validate the rp-id in passkey init. 2026-02-09 16:42:12 +00:00
LeoVasanko 6a217978d6 Upgrade fastapi-vue-setup. 2026-02-09 16:29:24 +00:00
LeoVasanko 4d48c0720a README 2026-02-06 01:07:00 +00:00
LeoVasanko 083d20c8c9 README 2026-02-06 01:00:58 +00:00
LeoVasanko c662fbd39c README 2026-02-06 00:29:16 +00:00
LeoVasanko edf80aef52 README 2026-02-06 00:27:01 +00:00
LeoVasanko 8d5c53ab72 README 2026-02-06 00:25:38 +00:00
LeoVasanko f8a760ddfd README 2026-02-06 00:23:44 +00:00
LeoVasanko 430394a1e5 README 2026-02-06 00:22:03 +00:00
LeoVasanko 4c54ad5d1f Docs, screenshot of permissions view. 2026-02-06 00:16:29 +00:00
LeoVasanko 0b2be18b44 Docs 2026-02-06 00:08:27 +00:00
LeoVasanko 1c8be68811 README 2026-02-05 22:58:42 +00:00
LeoVasanko 116bc3c4ef README 2026-02-05 22:44:26 +00:00
LeoVasanko c641c721c7 README 2026-02-05 21:54:14 +00:00
LeoVasanko 8fc03ade04 Remove the reset subcommand that was broken and unnecessary. 2026-02-05 21:53:30 +00:00
LeoVasanko 6ad3aa7d8c Screenshots, README. 2026-02-05 21:38:57 +00:00
LeoVasanko d8444b0db8 New screenshot after theme changes. 2026-02-05 20:38:30 +00:00
LeoVasanko f515daeecd Release paskia-js 0.1.3 with minor updates on styling and metadata. 2026-02-05 19:49:39 +00:00
LeoVasanko 5deb57435b Remove paskia-migration script (SQL no longer supported). 2026-02-05 19:32:18 +00:00
LeoVasanko 250189dbe5 Update tests for the latest changes. 2026-02-05 19:26:20 +00:00
LeoVasanko 3469e6fa3f ruff check 2026-02-05 19:21:03 +00:00
LeoVasanko c3df6c318c Change host normalization to remove port numbers - sessions are per host, cookies don't respect port numbers. 2026-02-05 19:04:40 +00:00
LeoVasanko 615066a2a2 Upgrade by fastapi-vue-setup, devserver script entirely rewritten to use its facilities. 2026-02-05 18:50:08 +00:00
LeoVasanko 70c682b539 Improved client IP and UA handling. 2026-02-05 17:33:08 +00:00
LeoVasanko 8444d0399e Improved theme picker 2026-02-05 16:19:06 +00:00
LeoVasanko 3c5f8694b3 Load stylesheets directly from HTML to avoid flashing wrong background color first. 2026-02-05 15:39:51 +00:00
LeoVasanko 871eb149ab Styling updates, more robust dynamic/userpref light/dark switching. Sync with paskia-js. 2026-02-05 15:27:15 +00:00
LeoVasanko 291a665e21 Improved UI feedback on registration link creation. 2026-02-05 14:26:11 +00:00
LeoVasanko 0537b85085 Better UI for role deletions. 2026-02-05 14:05:36 +00:00
LeoVasanko af5a48f565 API/DB cleanup for flat URLs that don't include org where users etc. are referred to. Implement user deletion in admin app and API, UI improvement. Reset token DB factory function revised to create passphrase and key internally. Removed unneeded functions and args, using update_user_role instead of a separate deleted _in_organization function. 2026-02-05 13:57:07 +00:00
157 changed files with 11833 additions and 6009 deletions
+2 -1
View File
@@ -5,7 +5,8 @@ dist/
*.lock
package-lock.json
paskia.sqlite
paskia.jsonl
*.paskiadb
*.data
/paskia/frontend-build
/paskia/_version.py
coverage-html/
+176 -18
View File
@@ -1,34 +1,41 @@
# Paskia
![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
![Login dialog screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/login-light.webp)
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
## What is Paskia?
- Easy to use fully featured auth&auth system (login and permissions)
- Organization and role-based access control (optional)
- Organization and role-based access control
* Org admins control their users and roles
* Master admin can create multiple independent orgs
* Master admin makes permissions available for orgs to assign
- User Profile and Administration by API and web interface.
under `/auth/` or `auth.example.com`
- Reset tokens and additional device linking via QR code or codewords.
- Pure Python, FastAPI, packaged with prebuilt Vue frontend
* Multiple independent orgs
* Master admin can do everything or delegate to org admins
- User Profile and Admin by API and web interface
- Implements login/reauth/forbidden flows for you
- Single Sign-On (SSO): Users register once and authenticate across your services
- Remote autentication by entering random keywords from another device (like 2fa)
- No CORS, NodeJS or anything extra needed.
## Authenticate to get to your app, or in your app
Two interfaces:
- API fetch: auth checks and login without leaving your app
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx)
- Forward-auth proxy: protect any unprotected site or service ([Caddy](docs/proxy/caddy.md), [Nginx](docs/proxy/nginx.md), and [others](docs/proxy/index.md))
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
Single Sign-On (SSO): Users register once and authenticate across all applications under your domain name (configured rp-id).
## Authentication flows already done
![Forbidden dialog, dark mode](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-dark.webp)
**Automatic light/dark mode switching with overrides by user profile and protected app's theme.**
Paskia includes set of login, reauthentication and forbidden dialogs that it can use to perform the needed flows. We never leave the URL, no redirections, and if you make use of API mode, we won't even interrupt whatever your app was doing but retry the blocked API fetch after login like nothing happened.
## Quick Start
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
```fish
```sh
uvx paskia --rp-id example.com
```
@@ -38,30 +45,181 @@ For production you need a web server such as [Caddy](https://caddyserver.com/) t
For a permanent install of `paskia` CLI command, not needing `uvx`:
```fish
```sh
uv tool install paskia
```
## Configuration
There is no config file. All settings are passed as CLI options:
You will need to specify your main domain to which all passkeys will be tied as rp-id. Use your main domain even if Paskia is not running there. All other options are optional.
```text
paskia [options]
paskia reset [user] # Generate passkey reset link
```
| Option | Description | Default |
|--------|-------------|---------|
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
| --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
| --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id |
| --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains |
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
| --save | Save current options to database | (only --rp-id required on further invocations) |
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` folder in current directory. This can be overridden by environment `PASKIA_DB` if needed.
## Tutorial: From Local Testing to Production
This section walks you through a complete example, from running Paskia locally to protecting a real site in production.
### Step 1: Production Configuration
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
```sh
uvx paskia --rp-id=example.com --rp-name="Example Corp"
```
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). On the first run, you'll see a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name.
### Step 2: Set Up Caddy
Install [Caddy](https://caddyserver.com/) and copy the [auth folder](caddy/auth) to `/etc/caddy/auth`. Say your current unprotected Caddyfile looks like this:
```caddyfile
app.example.com {
reverse_proxy :3000
}
```
Add Paskia full site protection:
```caddyfile
app.example.com {
import auth/setup
handle {
import auth/require perm=myapp:login
reverse_proxy :3000
}
}
```
Run `systemctl reload caddy`. Now `app.example.com` requires the `myapp:login` permission. Try accessing it and you'll land on a login dialog.
### Step 3: Assign Permissions via Admin Panel
![Admin panel permissions](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/master-permissions.webp)
1. Go to `app.example.com/auth/admin/`
2. Create a permission, give it a name and scope `myapp:login`
3. Assign it to Organization
4. In that organization, assign it to the Administration role
Now you have granted yourself the new permission.
Permission scopes are text identifiers with colons as separators that we can use for permission checks. The `myapp:` prefix is a convention to namespace permissions per application—you but you can use other forms as you see fit (urlsafe characters, no spaces allowed).
### Step 4: Add API Authentication to Your App
Your backend already receives `Remote-*` headers from Caddy's forward-auth. For frontend API calls, we provide a [JS paskia module](https://www.npmjs.com/package/paskia):
```js
import { apiJson } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
const data = await apiJson('/api/sensitive', { method: 'POST' })
```
When a 401/403 occurs, the auth dialog appears automatically, and the request retries after authentication.
To protect the API path with a different permission, update your Caddyfile:
```caddyfile
app.example.com {
import auth/setup
@api path /api/*
handle @api {
import auth/require perm=myapp:api
reverse_proxy :3000
}
handle {
import auth/require perm=myapp:login
reverse_proxy :3000
}
}
```
Create the `myapp:api` permission in the admin panel, that will be required for all API access. Link to `/auth/` for the built-in profile page.
You may also remove the `myapp:login` protection from the rest of your site paths, unless you wish to keep all your assets behind a login page. Having this as the last entry in your config allows free access to everything not matched by other sections.
```Caddyfile
handle {
reverse_proxy :3000
}
```
### Step 5: Run Paskia as a Service
Create a system user paskia, install UV on the system, and create a systemd unit:
```sh
sudo useradd --system --home-dir /srv/paskia --create-home paskia
```
Install UV on the system (or arch btw `pacman -S uv`):
```sh
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
```
Create a systemd unit:
```sh
sudo systemctl edit --force --full paskia@.service
```
Paste the following and save:
```ini
[Unit]
Description=Paskia for %i
[Service]
Type=simple
User=paskia
WorkingDirectory=/srv/paskia
ExecStart=uvx paskia@latest --rp-id=%i
[Install]
WantedBy=multi-user.target
```
Run the service and view log:
```sh
sudo systemctl enable --now paskia@example.com && sudo journalctl -n30 -ocat -fu paskia@example.com
```
### Optional: Dedicated Authentication Site
Add a Caddy configuration for the authentication domain:
```caddyfile
auth.example.com {
reverse_proxy :4401
}
```
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
Enter your auth site domain on Admin / Server Options panel or use `--auth-host=auth.example.com` when starting the server.
## Further Documentation
- [Caddy configuration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Caddy.md)
- [Forward-Auth Guides](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/proxy/index.md) (Caddy, Nginx, ...)
- [Trusted Headers for Backend Apps](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Headers.md)
- [Frontend integration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Integration.md)
- [Paskia API](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/API.md)
+72 -35
View File
@@ -1,64 +1,101 @@
# Paskia API
[Integration](Integration.md) · [Proxy guides](proxy/index.md)
For integrating Paskia with your app frontend, see [integration](Integration.md).
## Web Interface
| Method | Path | What it is for | Notes |
| Method | Path | What it is for | Responses |
|---:|---|---|---|
| GET | `/auth/` | User profile page | |
| GET | `/auth/admin/` | Admin panel | Requires auth:admin (master) or org admin permissions. |
| GET | `/auth/{token}` | Reset / add credential URL (QR code link) | E.g. `/auth/fun.cotton.fresh.xray.lava` |
| GET | /auth/ | User profile page | HTML 200/401 |
| GET | /auth/admin/ | Admin panel, requires auth:admin or org admin permissions | HTML 200/401/403 |
| GET | /auth/{token} | Reset / add credential URL (QR code link), e.g. /auth/fun.cotton.fresh.xray.lava | HTML 200 |
### Public JSON API: `/auth/api/*`
### Public JSON API: /auth/api/*
| Method | Path | Used for | Notes |
| Method | Path | Used for | Responses |
|---:|---|---|---|
| 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 |
| POST | `/auth/api/logout` | Terminate session and delete session cookie | Signs out of the current site |
| POST | `/auth/api/validate` | Validate and renew session cookie | Optional query: `perm=` (repeatable), `max_age=` |
| GET | `/auth/api/forward` | Validate access (Caddy/Nginx) | 204 on success; 401/403 otherwise (HTML if requested) |
| GET | /auth/api/settings | Paskia configuration: RP info, base paths, session cookie name | 200 |
| GET | /auth/api/user-info | Full user profile: info, credentials, sessions, permissions | 200/401 |
| POST | /auth/api/logout | Terminate session and delete session cookie on the current host | 200 |
| POST | [/auth/api/validate](api/validate.md) | Validate and renew the session cookie; query [perm](api/perm.md), [max_age](api/max-age.md) | 200/401/403 |
| GET | [/auth/api/forward](api/forward.md) | Forward-auth with reverse proxies; see [proxy guides](proxy/index.md), query [perm](api/perm.md), [max_age](api/max-age.md) | 204/401/403 empty, json or html|
The `validate` and `forward` endpoints take query arguments `perm=` and `max_age=` for specific requirements on the validation of the current session.
### User JSON API: /auth/api/user/*
### User JSON API: `/auth/api/user/*`
| Method | Path | Used for | Notes |
| Method | Path | Used for | Responses |
|---:|---|---|---|
| PUT | `/auth/api/user/display-name` | Update the users display name | Body: JSON `{ "display_name": "..." }` |
| POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie |
| DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued |
| DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication |
| POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication |
| PATCH | /auth/api/user/display-name | Update the user's display name | 200/401 |
| POST | /auth/api/user/logout-all | Terminate all user sessions | 200/401 |
| DELETE | /auth/api/user/session/{session_id} | Terminate one session | 200/401 |
| DELETE | /auth/api/user/credential/{uuid} | Delete a credential; requires recent authentication | 200/401/403 |
| POST | /auth/api/user/create-link | Create a device-add link; requires recent authentication | 200/401/403 |
| GET | /auth/api/user/{uuid}/profile.webp | Canonical avatar image URL, public on the auth host | 200/304/404 |
| PUT | /auth/api/user/{uuid}/profile.webp | Upload or replace an avatar; square WebP prepared in the browser | 200/401/403 |
| DELETE | /auth/api/user/{uuid}/profile.webp | Remove an avatar; allowed for the user or an admin | 200/401/403 |
These are used mostly from the user profile panel and modify the current user.
These are used mostly from the user profile panel by the user himself, but the profile pictures are public for all to read.
### Admin API: `/auth/api/admin/*`
### Admin API: /auth/api/admin/*
Normally only used via admin panel, requires auth admin permissions and can modify any users, orgs and permissions the session has access to.
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.
### WebSockets: `/auth/ws/*`
| Method | Path | Used for | Responses |
|---:|---|---|---|
| GET | /auth/api/admin/info | Admin overview: orgs, permissions, OIDC clients | 200/401/403 |
| POST | /auth/api/admin/permissions/ | Create permission | 200/401/403 |
| PATCH | /auth/api/admin/permissions/{uuid} | Update permission | 200/401/403 |
| DELETE | /auth/api/admin/permissions/{uuid} | Delete permission | 200/401/403 |
| POST | /auth/api/admin/orgs/ | Create organization | 200/401/403 |
| GET | /auth/api/admin/orgs/{uuid} | Get organization details | 200/401/403 |
| PATCH | /auth/api/admin/orgs/{uuid} | Update organization | 200/401/403 |
| DELETE | /auth/api/admin/orgs/{uuid} | Delete organization | 200/401/403 |
| POST | /auth/api/admin/orgs/{uuid}/users | Create user in org | 200/401/403 |
| POST | /auth/api/admin/orgs/{uuid}/roles | Create role in org | 200/401/403 |
| POST | /auth/api/admin/orgs/{uuid}/permission | Grant permission to org | 200/401/403 |
| DELETE | /auth/api/admin/orgs/{uuid}/permission | Revoke permission from org | 200/401/403 |
| PATCH | /auth/api/admin/roles/{uuid} | Update role | 200/401/403 |
| POST | /auth/api/admin/roles/{uuid}/permissions/{uuid} | Add permission to role | 200/401/403 |
| DELETE | /auth/api/admin/roles/{uuid}/permissions/{uuid} | Remove permission from role | 200/401/403 |
| DELETE | /auth/api/admin/roles/{uuid} | Delete role | 200/401/403 |
| PATCH | /auth/api/admin/users/{uuid}/role | Update user role | 200/401/403 |
| PATCH | /auth/api/admin/users/{uuid}/info | Update user info | 200/401/403 |
| GET | /auth/api/admin/users/{uuid} | Get user details | 200/401/403 |
| DELETE | /auth/api/admin/users/{uuid} | Delete user | 200/401/403 |
| POST | /auth/api/admin/users/{uuid}/create-link | Create device add link | 200/401/403 |
| DELETE | /auth/api/admin/users/{uuid}/credentials/{uuid} | Delete user credential | 200/401/403 |
| DELETE | /auth/api/admin/users/{uuid}/sessions/{key} | Delete user session | 200/401/403 |
| POST | /auth/api/admin/oidc-clients/ | Create OIDC client | 200/401/403 |
| PATCH | /auth/api/admin/oidc-clients/{uuid} | Update OIDC client | 200/401/403 |
| PATCH | /auth/api/admin/oidc-clients/{uuid}/reset-secret | Reset client secret | 200/401/403 |
| DELETE | /auth/api/admin/oidc-clients/{uuid} | Delete OIDC client | 200/401/403 |
| GET | /auth/api/admin/server-config/ | Get server config | 200/401/403 |
| PATCH | /auth/api/admin/server-config/ | Update server config | 200/401/403 |
### WebSockets: /auth/ws/*
| Path | Used for | Notes |
|---|---|---|
| `WS /auth/ws/authenticate` | Passkey authentication | Returns a session token |
| `WS /auth/ws/register` | Register a new credential | Adding another passkey to current user or via reset token |
| `WS /auth/ws/remote-auth/request` | Start a cross-device login/registration request | Used from unauthenticated client |
| `WS /auth/ws/remote-auth/permit` | Approve/deny a pairing code | Used to accept the request, if same words are entered |
| WS /auth/ws/authenticate | Passkey authentication | Returns a session token |
| WS /auth/ws/register | Register a new credential | Adding another passkey to current user or via reset token |
| WS /auth/ws/remote-auth/request | Start a cross-device login/registration request | Used from unauthenticated client |
| WS /auth/ws/remote-auth/permit | Approve/deny a pairing code | Used to accept the request, if same words are entered |
These are for internal use only, but are documented here because they are the core piece in all passkey operations.
### Auth host mode (`--auth-host`)
### Auth host mode (--auth-host)
#### On the auth host:
- The Web UI is served at site root (e.g. admin UI at `/admin/`), and the `/auth/...` equivalents (e.g. `/auth/admin/`) redirect to the root paths.
- All of the API stays under `/auth/api/*`
- Auth WebSockets remain at `/auth/ws/*` but take connections from other hosts to issue sessions for each of those.
- The Web UI is served at site root instead of /auth/* (that redirects to root paths)
- All of the API stays under /auth/api/*
- Auth WebSockets remain at /auth/ws/* but take connections from other hosts to issue sessions for each of those.
#### On non-auth hosts:
- `/auth/` shows only minimal profile and allows logging out of the current site
- `/auth/api/*` is served normally.
- `/auth/api/user/*`, `/auth/api/admin/*`, and `/auth/ws/*` don't exist.
- /auth/ shows only minimal profile and allows logging out of the current site, link to full profile on auth host
- /auth/api/* is served normally.
- /auth/api/user/*, /auth/api/admin/*, and /auth/ws/* don't exist.
The WebSocket connections are directed to auth host, and must have an allowed origin corresponding to the host where the user is logging in, that the session is tied with.
+12 -10
View File
@@ -1,16 +1,18 @@
# Paskia Trusted Headers for Backend Apps
[Proxy guides](proxy/index.md) · [`/auth/api/forward`](api/forward.md)
| HTTP Header | Meaning | Example |
|---|---|---|
| `Remote-User` | Authenticated user UUID | **01c03276-b8f0-**… (string) |
| `Remote-Name` | User display name | **John Doe** |
| `Remote-Org` | Organization UUID | Identifier for user's org (string) |
| `Remote-Org-Name` | Organization display name | **The Company Ltd.** |
| `Remote-Role` | Role UUID | Identifier for user's role (string) |
| `Remote-Role-Name` | Role display name | **Employee** |
| `Remote-Groups` | Permissions the user has, comma separated | **auth:admin,yourapp:reports** |
| `Remote-Session-Expires` | Session expiry timestamp (ISO 8601 UTC) | **2030-12-31T23:59:59Z** |
| `Remote-Credential` | Credential UUID | Identifier for the sign-in passkey (string) |
| Remote-User | Authenticated user UUID | **01c03276-b8f0-**… (string) |
| Remote-Name | User display name | **John Doe** |
| Remote-Org | Organization UUID | Identifier for user's org (string) |
| Remote-Org-Name | Organization display name | **The Company Ltd.** |
| Remote-Role | Role UUID | Identifier for user's role (string) |
| Remote-Role-Name | Role display name | **Employee** |
| Remote-Groups | Permissions the user has, comma separated | **auth:admin,yourapp:reports** |
| Remote-Session-Expires | Session expiry timestamp (ISO 8601 UTC) | **2030-12-31T23:59:59Z** |
| Remote-Credential | Credential UUID | Identifier for the sign-in passkey (string) |
Similar headers are also used by other authentication systems like [Authelia](https://www.authelia.com/integration/trusted-header-sso/introduction/) to signal the backend application information about the signed in user.
@@ -18,6 +20,6 @@ When a request is allowed, the auth service adds these headers by the forward-au
Only the UUID values should be used for identification needs, because they never change, even when things are renamed (display names change), and are never reused (created on authentication server). They are UUIDv7 so you can also extract the creation timestamp from them.
Any `Remote-*` headers from clients are stripped by our [Caddy configuration](Caddy.md) to avoid dealing with any fake headers.
Any `Remote-*` headers from clients are stripped by the proxy configuration (see our [Caddy configuration](proxy/caddy.md) and [Forward-Auth Proxy Guides](proxy/index.md)) to avoid dealing with any fake headers.
Note: the headers are intended primarily for the backend, while either frontend or backend (passing the session cookie) can request `/auth/api/user-info` for more complete information, and that is the recommended way to do it in the frontend. See [integration](Integration.md) for more.
+166 -32
View File
@@ -1,43 +1,177 @@
# 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.
[API overview](API.md) · [Proxy guides](proxy/index.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).
This guide covers frontend and backend integration with Paskia. For forward-auth setup, see the [Forward-Auth Proxy Guides](proxy/index.md); Caddy users can also start from the dedicated [Caddy configuration](proxy/caddy.md).
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.
## Frontend Integration
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.
### Using the paskia-js Module
## Authentication Flow (iframe)
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.
```js
// Show an authentication dialog
const iframe = document.createElement('iframe')
iframe.src = auth.url // from 401/403 response JSON
iframe.style.cssText = `
position: fixed;
inset: 0;
width: 100%;
height: 100%;
border: 0;
z-index: 9999;
background: transparent;
backdrop-filter: blur(0.1rem) brightness(0.7);
`
document.body.appendChild(iframe)
// Wait until user is finished with the dialog
const handler = ev => {
if (ev.origin !== location.origin) return
iframe.remove()
removeEventListener('message', handler)
if (ev.data?.type === 'auth-success') retry_original_fetch()
}
addEventListener('message', handler)
```html
<script type="module">
import { apiJson, apiFetch, SessionValidator } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
</script>
```
This describes the frontend flow for handling 401/403 responses from endpoints protected by Paskia forward-auth, without ever exiting your app.
Or install to your project:
When a protected request fails, the backend returns 401 (needs auth / reauth) or 403 (missing permission). For API requests, the response is JSON that includes an iframe URL. Your app should render that URL in a full-screen iframe overlay, and retry the request after the iframe reports success. If it reports `auth-cancel`, don't try again. The backdrop for the dialog is a stylistic choice, and you can style the background shown with the dialog any way you wish, and consider using CSS file with the iframe rather than inline styles as used in the example.
```sh
npm install paskia
```
Following this flow the user gets authenticated properly and after that your app keeps running as if nothing ever happened.
### API Fetch with Automatic Auth
Use `apiJson` or `apiFetch` for API calls. When a 401/403 response includes an auth URL, the authentication dialog appears automatically, then the request retries. The JSON variant is purely for convenience, doing JSON headers and conversions for you.
```js
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
// JSON API call (sets Content-Type, parses response)
try {
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
} catch (e) {
if (e instanceof AuthCancelledError) {
// User cancelled auth dialog
}
}
// Raw fetch with auth handling (returns Response object)
const response = await apiFetch('/api/endpoint')
```
For requests that shouldn't trigger auth dialogs, use standard `fetch` or our `fetchJson`.
### Session Validation Polling
Keep sessions alive and detect when the user logs out or switches accounts:
```js
import { SessionValidator } from 'paskia'
const validator = new SessionValidator(
() => currentUser?.uuid, // getter for current user ID
(error) => handleSessionLost(error) // callback when session is lost or user changes
)
validator.start() // start polling (pauses on idle)
validator.stop() // stop polling
```
The validator calls `/auth/api/validate` (see below) 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 forward-auth, your backend receives `Remote-*` headers on authenticated requests. See [Headers](Headers.md) for the full list and [Forward-Auth Proxy Guides](proxy/index.md) for proxy configuration.
```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
This is useful for:
- Apps/APIs not behind proxy Forward-Auth protection
- Background jobs that need to verify a stored session
- Check extra permissions, get user context or renew session
Your backend can validate sessions directly by calling Paskia's validate endpoint [`/auth/api/validate`](api/validate.md). It generally expects client headers proxied as is, while on the URL you can specify exact requirements.
Usually it is sufficient to simply forward the headers the client sent, assuming your proxy already preserved `Host` and set `X-Forwarded-For` (otherwise set them here with original host and IP). `User-Agent` should also be forwarded if available, omitted if not: do not let your backend HTTP client add its own header.
Be sure to REMOVE connection hop-by-hop headers (these will break WebSockets among other things):
```python
"Connection", "Keep-Alive", "Proxy-Connection", "TE", "Transfer-Encoding", "Upgrade"
```
## 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 a reverse proxy. The [Forward-Auth Proxy Guides](proxy/index.md) cover Caddy, Nginx, Traefik, Apache APISIX, Envoy and HAProxy.
### 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.
+71
View File
@@ -0,0 +1,71 @@
# GET /auth/api/forward
[API overview](../API.md) · [Proxy guides](../proxy/index.md)
Forward-auth validation for reverse proxies. The proxy calls this endpoint for every incoming request; Paskia validates the session and either authorizes the request by returning 204 with `Remote-*` headers, or rejects it with 401/403 error responses with an HTML login page if `text/html` was requested (i.e. it's a browser viewing the page), or otherwise JSON with details on the error and a URL to initiate API authentication flow.
The proxy server follows the 204 response with the original request to protected service, adding those remote headers to original user request, sent to the service. Any error response is sent directly back to client, never connecting to the protected service.
See [Forward-Auth Proxy Guides](../proxy/index.md) for Caddy, Nginx, Traefik, Apache APISIX, Envoy and HAProxy configuration examples. Caddy users can also start from the dedicated [Caddy configuration guide](../proxy/caddy.md).
## Query parameters
| Parameter | Description |
|-----------|-------------|
| perm | Required permissions. See the [perm argument](perm.md). |
| max_age | Require recent passkey use. See the [max_age argument](max-age.md). |
## Request headers
| Header | Expected value / note | How Paskia uses it |
|---|---|---|
| Host | Forwarded directly from the client | Verifying the session's bound host |
| Cookie | Forwarded directly or just cookie `__Host-paskia` | Session ID |
| X-Forwarded-Method | The HTTP method, e.g. POST | Logging of original request |
| X-Forwarded-Uri | Request path and query, e.g. /reports?foo=bar | Logging of original request |
| Accept | text/html or anything else | Determines whether failures return an HTML page or JSON |
Connection hop-by-hop headers (Connection, Upgrade, Transfer-Encoding, etc.) must not be forwarded.
## Response
### Success (204)
No response body. Only [Remote headers](../Headers.md) are set on the response and your proxy should forward them to the backend request. The headers, not a response body, are the whole point of this endpoint: they are how the authenticated identity reaches the protected service, which can trust them because it is only reachable through the proxy.
### Failure (400 / 401 / 403)
| Status | Meaning |
|---|---|
| 400 | Malformed perm argument (see [perm](perm.md#syntax-errors)). The error detail names `/auth/api/forward` as the origin and never echoes query arguments. |
| 401 | Session missing or expired, or max_age not satisfied — the user needs to (re)authenticate. |
| 403 | Requested permissions are missing — the forbidden flow allows signing in with another account. |
Failure responses come in two flavors, chosen by the Accept header, because the audience differs:
- A browser asking for a page (Accept includes text/html) gets a full authentication page it can show directly — the proxy simply passes the response through and the user can sign in without any application involvement.
- Any other request (fetch, img, ...) gets JSON intended for programmatic handling. Besides the error detail, it carries an auth section whose iframe URL points to a ready-made authentication dialog your frontend can embed, so the user can sign in without leaving your app:
```json
{
"detail": "Additional authentication required",
"auth": {
"mode": "reauth",
"iframe": "/auth/restricted/iframe#mode=reauth&theme=dark"
}
}
```
The mode field:
- login: no valid session, need to sign in
- reauth: additional authentication required (using same passkey)
- forbidden: lacking required permissions
Extra metadata such as the user's theme override may appear as additional fields and iframe fragment parameters.
Note: we provide a JavaScript package [paskia](https://www.npmjs.com/package/paskia) with helpers for the embedding, fetch 401/403 handling, session renewals and more.
## Session renewal
The forward endpoint **does not renew** the session because in the forward-auth mechanism it could not send the client a renewed session cookie. It only validates the current cookie and returns the trusted headers. Use [/auth/api/validate](validate.md) when you need to refresh the session lifetime. Otherwise the user will have to sign in again every 24h even if they are actively using the service.
+47
View File
@@ -0,0 +1,47 @@
# The max_age argument
[API overview](../API.md) · [`/auth/api/validate`](validate.md) · [`/auth/api/forward`](../forward.md)
The max_age argument is used by endpoints that validate sessions to require a recent passkey use. It is supported by:
- [POST /auth/api/validate](validate.md)
- [GET /auth/api/forward](forward.md)
## What it checks
max_age limits how long ago the user last authenticated with their passkey. It is intended for high-risk actions where you want to be sure the user recently proved possession of their credential, not just that they still have a valid session cookie.
The check compares the elapsed time since the credential was last used against the given limit:
- If the credential has a last_used timestamp, that time is used.
- Otherwise, the session's validated timestamp is used as a fallback.
If the authentication is older than max_age, the endpoint returns 401 with mode reauth, prompting the user to re-authenticate.
## Time units
max_age accepts a number followed by one of these units:
| Unit | Meaning |
|---|---|
| s | seconds |
| m / min | minutes |
| h | hours |
| d | days |
Examples:
```text
?max_age=30s
?max_age=5m
?max_age=5min
?max_age=1h
?max_age=1d
```
An invalid format is logged as a warning but does not cause the request to fail; the requirement is simply ignored in that case.
## Important notes
- Session renewal by /auth/api/validate does **not** count as fresh authentication. The check is based on the credential's last_used time, not on how recently the session cookie was renewed.
- max_age is independent of perm. You can use either or both at the same time.
+96
View File
@@ -0,0 +1,96 @@
# The perm argument
[API overview](../API.md) · [`/auth/api/validate`](validate.md) · [`/auth/api/forward`](../forward.md)
The perm argument is used by endpoints that validate sessions to require one or more permission scopes. It is supported by:
- [POST /auth/api/validate](validate.md)
- [GET /auth/api/forward](forward.md)
## Passing the argument
Repeat the query parameter for each required scope:
```text
?perm=myapp:read&perm=myapp:write
```
You can also pass multiple scopes in one parameter by separating them with whitespace (a literal space, i.e. `+` or `%20` in the query string):
```text
?perm=myapp:read%20myapp:write
```
Both forms produce the same result.
## Semantics
The perm argument uses **AND** semantics between groups: **every** listed scope group must be satisfied by the effective permissions for the request to succeed. If any required group is not satisfied, the endpoint returns 403.
Within a single group, use `|` to list alternatives with **OR** semantics — the group is satisfied when **any one** of the alternatives is present:
```text
?perm=myapp:read|myapp:write+myapp:login
```
This requires `myapp:login` **and** (`myapp:read` **or** `myapp:write`). No whitespace is allowed around the `|` operator.
## Syntax errors
Parsing is strict: anything out of spec is rejected with **400 Bad Request** rather than guessed at. This includes:
- Empty values or alternatives (`?perm=`, `?perm=a||b`, `?perm=|a`, `?perm=a|`)
- Whitespace around `|` (`?perm=a+|+b`)
- Characters not allowed in scopes other than the operators (space and `|`); scopes match `^[A-Za-z0-9:._~/-]+$` plus the `*` wildcard. In particular a literal `+` in the decoded value (from a `%2B` in the query string) is rejected — use `+` or `%20` to encode a space, never `%2B`.
Extra spaces between groups (leading, trailing, or repeated) are tolerated, since they can easily result from URL formatting and carry no ambiguity — they only ever add required permissions, never remove them. The `|` operator is parsed strictly: `?perm=foo&perm=|bar` is an error, never a way to make `foo` optional.
## Wildcards
Wildcards work like filenames, with `:` and `/` acting as path separators:
- `*` matches any sequence of characters **within a single segment** (it never crosses a `:` or `/`)
- `**` matches any sequence of characters, **across separators**
- Part of a segment can be wildcarded, with required text on either or both sides
```text
?perm=myapp:*
```
This matches myapp:read and myapp:write, but **not** myapp:read:all — use `myapp:**` for that. Partial wildcards like `myapp:re*` or `myapp:r*d` match myapp:read. The same applies to path-based scopes: `myapp:path:/api/*` matches myapp:path:/api/clients but not myapp:path:/api/v2/clients — use `myapp:path:/api/**` to span path segments.
## Effective permissions
The permissions available to a session are determined as follows:
1. **Role permissions** — the role assigned to the user contains a set of permission UUIDs.
2. **Org grantable permissions** — only permissions that the user's organization is allowed to grant are effective.
3. **Domain filtering** — a permission can be restricted to a specific domain via its domain field. If the request's Host header does not match that domain, the permission is excluded.
The result is the set of effective permission scopes used for the perm check. Domain-restricted permissions let you grant a scope only for a specific site or subdomain without making it global.
## Examples
Require a single permission:
```text
?perm=myapp:login
```
Require two permissions:
```text
?perm=myapp:login&perm=myapp:api
```
Require any scope under myapp:
```text
?perm=myapp:*
```
Require myapp:login and either myapp:read or myapp:write:
```text
?perm=myapp:login&perm=myapp:read|myapp:write
```
+66
View File
@@ -0,0 +1,66 @@
# POST /auth/api/validate
[API overview](../API.md) · [`/auth/api/forward`](../forward.md) · [max_age](max-age.md) · [perm](perm.md)
Validate a session and renew its lifetime when needed. This endpoint is normally called by the browser/session validator, but backends may also call it directly when not behind a forward-auth proxy.
See also the [API overview](../API.md) and the [integration guide](../Integration.md).
## Query parameters
| Parameter | Description |
|-----------|-------------|
| perm | Required permissions. See the [perm argument](perm.md). |
| max_age | Require recent passkey use. See the [max_age argument](max-age.md). |
## Request headers
| Header | Expected value / note | How Paskia uses it |
|---|---|---|
| Host | Forwarded directly from the client | Verifying the session's bound host |
| Cookie | Forwarded directly or just cookie `__Host-paskia` | Session ID |
| X-Forwarded-For | Real client IP | Recorded in session data instead of the backend/proxy IP; requires FORWARDED_ALLOW_IPS to trust the immediate peer |
| User-Agent | Forward the original client UA if available; do not let your backend client add its own default | Recorded in session data only when the header is present; omitting it preserves the existing value |
See [integration documentation for backend validate requests](../Integration.md) for more detailed instructions, in particular for forwarding of client-provided headers.
## Response
The endpoint always responds with JSON.
### Success (200)
```json
{
"valid": true,
"renewed": false,
"ctx": {
"user": {
"uuid": "...",
"display_name": "John Smith",
"theme": "dark"
},
"org": {
"uuid": "...",
"display_name": "The Company Ltd."
},
"role": {
"uuid": "...",
"display_name": "Employee"
},
"permissions": ["auth:admin", "myapp:login"]
}
}
```
If the response includes a Set-Cookie header, the session has been renewed (renewed is true) and you should forward that cookie to the client so the browser updates its expiry. Not forwarding it means the session lifetime is not extended, so the user may need to re-authenticate sooner. Renewals are throttled, so frequent calls usually return renewed: false with no Set-Cookie.
### Failure (400 / 401 / 403)
| Status | Meaning |
|---|---|
| 400 | Malformed perm argument (see [perm](perm.md#syntax-errors)). |
| 401 | Session missing, expired, or max_age not satisfied. The response body includes auth metadata for the login/reauth iframe. |
| 403 | Session is valid but one or more requested permissions are missing. |
A failure response never contains a refreshed Set-Cookie.
+163
View File
@@ -0,0 +1,163 @@
# Apache APISIX Forward-Auth
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
This guide uses the Apache APISIX [`forward-auth`](https://apisix.apache.org/docs/apisix/plugins/forward-auth/) plugin to ask Paskia whether each request is allowed.
## Overview
APISIX adds the standard `X-Forwarded-*` headers automatically, but we still tell the plugin to forward `Host`, `Cookie`, and `Accept` from the client request. On a `204` response from Paskia we copy the `Remote-*` headers to the backend request.
## Admin API example
```sh
# Route that proxies /auth/ to Paskia without forward-auth.
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${admin_key}" \
-H 'Content-Type: application/json' \
-d '{
"id": "paskia-auth-ui",
"uri": "/auth/*",
"upstream": {
"nodes": { "localhost:4401": 1 },
"type": "roundrobin"
}
}'
# Protected route that uses Paskia forward-auth.
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${admin_key}" \
-H 'Content-Type: application/json' \
-d '{
"id": "app-protected",
"uri": "/*",
"priority": 10,
"plugins": {
"forward-auth": {
"uri": "http://localhost:4401/auth/api/forward?perm=myapp:login",
"request_headers": [
"Host",
"Cookie",
"Accept",
"X-Forwarded-Method",
"X-Forwarded-Uri",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Forwarded-For"
],
"upstream_headers": [
"Remote-User",
"Remote-Name",
"Remote-Groups",
"Remote-Org",
"Remote-Org-Name",
"Remote-Role",
"Remote-Role-Name",
"Remote-Session-Expires",
"Remote-Credential"
]
}
},
"upstream": {
"nodes": { "localhost:3000": 1 },
"type": "roundrobin"
}
}'
```
The `paskia-auth-ui` route has a higher priority (`priority` defaults to the same value for both routes; you can also rely on the more specific `/auth/*` URI matching first). Because it does not use the `forward-auth` plugin, users can reach the login/profile pages without already being authenticated.
## ADC / declarative example
```yaml
services:
- name: paskia-auth-ui
routes:
- name: auth-route
uris:
- /auth/*
upstream:
type: roundrobin
nodes:
- host: localhost
port: 4401
weight: 1
- name: app-protected
routes:
- name: app-route
uris:
- /*
plugins:
forward-auth:
uri: http://localhost:4401/auth/api/forward?perm=myapp:login
request_headers:
- Host
- Cookie
- Accept
- X-Forwarded-Method
- X-Forwarded-Uri
- X-Forwarded-Host
- X-Forwarded-Proto
- X-Forwarded-For
upstream_headers:
- Remote-User
- Remote-Name
- Remote-Groups
- Remote-Org
- Remote-Org-Name
- Remote-Role
- Remote-Role-Name
- Remote-Session-Expires
- Remote-Credential
upstream:
type: roundrobin
nodes:
- host: localhost
port: 3000
weight: 1
```
Apply it with:
```sh
adc sync -f paskia.yaml
```
## What APISIX sends to Paskia
APISIX automatically adds these headers to the auth request:
| Header | Value |
|---|---|
| `X-Forwarded-Method` | Original HTTP method |
| `X-Forwarded-Proto` | Request scheme (`http`/`https`) |
| `X-Forwarded-Host` | Original host |
| `X-Forwarded-Uri` | Original request URI |
| `X-Forwarded-For` | Client IP address |
We list them again in `request_headers` to make sure they are not accidentally filtered out when the list is explicit.
## Response headers
`upstream_headers` lists the `Remote-*` headers that APISIX copies from the auth response to the backend request. The plugin does not support a wildcard here, so each header must be named.
If you want Paskia's failure-response headers (such as `Content-Type` or `Set-Cookie`) to reach the client, list them in `client_headers`. For Paskia this is usually not needed; the response body already contains the JSON auth URL or the HTML login page.
## Adjusting requirements
Change the `uri` query string to require different permissions or recent authentication:
```yaml
uri: http://localhost:4401/auth/api/forward?perm=myapp:login
uri: http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min
uri: http://localhost:4401/auth/api/forward
```
The last form requires only authentication. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md).
## Notes
- The auth request is `GET` by default. Since the `forward-auth` plugin does not forward the request body unless `request_method` is set to `POST`, the default `GET` is the right choice for Paskia.
- Hop-by-hop headers are handled by APISIX when it builds the auth request, so no extra configuration is needed for `Connection`/`Upgrade`.
- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (`--auth-host`), route `auth.example.com` to Paskia instead of `/auth/`.
+4 -2
View File
@@ -1,13 +1,15 @@
# Paskia Caddy Configuration
[Caddy](https://caddyserver.com/) is a modern web server that makes setting up web services easy. We provide a few Caddy snippets that make the configuration even easier, although the `forward_auth` directive of Caddy can be used directly as well. Place the [auth folder](../caddy/auth) with the snippets `require` and `setup` where your config file is (e.g. `/etc/caddy/auth`)
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
[Caddy](https://caddyserver.com/) is a modern web server that makes setting up web services easy. We provide a few Caddy snippets that make the configuration even easier, although the `forward_auth` directive of Caddy can be used directly as well. Place the [auth folder](../../caddy/auth) with the snippets `require` and `setup` where your config file is (e.g. `/etc/caddy/auth`)
What these snippets do
- `setup`: Mount the auth UI at `/auth/` proxying to `:4401`
- `require`: Use `/auth/api/forward` for access control
- Render a login page or a permission denied page if needed (without changing URL)
Your backend may not use authentication at all, or it can make use of the user information passed via `Remote-*` headers by the authentication system, see [trusted headers](Headers.md) for details.
Your backend may not use authentication at all, or it can make use of the user information passed via `Remote-*` headers by the authentication system, see [trusted headers](../Headers.md) for details.
We assume the normal unprotected **Caddyfile** for your site looks like this:
+170
View File
@@ -0,0 +1,170 @@
# Envoy External Authorization (ext_authz)
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
This guide uses Envoy's [external authorization filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) (`ext_authz`) to ask Paskia whether each request is allowed.
## Overview
Envoy's `ext_authz` HTTP filter calls an external HTTP service before forwarding a request to the upstream. The filter needs to know which headers from the original request to send to Paskia, and which headers from Paskia's response to add to the upstream request or to the client response.
A minimal static configuration looks like this:
```yaml
static_resources:
listeners:
- name: app_listener
address:
socket_address:
address: 0.0.0.0
port_value: 8080
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
use_remote_address: true
route_config:
name: local_route
virtual_hosts:
- name: app
domains: ["*"]
routes:
# Pass /auth/ straight to Paskia, bypassing ext_authz.
- match:
prefix: "/auth/"
route:
cluster: paskia
typed_per_filter_config:
envoy.filters.http.ext_authz:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
disabled: true
# Protect everything else.
- match:
prefix: "/"
route:
cluster: app_backend
http_filters:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: v3
http_service:
server_uri:
uri: localhost:4401
cluster: paskia
timeout: 0.5s
# Send every auth check to /auth/api/forward with the
# required permission, regardless of the original path.
path_override: "/auth/api/forward?perm=myapp:login"
authorization_request:
allowed_headers:
patterns:
- exact: Host
- exact: Cookie
- exact: Accept
# Add the headers Paskia logs / expects.
headers_to_add:
- key: X-Forwarded-Method
value: "%REQ(:METHOD)%"
- key: X-Forwarded-Uri
value: "%REQ(:PATH)%"
- key: X-Forwarded-Proto
value: "%REQ(:SCHEME)%"
- key: X-Forwarded-For
value: "%REQ(X-Forwarded-For)%"
authorization_response:
# Forward every Remote-* header to the backend.
allowed_upstream_headers:
patterns:
- prefix: Remote-
# Forward the response Content-Type to the client on 401/403.
allowed_client_headers:
patterns:
- exact: Content-Type
failure_mode_allow: false
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: app_backend
connect_timeout: 0.25s
type: logical_dns
lb_policy: round_robin
load_assignment:
cluster_name: app_backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: localhost
port_value: 3000
- name: paskia
connect_timeout: 0.25s
type: logical_dns
lb_policy: round_robin
load_assignment:
cluster_name: paskia
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: localhost
port_value: 4401
```
## Important configuration details
- **`path_override`** — the auth request always goes to `/auth/api/forward?perm=myapp:login`, no matter which path the client requested. The original path is sent in `X-Forwarded-Uri` for Paskia to log.
- **`authorization_request.allowed_headers`** — Envoy only forwards the headers you explicitly allow. We allow `Host`, `Cookie`, and `Accept`. The `X-Forwarded-*` headers are added via `headers_to_add` so they are based on Envoy's view of the request, not spoofed client values.
- **`headers_to_add`** — Envoy supports substitution format strings such as `%REQ(:METHOD)%` and `%REQ(:PATH)%`. These set the headers Paskia uses for logging and host validation.
- **`authorization_response.allowed_upstream_headers`** — `prefix: Remote-` tells Envoy to copy every response header starting with `Remote-` to the upstream request. This also removes any client-supplied `Remote-*` headers, so the backend can trust them.
- **`authorization_response.allowed_client_headers`** — on a 401/403 response, Envoy forwards only the allowed response headers to the client. Paskia returns HTML or JSON with a `Content-Type` header, so we allow that. (Paskia does not set cookies on the forward-auth response.)
- **`typed_per_filter_config`** on the `/auth/` route disables `ext_authz` so users can reach the login/profile pages without already being authenticated.
## Per-route requirements
Different routes often need different permissions or `max_age` values. Use `typed_per_filter_config` on each route to override the `http_service.path_override`:
```yaml
routes:
- match:
prefix: "/reports"
route:
cluster: app_backend
typed_per_filter_config:
envoy.filters.http.ext_authz:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
check_settings:
http_service:
path_override: "/auth/api/forward?perm=myapp:reports&max_age=5min"
- match:
prefix: "/"
route:
cluster: app_backend
typed_per_filter_config:
envoy.filters.http.ext_authz:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
check_settings:
http_service:
path_override: "/auth/api/forward?perm=myapp:login"
```
See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for query parameter syntax.
## WebSocket support for `/auth/`
If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them.
## Notes
- Envoy's `ext_authz` filter does not send the request body to the auth server by default. For Paskia this is fine.
- If Paskia is running behind TLS, use `https://` in `server_uri.uri` and configure the cluster's transport socket.
- The `failure_mode_allow: false` setting means that if Paskia cannot be reached, Envoy will reject the request. In testing you may prefer `true`, but use `false` in production so a failed auth service cannot accidentally allow traffic.
+114
View File
@@ -0,0 +1,114 @@
# HAProxy Forward-Auth
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
HAProxy does not have a built-in forward-auth primitive, but the community [`haproxy-auth-request`](https://github.com/TimWolla/haproxy-auth-request) Lua script provides an `auth-intercept` action that works very similarly to Nginx's `auth_request`. It makes an internal HTTP request to Paskia and copies the response headers to the backend request.
## Requirements
- HAProxy 2.2 or newer (2.0+ may work but 2.2+ supports all features shown here).
- Compiled with `USE_LUA=1`.
- The [`haproxy-auth-request`](https://github.com/TimWolla/haproxy-auth-request) Lua script loaded.
- The [`haproxy-lua-http`](https://github.com/haproxytech/haproxy-lua-http) dependency in the Lua path.
## Overview
```haproxy
global
lua-load /usr/share/haproxy/auth-request.lua
defaults
mode http
timeout connect 5s
timeout client 30s
timeout server 30s
# Backend that runs the Paskia auth check.
backend paskia_auth
server paskia 127.0.0.1:4401
# Backend that runs the Paskia UI / WebSocket / API.
backend paskia_ui
server paskia 127.0.0.1:4401
# Your protected application.
backend app_backend
server app 127.0.0.1:3000
frontend app
bind *:80
# 1. Route /auth/ straight to Paskia, bypassing the auth check.
acl is_auth path_beg /auth/
use_backend paskia_ui if is_auth
# 2. Add the headers Paskia logs / expects. These are then copied to the
# auth subrequest by auth-intercept.
http-request set-header X-Forwarded-Method %[method]
http-request set-header X-Forwarded-Uri %[url]
# 3. Run the auth check. The parameters are:
# backend path method
# req-headers success-headers failure-headers
#
# - req-headers: headers copied from client to Paskia.
# - success-headers: headers copied from Paskia response to backend request.
# - failure-headers: headers copied from Paskia response to client response.
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:login GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
# 4. If the subrequest was not successful, deny the request.
http-request deny if ! { var(txn.auth_response_successful) -m bool }
default_backend app_backend
```
## What the configuration does
1. **Route `/auth/` to Paskia** — users must be able to reach the login/profile UI without already being authenticated. HAProxy will proxy WebSocket upgrade headers automatically for this backend when the client requests them.
2. **Set `X-Forwarded-Method` and `X-Forwarded-Uri`** — HAProxy adds these headers to the incoming request so the Lua script can copy them to the auth subrequest. `%[method]` returns the HTTP method and `%[url]` returns the path and query string.
3. **`lua.auth-intercept`** — sends a `GET` request to `/auth/api/forward?perm=myapp:login` on the `paskia_auth` backend. It copies the listed request headers (including the dynamic ones we just set) to the auth subrequest.
4. **On success (`2xx`)** — copies every response header matching `Remote-*` from Paskia to the backend request. This overrides any client-supplied `Remote-*` headers, so the backend can trust them.
5. **On failure (`4xx`)** — copies all response headers (`*`) to the client response and uses Paskia's response body, so the browser gets the login HTML or the JSON auth URL.
6. **Deny if auth failed** — the final `http-request deny` rule is a safety net. In practice, `auth-intercept` with `*` as the failure-headers already terminates the transaction with Paskia's response.
## Backend definition for the auth subrequest
The `paskia_auth` backend can be the same physical server as `paskia_ui`, but using a separate backend is convenient because the Lua script will use the first available server in the backend. The auth subrequest is a plain HTTP request, so no special WebSocket options are needed here.
```haproxy
backend paskia_auth
server paskia 127.0.0.1:4401
```
## Per-route permissions
You can run different auth checks for different paths by using HAProxy ACLs. Place the more specific rules before the generic one:
```haproxy
frontend app
bind *:80
acl is_auth path_beg /auth/
use_backend paskia_ui if is_auth
acl is_reports path_beg /reports
http-request set-header X-Forwarded-Method %[method] if is_reports
http-request set-header X-Forwarded-Uri %[url] if is_reports
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:reports&max_age=5min GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* * if is_reports
http-request set-header X-Forwarded-Method %[method]
http-request set-header X-Forwarded-Uri %[url]
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:login GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
http-request deny if ! { var(txn.auth_response_successful) -m bool }
default_backend app_backend
```
See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for query parameter syntax.
## Notes
- The Lua script strips the request body from the auth subrequest, so Paskia's `/auth/api/forward` will only see the headers.
- HAProxy variables are limited to alphanumeric characters, dots, and underscores, but the script already normalizes header names for you (e.g. `Remote-User` becomes `req.auth_response_header.remote_user`). The `Remote-*` glob pattern in the success-headers argument handles this automatically.
- The auth backend must be reachable without TLS. If you need TLS to Paskia, run a local TCP forwarder or use HAProxy's Lua HTTP support directly (not covered by this script).
- If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia backend and start Paskia with `--auth-host auth.example.com` instead of exposing `/auth/` on every site.
+47
View File
@@ -0,0 +1,47 @@
# Forward-Auth Proxy Guides
[`/auth/api/forward`](../api/forward.md) · [Trusted headers](../Headers.md)
These guides show how to protect a backend application with Paskia using the forward-auth (also called "external authentication") mechanism. The reverse proxy asks Paskia whether a request is allowed before forwarding it to the protected service.
For details about the endpoint the proxy calls, see [`/auth/api/forward`](../api/forward.md). For the headers your backend receives on successful requests, see [Trusted Headers](../Headers.md).
## Available guides
- [Caddy](caddy.md) — fully supported with ready-to-use snippets (`auth/setup` and `auth/require`).
- [Nginx](nginx.md) — using the `auth_request` module.
- [Traefik](traefik.md) — using the `ForwardAuth` middleware.
- [Apache APISIX](apisix.md) — using the `forward-auth` plugin.
- [Envoy](envoy.md) — using the `ext_authz` HTTP filter.
- [HAProxy](haproxy.md) — using a Lua auth request.
## Common requirements
No matter which proxy you use, the auth subrequest must:
1. Be sent to `GET /auth/api/forward` on the Paskia backend. By default Paskia listens on `localhost:4401`; set the `AUTH_UPSTREAM` environment variable in our Caddy snippets, or point your proxy at wherever Paskia is running.
2. Include the query parameters Paskia needs for access control:
- `perm` — required permission scope, repeatable (e.g. `perm=myapp:login`). See [perm argument](../api/perm.md).
- `max_age` — how recently the user must have authenticated (e.g. `max_age=5min`). See [max_age argument](../api/max-age.md).
3. Forward these request headers from the original client request:
- `Host` — the site the user is visiting.
- `Cookie` — the session cookie, normally `__Host-paskia`.
- `X-Forwarded-Method` — the original HTTP method (e.g. `GET`, `POST`).
- `X-Forwarded-Uri` — the original path and query string (e.g. `/reports?foo=bar`).
- `Accept` — decides whether a 401/403 response should be HTML (browser) or JSON (API/fetch).
4. Strip hop-by-hop headers (`Connection`, `Upgrade`, `Transfer-Encoding`, `Keep-Alive`, `Proxy-Connection`, `TE`) from the auth subrequest. The auth check is a plain HTTP request and must not carry WebSocket/body framing headers.
5. On a `204 No Content` response, copy the `Remote-*` response headers to the request that is forwarded to the protected backend. The headers are the whole point of the auth check.
6. On a 401/403 response, send Paskia's response back to the client without contacting the protected backend.
7. Also proxy the `/auth/` path prefix to Paskia so the login/profile UI, API endpoints, and WebSockets are reachable. Paskia's WebSocket endpoints need `Upgrade` and `Connection` headers passed through for that path.
## Backend usage
After the proxy forwards the request, your backend can read the trusted headers. For example, in Python/FastAPI:
```python
user_id = request.headers.get("Remote-User")
org_id = request.headers.get("Remote-Org")
permissions = request.headers.get("Remote-Groups", "").split(",")
```
Only trust headers that come from the proxy; never trust `Remote-*` headers that arrive directly from the internet. Your proxy configuration should strip any client-supplied `Remote-*` headers before the auth check.
+130
View File
@@ -0,0 +1,130 @@
# Nginx Forward-Auth
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
This guide uses the [Nginx `auth_request`](http://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to ask Paskia whether each request is allowed before proxying it to your backend.
## Overview
```nginx
server {
listen 80;
server_name app.example.com;
# 1. Proxy /auth/ to Paskia (HTTP + WebSocket).
location /auth/ {
proxy_pass http://localhost:4401;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# 2. All other paths are protected.
location / {
auth_request /auth-internal;
# 3. Capture the Remote-* headers from the auth response and
# pass them to the backend request.
auth_request_set $remote_user $upstream_http_remote_user;
auth_request_set $remote_name $upstream_http_remote_name;
auth_request_set $remote_groups $upstream_http_remote_groups;
auth_request_set $remote_org $upstream_http_remote_org;
auth_request_set $remote_org_name $upstream_http_remote_org_name;
auth_request_set $remote_role $upstream_http_remote_role;
auth_request_set $remote_role_name $upstream_http_remote_role_name;
auth_request_set $remote_session_exp $upstream_http_remote_session_expires;
auth_request_set $remote_credential $upstream_http_remote_credential;
proxy_set_header Remote-User $remote_user;
proxy_set_header Remote-Name $remote_name;
proxy_set_header Remote-Groups $remote_groups;
proxy_set_header Remote-Org $remote_org;
proxy_set_header Remote-Org-Name $remote_org_name;
proxy_set_header Remote-Role $remote_role;
proxy_set_header Remote-Role-Name $remote_role_name;
proxy_set_header Remote-Session-Expires $remote_session_exp;
proxy_set_header Remote-Credential $remote_credential;
# 4. The proxy_set_header lines above override any client-supplied
# Remote-* headers, so the backend receives only the values from
# Paskia's auth response.
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 5. Internal endpoint used by auth_request.
location = /auth-internal {
internal;
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:login;
proxy_http_version 1.1;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Forwarded-Method $request_method;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header Accept $http_accept;
# Drop hop-by-hop headers that must not reach the auth subrequest.
proxy_set_header Connection "";
proxy_set_header Upgrade "";
proxy_set_header Transfer-Encoding "";
proxy_set_header Keep-Alive "";
proxy_set_header Proxy-Connection "";
proxy_set_header TE "";
}
}
# WebSocket upgrade map.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
## What the configuration does
1. **`/auth/`** is proxied straight to Paskia. The `Upgrade` and `Connection` headers are passed through so WebSocket endpoints such as `/auth/ws/authenticate` work.
2. **`/`** is protected by `auth_request /auth-internal`. Nginx makes an internal subrequest to that location before proxying the original request to the app.
3. **`auth_request_set`** captures each `Remote-*` header from the Paskia response. Nginx does not have wildcard capture, so every header must be listed explicitly. The captured values are then attached to the backend request with `proxy_set_header`.
4. The `proxy_set_header` lines override any `Remote-*` headers the client might have sent, so the backend can trust the headers that come from Paskia.
5. **`/auth-internal`** is the actual forward-auth call. It must:
- point to `/auth/api/forward`,
- not forward the request body (`proxy_pass_request_body off;`),
- pass `Host`, `Cookie`, `Accept`, `X-Forwarded-Method`, and `X-Forwarded-Uri`,
- strip hop-by-hop headers.
## Adjusting requirements
Change the query string on the `proxy_pass` line inside `/auth-internal` to require different permissions or recent authentication:
```nginx
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:login;
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min;
proxy_pass http://localhost:4401/auth/api/forward?"";
```
The last form (`?""`) requires only authentication and no specific permission. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for details.
## Public paths
To leave some paths unprotected (for example `/.well-known/` or `/static/`), add `location` blocks before the protected `location /` block:
```nginx
location /.well-known/ {
root /var/www;
}
location /static/ {
root /var/www;
}
```
## Notes
- Nginx `auth_request` always makes the auth subrequest with the same HTTP method as the original request, but the body is suppressed by the configuration above. Paskia uses the `X-Forwarded-Method` and `X-Forwarded-Uri` headers for logging.
- The `auth_request_set` variables are empty when the auth request fails, so on 401/403 the backend is never contacted; Nginx returns Paskia's response directly.
- For HTTPS, add `listen 443 ssl;` and your certificate configuration as usual.
+126
View File
@@ -0,0 +1,126 @@
# Traefik ForwardAuth
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
This guide uses Traefik's [ForwardAuth middleware](https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/forwardauth/) to ask Paskia whether each request is allowed.
## Overview
A typical dynamic (YAML) configuration looks like this:
```yaml
http:
routers:
app:
rule: "Host(`app.example.com`)"
service: app-backend
middlewares:
- paskia-auth
# Route /auth/ straight to Paskia, bypassing the auth middleware.
auth:
rule: "Host(`app.example.com`) && PathPrefix(`/auth/`)"
service: paskia
middlewares: []
middlewares:
paskia-auth:
forwardAuth:
address: "http://localhost:4401/auth/api/forward?perm=myapp:login"
# Forward every Remote-* header from the auth response to the backend.
authResponseHeadersRegex: "^Remote-"
# Explicitly pass the headers Paskia needs. If left empty, all headers
# are forwarded; being explicit avoids accidentally leaking hop-by-hop
# headers to the auth server.
authRequestHeaders:
- Host
- Cookie
- Accept
- X-Forwarded-Method
- X-Forwarded-Uri
- X-Forwarded-Host
- X-Forwarded-Proto
- X-Forwarded-For
services:
app-backend:
loadBalancer:
servers:
- url: "http://localhost:3000"
paskia:
loadBalancer:
servers:
- url: "http://localhost:4401"
```
## What Traefik sends automatically
Traefik's ForwardAuth middleware sends the auth request to the configured `address` and includes the following headers derived from the original request:
| Header | Value |
|---|---|
| `X-Forwarded-Method` | Original HTTP method |
| `X-Forwarded-Proto` | Original protocol (`http`/`https`) |
| `X-Forwarded-Host` | Original host |
| `X-Forwarded-Uri` | Original request URI (path and query) |
| `X-Forwarded-For` | Client IP address |
These are exactly the headers Paskia logs. You should still include them in `authRequestHeaders` if you set that list explicitly, to make sure they are not filtered out.
## Response headers
`authResponseHeadersRegex: "^Remote-"` tells Traefik to copy every response header starting with `Remote-` from Paskia's `204` response and add it to the request that is forwarded to your backend. It also strips any `Remote-*` headers that the client may have sent, so the backend can trust the values.
For stricter control, you can list the headers explicitly instead of using the regex:
```yaml
authResponseHeaders:
- Remote-User
- Remote-Name
- Remote-Groups
- Remote-Org
- Remote-Org-Name
- Remote-Role
- Remote-Role-Name
- Remote-Session-Expires
- Remote-Credential
```
## Proxying `/auth/` to Paskia
The `/auth/` router above forwards all authentication UI, API, and WebSocket traffic to Paskia. Because this router does **not** use the `paskia-auth` middleware, users can reach the login page and profile UI without being authenticated first. Traefik handles WebSocket upgrades automatically when the client requests them.
If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and start Paskia with `--auth-host auth.example.com`.
## Adjusting requirements
Change the `address` query string to require different permissions or recent authentication:
```yaml
address: "http://localhost:4401/auth/api/forward?perm=myapp:login"
address: "http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min"
address: "http://localhost:4401/auth/api/forward"
```
The last form requires only authentication, no specific permission. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md).
## Docker labels example
When using Traefik with Docker Compose, you can define the middleware with labels:
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
- "traefik.http.routers.myapp.middlewares=paskia-auth"
- "traefik.http.middlewares.paskia-auth.forwardauth.address=http://localhost:4401/auth/api/forward?perm=myapp:login"
- "traefik.http.middlewares.paskia-auth.forwardauth.authResponseHeadersRegex=^Remote-"
- "traefik.http.middlewares.paskia-auth.forwardauth.authRequestHeaders=Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri"
```
## Notes
- By default ForwardAuth sends a request without the original body. If you need to forward the body for logging/validation, set `forwardBody: true` and a sensible `maxBodySize`, but for Paskia this is not required.
- Paskia does not set cookies on the forward-auth response; it only returns `Remote-*` headers. Use the JavaScript helpers from the [paskia](https://www.npmjs.com/package/paskia) package for session renewal in the frontend.
- For HTTPS, use `https://` in the `address` and configure TLS options (`tls.insecureSkipVerify: true` only for testing).
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+38 -26
View File
@@ -10,7 +10,7 @@
"devDependencies": {
"@playwright/test": "^1.49.0",
"@simplewebauthn/browser": "^13.1.2",
"@types/bun": "^1.3.3",
"@types/node": "*",
"c8": "^10.1.3"
}
},
@@ -92,11 +92,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.57.0",
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
@@ -107,17 +109,11 @@
},
"node_modules/@simplewebauthn/browser": {
"version": "13.2.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/bun": {
"version": "1.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.3.3"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -126,7 +122,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.10.1",
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -176,14 +174,6 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/bun-types": {
"version": "1.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/c8": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
@@ -412,6 +402,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -426,6 +431,7 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -674,11 +680,13 @@
}
},
"node_modules/playwright": {
"version": "1.57.0",
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
@@ -691,7 +699,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -712,9 +722,9 @@
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -894,6 +904,8 @@
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
+8 -8
View File
@@ -5,18 +5,18 @@
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
"type": "module",
"scripts": {
"test": "bunx playwright test",
"test:headed": "bunx playwright test --headed",
"test:debug": "bunx playwright test --debug",
"test:ui": "bunx playwright test --ui",
"test:coverage": "COVERAGE=1 bunx playwright test",
"report": "bunx playwright show-report",
"install:browsers": "bunx playwright install chromium"
"test": "npx playwright test",
"test:headed": "npx playwright test --headed",
"test:debug": "npx playwright test --debug",
"test:ui": "npx playwright test --ui",
"test:coverage": "COVERAGE=1 npx playwright test",
"report": "npx playwright show-report",
"install:browsers": "npx playwright install chromium"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@simplewebauthn/browser": "^13.1.2",
"@types/bun": "^1.3.3",
"@types/node": "*",
"c8": "^10.1.3"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test'
* Playwright configuration for Paskia E2E tests.
* Uses Chrome's Virtual Authenticator for automated passkey testing.
*
* Run with: bun run test
* Run with: npm test
*/
export default defineConfig({
+5 -5
View File
@@ -148,10 +148,10 @@ test.describe('Passkey Authentication E2E', () => {
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
expect(userInfo.ctx.user.uuid).toBe(userUuid)
expect(userInfo.ctx.user.display_name).toBe('Admin User')
expect(userInfo.user.uuid).toBe(userUuid)
expect(userInfo.user.display_name).toBe('Admin User')
expect(userInfo.credentials).toBeDefined()
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
expect(Object.keys(userInfo.credentials).length).toBeGreaterThanOrEqual(1)
// Navigate to profile and take screenshot
const cookieName = getSessionCookieName()
@@ -169,8 +169,8 @@ test.describe('Passkey Authentication E2E', () => {
await page.screenshot({ path: 'test-results/profile-view.png' })
console.log('✓ Screenshot saved: test-results/profile-view.png')
console.log(`✓ User info retrieved: ${userInfo.ctx.user.display_name}`)
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
console.log(`✓ User info retrieved: ${userInfo.user.display_name}`)
console.log(`✓ Credentials count: ${Object.keys(userInfo.credentials).length}`)
})
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
+34 -18
View File
@@ -10,6 +10,12 @@ import {
logout,
} from './fixtures/passkey-helpers'
import type { Page, Frame } from '@playwright/test'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* E2E tests for API mode authentication flows.
@@ -55,8 +61,18 @@ async function clearSessionCookie(page: Page): Promise<void> {
/**
* Set up the test page using the examples page directly.
* The examples page already has iframe handling - we just add a Promise wrapper.
* We route the paskia-js module request to serve from the local dist.
*/
async function setupTestHarness(page: Page): Promise<void> {
// Serve paskia.js from the local filesystem since the server doesn't serve /paskia-js/
const paskiaJsPath = join(__dirname, '..', '..', 'paskia-js', 'dist', 'paskia.js')
await page.route('**/paskia-js/dist/paskia.js', async route => {
const body = readFileSync(paskiaJsPath, 'utf-8')
await route.fulfill({
body,
contentType: 'application/javascript',
})
})
// Navigate to the examples page which already has the auth iframe handling
await page.goto(`${baseUrl}/auth/examples/`)
}
@@ -143,8 +159,8 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s
* Wait for auth iframe to appear and return a reference to it.
*/
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
await page.waitForSelector('#auth-iframe', { timeout })
const iframe = page.frameLocator('#auth-iframe')
await page.waitForSelector('#paskia-iframe', { timeout })
const iframe = page.frameLocator('#paskia-iframe')
// Wait for iframe content to load
await iframe.locator('.view-root').waitFor({ timeout })
return page.frame({ url: /\/auth\/restricted\// })!
@@ -154,14 +170,14 @@ async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
* Wait for auth iframe to disappear.
*/
async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> {
await page.waitForSelector('#auth-iframe', { state: 'detached', timeout })
await page.waitForSelector('#paskia-iframe', { state: 'detached', timeout })
}
/**
* Click Back button in auth iframe.
*/
async function clickBackInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Back' }).click()
}
@@ -169,7 +185,7 @@ async function clickBackInIframe(page: Page): Promise<void> {
* Click Login button in auth iframe.
*/
async function clickLoginInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Login' }).click()
}
@@ -177,7 +193,7 @@ async function clickLoginInIframe(page: Page): Promise<void> {
* Click Verify button in auth iframe (for reauth mode).
*/
async function clickVerifyInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Verify' }).click()
}
@@ -185,7 +201,7 @@ async function clickVerifyInIframe(page: Page): Promise<void> {
* Click Logout button in auth iframe (for forbidden mode).
*/
async function clickLogoutInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Logout' }).click()
}
@@ -200,11 +216,11 @@ test.describe('API Mode - 401 Login Flow', () => {
await clearSessionCookie(page)
// Make API call that triggers 401 (don't await - it blocks until iframe resolves)
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST').catch(e => e)
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET').catch(e => e)
console.log('✓ Auth iframe appeared on 401')
// Verify it's in login mode (not reauth)
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('🔐')
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible()
@@ -252,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => {
await setupTestHarness(page)
// Make API call that triggers 401
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST')
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET')
// Wait for auth iframe to appear
await waitForAuthIframe(page)
@@ -268,7 +284,7 @@ test.describe('API Mode - 401 Login Flow', () => {
// Wait for API call to complete and verify result
const result = await apiCallPromise
expect(result.status).toBe(200)
expect(result.data.ctx).toBeDefined()
expect(result.data.user).toBeDefined()
console.log('✓ API call succeeded after authentication')
// Save the session for other tests
@@ -314,7 +330,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
console.log('✓ Reauth iframe appeared (session older than max_age)')
// Verify it's in reauth mode
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible()
@@ -362,7 +378,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
await waitForAuthIframe(page)
console.log('✓ Reauth iframe appeared')
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
// Click Verify - virtual authenticator handles passkey
@@ -394,7 +410,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
// Check if auth iframe appeared
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
if (!iframeAppeared) {
// User might already have admin permission
@@ -410,7 +426,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
// Wait for view to stabilize and check mode
await page.waitForTimeout(500)
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
const headingText = await iframe.locator('h1').textContent()
console.log(` Heading: ${headingText}`)
@@ -459,7 +475,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
// Check if auth iframe appeared
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
if (!iframeAppeared) {
const result = await apiCallPromise
@@ -470,7 +486,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
}
await waitForAuthIframe(page)
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await page.waitForTimeout(500)
const headingText = await iframe.locator('h1').textContent()
@@ -534,7 +550,7 @@ test.describe('API Mode - Direct API Response Format', () => {
expect(data.auth).toBeDefined()
expect(data.auth.iframe).toBeDefined()
expect(data.auth.mode).toBe('login')
expect(data.auth.iframe).toContain('/auth/restricted/')
expect(data.auth.iframe).toContain('/auth/restricted/iframe')
console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`)
})
+62 -12
View File
@@ -44,7 +44,7 @@ export interface UserInfo {
sign_count: number
is_current_session: boolean
}>
aaguid_info: Record<string, { name: string; icon_light?: string; icon_dark?: string }>
aaguid_info: Record<string, { name: string; icon?: string; icon_dark?: string }>
sessions: Array<{
id: string
credential: string
@@ -193,7 +193,8 @@ export async function registerPasskey(
baseUrl: string,
options: { resetToken?: string; displayName?: string } = {}
): Promise<RegistrationResult> {
return await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
// Step 1: Do WebSocket registration + exchange code in browser context
const wsResult = await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
// Build WebSocket URL with query parameters
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
const params: string[] = []
@@ -203,6 +204,7 @@ export async function registerPasskey(
return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl)
let done = false
ws.onopen = () => {
console.log('WebSocket connected for registration')
@@ -213,15 +215,31 @@ export async function registerPasskey(
// Check for error response
if (data.detail) {
done = true
ws.close()
reject(new Error(data.detail))
return
}
// Check if this is the final success response
if (data.session_token) {
// Check if this is the final success response (exchange_code flow)
if (data.exchange_code) {
done = true
ws.close()
resolve(data)
// Exchange the code for a session cookie
try {
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
})
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
resolve({
user: data.user,
credential: data.credential,
message: data.message || 'Registration successful',
})
} catch (err: any) {
reject(new Error(`Code exchange failed: ${err.message}`))
}
return
}
@@ -293,12 +311,21 @@ export async function registerPasskey(
}
ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) {
if (!done && !event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
}
}
})
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
// Step 2: Extract the session token from the cookie set by the exchange
const cookies = await page.context().cookies()
const cookieName = getSessionCookieName()
const sessionCookie = cookies.find(c => c.name === cookieName)
return {
...wsResult,
session_token: sessionCookie?.value || '',
}
}
/**
@@ -309,11 +336,13 @@ export async function authenticatePasskey(
page: Page,
baseUrl: string
): Promise<AuthenticationResult> {
return await page.evaluate(async ({ baseUrl }) => {
// Step 1: Do WebSocket authentication + exchange code in browser context
const wsResult = await page.evaluate(async ({ baseUrl }) => {
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate`
return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl)
let done = false
ws.onopen = () => {
console.log('WebSocket connected for authentication')
@@ -324,15 +353,27 @@ export async function authenticatePasskey(
// Check for error response
if (data.detail) {
done = true
ws.close()
reject(new Error(data.detail))
return
}
// Check if this is the final success response
if (data.session_token) {
// Check if this is the final success response (exchange_code flow)
if (data.exchange_code) {
done = true
ws.close()
resolve(data)
// Exchange the code for a session cookie
try {
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
})
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
resolve({ user: data.user })
} catch (err: any) {
reject(new Error(`Code exchange failed: ${err.message}`))
}
return
}
@@ -395,12 +436,21 @@ export async function authenticatePasskey(
}
ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) {
if (!done && !event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
}
}
})
}, { baseUrl })
// Step 2: Extract the session token from the cookie set by the exchange
const cookies = await page.context().cookies()
const cookieName = getSessionCookieName()
const sessionCookie = cookies.find(c => c.name === cookieName)
return {
...wsResult,
session_token: sessionCookie?.value || '',
}
}
/**
@@ -429,7 +479,7 @@ export async function getUserInfo(
sessionToken: string
): Promise<UserInfo> {
const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
const response = await page.request.get(`${baseUrl}/auth/api/user-info`, {
headers: {
'Cookie': `${cookieName}=${sessionToken}`,
},
+16 -6
View File
@@ -1,6 +1,6 @@
import { spawn } from 'child_process'
import { execSync, spawn } from 'child_process'
import { join, dirname } from 'path'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -31,6 +31,11 @@ export default async function globalSetup() {
mkdirSync(testDataDir, { recursive: true })
}
// Build the package first
console.log(' Building package with uv build...')
execSync('uv build', { cwd: projectRoot, stdio: 'inherit' })
console.log(' ✅ Build complete\n')
console.log(' Starting server with in-memory database...')
if (COLLECT_COVERAGE) {
console.log(' 📊 Coverage collection enabled for Python backend')
@@ -42,16 +47,21 @@ export default async function globalSetup() {
const serverArgs = COLLECT_COVERAGE
? [
'run', 'coverage', 'run', '--parallel-mode',
'-m', 'paskia.fastapi', 'localhost:4404',
'-m', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost'
]
: [
'run', 'paskia', 'localhost:4404',
'run', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost'
]
// Use a temporary jsonl file for test database
const testDbFile = join(testDataDir, 'test-db.jsonl')
// Use a fresh database file for tests
const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) {
console.log(' Removing stale test database...')
rmSync(testDbFile, { force: true, recursive: true })
}
// Start the server using Node's spawn
const serverProcess = spawn('uv', serverArgs, {
+2 -2
View File
@@ -60,10 +60,10 @@ export default async function globalTeardown() {
}
// Clean up test database
const testDbFile = join(testDataDir, 'test-db.jsonl')
const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) {
console.log(' Removing test database...')
rmSync(testDbFile)
rmSync(testDbFile, { force: true, recursive: true })
}
// Generate Python coverage report if coverage was collected
+1 -1
View File
@@ -8,7 +8,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["bun-types"]
"types": ["node"]
},
"include": ["tests/**/*.ts", "playwright.config.ts"],
"exclude": ["node_modules"]
+38 -107
View File
@@ -8,26 +8,6 @@
:root {
color-scheme: light dark; /* Automatic themes by browser */
}
/* Login/reauth/forbidden dialog will appear in this iframe */
#auth-iframe {
/* Full viewport overlay */
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
/* Optional transparent background with optional blur backdrop */
color-scheme: auto;
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
/* Prevent background scroll when auth-iframe is shown */
body:has(#auth-iframe) {
overflow: hidden;
}
</style>
</head>
<body>
@@ -47,7 +27,7 @@
<div class="section">
<h2>API Mode (not leaving the page)</h2>
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
<button onclick="apiCall('/auth/api/user-info', 'POST')">📋 Get User Info</button>
<button onclick="apiCall('/auth/api/user-info', 'GET')">📋 Get User Info</button>
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
<button onclick="logout()">🚪 Logout</button>
@@ -65,99 +45,50 @@
</div>
</div>
<script>
<script type="module">
import { apiFetch, apiJson, AuthCancelledError } from '/paskia-js/dist/paskia.js'
const output = document.getElementById('output');
let pendingCall = null; // Stores the API call to retry after auth
// The auth iframe posts messages when authentication completes or is cancelled.
// Message types: 'auth-success' (proceed), 'auth-back' (user cancelled)
// Errors during auth stay in the dialog allowing retry, no message is sent.
window.addEventListener('message', (event) => {
const { type, message } = event.data || {};
if (type === 'auth-success') {
log('✓ Authentication successful, retrying...');
hideAuthIframe();
// Retry the original API call that triggered authentication
if (pendingCall) {
const { url, method } = pendingCall;
pendingCall = null;
apiCall(url, method);
}
} else if (type === 'auth-back') {
log(message || 'Authentication cancelled');
hideAuthIframe();
pendingCall = null;
}
});
// Make an API call, handling 401/403 by showing the auth iframe.
// The server returns JSON with auth.iframe URL when authentication is needed.
async function apiCall(url, method = 'GET') {
log(`${method} ${url}...`);
const response = await fetch(url, { method });
// Server returns 401 (login/reauth) or 403 (missing permissions)
// with a JSON body containing the iframe URL for authentication
if (response.status === 401 || response.status === 403) {
const data = await response.json();
if (data.auth?.iframe) {
const mode = data.auth.mode; // 'login' or 'reauth'
log(`${mode === 'reauth' ? 'Re-authentication' : 'Authentication'} required...`);
pendingCall = { url, method };
showAuthIframe(data.auth.iframe);
return;
}
log(`Error: ${response.status} - ${data.detail}`);
return;
}
// Forward endpoint returns 204 on success (Caddy then adds Remote-* headers)
if (response.status === 204) {
log('✓ Success (204 No Content)\nHeaders:\n' +
[...response.headers].filter(([k]) => k.startsWith('remote-'))
.map(([k, v]) => ` ${k}: ${v}`).join('\n'));
return;
}
if (!response.ok) {
log(`Error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
log('✓ Response:\n' + JSON.stringify(data, null, 2));
}
async function logout() {
await fetch('/auth/api/logout', { method: 'POST' });
log('Logged out');
}
// Create fullscreen iframe for authentication.
// The 'allow' attribute enables WebAuthn (passkey) API inside the iframe.
function showAuthIframe(url) {
hideAuthIframe();
const iframe = document.createElement('iframe');
iframe.id = 'auth-iframe';
iframe.src = url;
document.body.appendChild(iframe);
log("Authentication dialog open...")
}
function hideAuthIframe() {
document.getElementById('auth-iframe')?.remove();
}
function log(msg) {
output.textContent = msg;
}
// Make an API call using paskia module (handles 401/403 automatically)
window.apiCall = async function(url, method = 'GET') {
log(`${method} ${url}...`);
try {
const response = await apiFetch(url, { method });
// Forward endpoint returns 204 on success
if (response.status === 204) {
log('✓ Success (204 No Content)');
return;
}
if (!response.ok) {
log(`Error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
log('✓ Response:\n' + JSON.stringify(data, null, 2));
} catch (e) {
if (e instanceof AuthCancelledError) {
log('Authentication cancelled');
} else {
log(`Error: ${e.message}`);
}
}
}
window.logout = async function() {
await fetch('/auth/api/logout', { method: 'POST' });
log('Logged out');
}
// Browser mode: open the forward endpoint directly in a new window.
// When Accept: text/html, the server redirects to the login page if needed,
// then back to the original URL after authentication.
function browserNav(url) {
window.browserNav = function(url) {
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).');
window.open(url, '_blank');
}
+33 -76
View File
@@ -13,8 +13,8 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson, SessionValidator, createAuthIframe, removeAuthIframe } from 'paskia'
import { getAuthIframeUrl } from '@/utils/api'
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue'
import HostProfileView from '@/components/HostProfileView.vue'
@@ -47,80 +47,50 @@ const isHostMode = computed(() => {
const configuredHost = normalizeHost(authHost)
return currentHost !== configuredHost
})
const userUuid = computed(() => store.userInfo?.ctx.user.uuid)
function terminateSession() {
function onSessionLost(e) {
store.userInfo = null
viewState.value = 'terminal'
store.ctx = null
if (e?.name === 'AuthCancelledError') {
viewState.value = 'terminal'
} else {
store.showMessage(e?.message || 'Session lost', 'error', 5000)
viewState.value = 'terminal'
}
}
const userUuidGetter = () => store.userInfo?.ctx.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
const userUuidGetter = () => store.ctx?.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
onMounted(() => sessionValidator.start())
onUnmounted(() => sessionValidator.stop())
async function loadUserInfo() {
viewState.value = 'loading'
loadingMessage.value = 'Loading...'
try {
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
// apiJson handles 401/403 with auth.iframe automatically:
// shows overlay iframe, waits for auth, retries the request.
const [validateData, userInfoData] = await Promise.all([
apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
])
store.userInfo = userInfoData
store.ctx = validateData.ctx
updateThemeFromSession(store.userInfo)
// Verify that the user UUIDs match between user-info and validate responses
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
console.error('User UUID mismatch between user-info and validate responses')
window.location.reload()
return
}
viewState.value = 'profile'
return true
} catch {
store.userInfo = null
return false
}
}
async function showAuthIframe() {
const url = await getAuthIframeUrl('login')
createAuthIframe(url)
loadingMessage.value = 'Authentication required...'
}
function handleAuthMessage(event) {
const data = event.data
if (!data?.type) return
switch (data.type) {
case 'auth-success':
// Authentication successful - reload user info
removeAuthIframe()
viewState.value = 'loading'
loadingMessage.value = 'Loading user profile...'
loadUserInfo()
break
case 'auth-error':
// Authentication failed - keep iframe open so user can retry
if (data.cancelled) {
console.log('Authentication cancelled by user')
} else {
store.showMessage(data.message || 'Authentication failed', 'error', 5000)
}
break
case 'auth-cancelled':
// Legacy support - treat as auth-error with cancelled flag
console.log('Authentication cancelled')
break
case 'auth-back':
// User clicked Back - show terminal state
removeAuthIframe()
terminateSession()
break
case 'auth-close-request':
// Legacy support - treat as back
removeAuthIframe()
break
} catch (e) {
onSessionLost(e)
}
}
onMounted(async () => {
// Listen for postMessage from auth iframe
window.addEventListener('message', handleAuthMessage)
// Load settings
await store.loadSettings()
@@ -134,20 +104,7 @@ onMounted(async () => {
document.title = inHostMode ? `${rpName} · Account summary` : rpName
}
// Try to load user info
const success = await loadUserInfo()
if (!success) {
// Need authentication - show login iframe
showAuthIframe()
}
})
onUnmounted(() => {
window.removeEventListener('message', handleAuthMessage)
removeAuthIframe()
// Load user info (apiJson handles auth iframe if needed)
await loadUserInfo()
})
</script>
<style scoped>
</style>
+358 -110
View File
@@ -9,10 +9,13 @@ import AccessDenied from '@/components/AccessDenied.vue'
import AdminOverview from '@/admin/AdminOverview.vue'
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson, SessionValidator } from 'paskia'
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import { uuidv7 } from 'uuidv7'
import { getDirection } from '@/utils/keynav'
import { goBack } from '@/utils/helpers'
@@ -24,9 +27,12 @@ const showBackMessage = ref(false)
const error = ref(null)
const orgs = ref([])
const permissions = ref([])
const oidcClients = ref([])
const currentOrgId = ref(null) // UUID of selected org for detail view
const currentUserId = ref(null) // UUID for user detail view
const currentOidcId = ref(null) // UUID for OIDC client detail view
const userDetail = ref(null) // cached user detail object
const editingOidcClient = ref(null) // OIDC client being edited (with local changes)
const authStore = useAuthStore()
const addingOrgForPermission = ref(null)
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
@@ -43,6 +49,7 @@ const breadcrumbsRef = ref(null)
const adminOverviewRef = ref(null)
const adminOrgDetailRef = ref(null)
const adminUserDetailRef = ref(null)
const adminOidcDetailRef = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
@@ -79,11 +86,12 @@ onUnmounted(() => {
const permissionSummary = computed(() => {
const summary = {}
for (const o of orgs.value) {
const orgBase = { uuid: o.uuid, display_name: o.display_name }
const orgPerms = new Set(o.permissions || [])
const orgBase = { uuid: o.uuid, display_name: o.org.display_name }
// o.permissions is a dict[UUID, Permission]
const orgPermUuids = new Set(Object.keys(o.permissions || {}))
// Org-level permissions (direct) - only count if org can grant them
for (const pid of o.permissions || []) {
for (const pid of Object.keys(o.permissions || {})) {
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
@@ -92,17 +100,18 @@ const permissionSummary = computed(() => {
}
// Role-based permissions (inheritance) - only count if org can grant them
for (const r of o.roles) {
for (const pid of r.permissions) {
for (const [roleUuid, r] of Object.entries(o.roles || {})) {
// r.permissions is dict[UUID, bool]
for (const pid of Object.keys(r.permissions || {})) {
// Only count if the org can grant this permission
if (!orgPerms.has(pid)) continue
if (!orgPermUuids.has(pid)) continue
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
summary[pid].orgSet.add(o.uuid)
}
summary[pid].userCount += r.users.length
summary[pid].userCount += roleUserCount(o, roleUuid)
}
}
}
@@ -120,32 +129,76 @@ function parseHash() {
const h = window.location.hash || ''
currentOrgId.value = null
currentUserId.value = null
currentOidcId.value = null
editingOidcClient.value = null
if (h.startsWith('#org/')) {
currentOrgId.value = h.slice(5)
} else if (h.startsWith('#user/')) {
currentUserId.value = h.slice(6)
} else if (h.startsWith('#oidc:')) {
const oidcUuid = h.slice(6)
currentOidcId.value = oidcUuid
// Initialize editing client data
if (oidcUuid === 'new') {
// Generate client_id and secret for new client
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
const client_secret = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
editingOidcClient.value = {
client_id: uuidv7(),
client_secret,
isNew: true,
name: '',
redirect_uris: []
}
} else {
const client = oidcClients.value.find(c => c.uuid === oidcUuid)
if (client) {
editingOidcClient.value = {
...client,
client_id: client.uuid,
client_secret: null,
isNew: false
}
}
}
}
}
async function loadOrgs() {
const data = await apiJson('/auth/api/admin/orgs')
orgs.value = data.map(o => {
const roles = o.roles.map(r => ({ ...r, org: o.uuid, users: [] }))
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
for (const u of o.users || []) {
if (roleMap[u.role]) roleMap[u.role].users.push(u)
}
return { ...o, roles }
})
async function loadAdminData() {
const data = await apiJson('/auth/api/admin/info')
// Convert dicts to arrays with uuid added
orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o }))
permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p }))
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
}
async function loadPermissions() {
permissions.value = await apiJson('/auth/api/admin/permissions')
// Helper to get users for a role as sorted array of [uuid, user]
function roleUsers(org, roleUuid) {
return Object.entries(org.users)
.filter(([_, u]) => u.role === roleUuid)
.sort(([, a], [, b]) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
return nameA.localeCompare(nameB)
})
}
// Helper to count users in a role
function roleUserCount(org, roleUuid) {
return Object.values(org.users).filter(u => u.role === roleUuid).length
}
// Helper to count total users in an org
function orgUserCount(org) {
return Object.keys(org.users).length
}
async function loadUserInfo() {
const data = await apiJson('/auth/api/validate', { method: 'POST' })
const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
info.value = data
updateThemeFromSession(data.ctx)
authenticated.value = true
}
@@ -153,7 +206,9 @@ function clearSensitiveState() {
info.value = null
orgs.value = []
permissions.value = []
oidcClients.value = []
userDetail.value = null
editingOidcClient.value = null
authenticated.value = false
}
@@ -178,7 +233,7 @@ async function load() {
error.value = null
try {
// Load admin data first - apiJson will handle 401/403 with iframe authentication
await Promise.all([loadOrgs(), loadPermissions()])
await loadAdminData()
// If we get here, user has admin access - now fetch user info for display
await loadUserInfo()
@@ -186,7 +241,6 @@ async function load() {
if (!window.location.hash || window.location.hash === '#overview') {
currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}`
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
} else {
parseHash()
}
@@ -207,17 +261,17 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user.
async function performOrgDeletion(orgUuid) {
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
await Promise.all([loadOrgs(), loadPermissions()])
await Promise.all([loadAdminData()])
}
function deleteOrg(org) {
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
const userCount = orgUserCount(org)
if (userCount === 0) {
// No users in the organization, safe to delete directly
performOrgDeletion(org.uuid)
.then(() => {
authStore.showMessage(`Organization "${org.display_name}" deleted.`, 'success', 2500)
authStore.showMessage(`Organization "${org.org.display_name}" deleted.`, 'success', 2500)
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to delete organization', 'error')
@@ -226,50 +280,68 @@ function deleteOrg(org) {
}
// Build detailed breakdown of users by role
const roleParts = org.roles
.filter(r => r.users.length > 0)
.map(r => `${r.users.length} ${r.display_name}`)
const roleParts = Object.entries(org.roles)
.map(([uuid, r]) => ({ role: r, count: roleUserCount(org, uuid) }))
.filter(x => x.count > 0)
.map(x => `${x.count} ${x.role.display_name}`)
const affects = roleParts.join(', ')
openDialog('confirm', { message: `Delete organization "${org.display_name}", including accounts of ${affects})?`, action: async () => {
openDialog('confirm', { message: `Delete organization "${org.org.display_name}", including accounts of ${affects})?`, action: async () => {
await performOrgDeletion(org.uuid)
} })
}
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
async function moveUserToRole(org, user, targetRoleDisplayName) {
if (user.role === targetRoleDisplayName) return
function deleteUser(user, userDetail) {
const credentialCount = userDetail?.credentials ? Object.keys(userDetail.credentials).length : 0
const userUuid = user.uuid
const userName = user.display_name
const orgUuid = user.org // org UUID is stored in selectedUser
if (credentialCount === 0) {
// No credentials, safe to delete directly
performUserDeletion(userUuid, userName, orgUuid)
return
}
const passkeys = credentialCount === 1 ? '1 passkey' : `${credentialCount} passkeys`
openDialog('confirm', {
message: `Delete user "${userName}" with ${passkeys}? This action cannot be undone.`,
action: async () => {
await performUserDeletion(userUuid, userName, orgUuid)
}
})
}
async function performUserDeletion(userUuid, userName, orgUuid) {
try {
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' })
authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500)
await loadAdminData()
window.location.hash = `#org/${orgUuid}`
} catch (e) {
authStore.showMessage(e.message || 'Failed to delete user', 'error')
}
}
async function moveUserToRole(userUuid, user, targetRoleUuid) {
if (user.role === targetRoleUuid) return
try {
await apiJson(`/auth/api/admin/users/${userUuid}/role`, {
method: 'PATCH',
body: { role: targetRoleDisplayName }
body: { role_uuid: targetRoleUuid }
})
await loadOrgs()
await loadAdminData()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update user role')
}
}
function onUserDragStart(e, user, org) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org }))
}
function onRoleDragOver(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
function onRoleDrop(e, org, role) {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
if (data.org !== org.uuid) return // only within same org
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
if (user) moveUserToRole(org, user, role.display_name)
} catch (_) { /* ignore */ }
function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
const user = selectedOrg.value?.users?.[userUuid]
if (user) moveUserToRole(userUuid, user, newRoleUuid)
}
// Role actions
@@ -279,10 +351,10 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
function deleteRole(role) {
// UI only allows deleting empty roles, so no confirmation needed
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'DELETE' })
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
.then(() => {
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to delete role', 'error')
@@ -290,19 +362,22 @@ function deleteRole(role) {
}
async function toggleRolePermission(role, pid, checked) {
// Optimistic update
const prevPermissions = [...role.permissions]
const newPermissions = checked
? [...role.permissions, pid]
: role.permissions.filter(p => p !== pid)
// Optimistic update - role.permissions is dict[UUID, bool]
const prevPermissions = { ...role.permissions }
const newPermissions = { ...role.permissions }
if (checked) {
newPermissions[pid] = true
} else {
delete newPermissions[pid]
}
role.permissions = newPermissions
try {
const method = checked ? 'POST' : 'DELETE'
await apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}/permissions/${pid}`, {
await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
method
})
await loadOrgs()
await loadAdminData()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update role permission')
role.permissions = prevPermissions // revert
@@ -313,7 +388,7 @@ async function toggleRolePermission(role, pid, checked) {
async function performPermissionDeletion(permissionUuid) {
const params = new URLSearchParams({ permission_uuid: permissionUuid })
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
await loadPermissions()
await loadAdminData()
}
function deletePermission(p) {
@@ -322,8 +397,8 @@ function deletePermission(p) {
// Count roles that have this permission
let roleCount = 0
for (const org of orgs.value) {
for (const role of org.roles) {
if (role.permissions.includes(p.uuid)) {
for (const role of Object.values(org.roles)) {
if (p.uuid in (role.permissions || {})) {
roleCount++
}
}
@@ -351,6 +426,104 @@ function deletePermission(p) {
} })
}
// OIDC Client actions
async function sha256Hex(text) {
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('')
}
function createOidcClient() {
// Navigate to new OIDC client page
window.location.hash = '#oidc:new'
}
function openOidcClient(client) {
// Navigate to OIDC client detail page
window.location.hash = `#oidc:${client.uuid}`
}
function resetOidcSecret(clientId) {
// Generate new secret locally; it will be sent to server on Save
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
const client_secret = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
// Update editingOidcClient if we're on the detail page
if (editingOidcClient.value?.client_id === clientId) {
editingOidcClient.value = { ...editingOidcClient.value, client_secret }
}
// Also update dialog if open (for backwards compatibility)
if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) {
dialog.value.data.client_secret = client_secret
}
}
function createPermissionForClient(clientId) {
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
}
async function openServerConfig() {
try {
const config = await apiJson('/auth/api/admin/server-config')
// Strip https:// scheme from stored origins and auth_host for editing
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
openDialog('server-config', {
rp_name: config.rp_name || '',
auth_host,
origins,
originValidation: origins.map(() => null),
})
} catch (e) {
authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
}
}
function deleteOidcClient(client) {
openDialog('confirm', {
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
action: async () => {
await performOidcClientDeletion(client.uuid, client.name)
// Navigate back to overview if we were on the detail page
if (currentOidcId.value === client.uuid) {
window.location.hash = '#overview'
}
}
})
}
async function performOidcClientDeletion(clientUuid, clientName) {
await apiJson(`/auth/api/admin/oidc-clients/${clientUuid}`, { method: 'DELETE' })
authStore.showMessage(`OIDC client "${clientName}" deleted.`, 'success', 2500)
await loadAdminData()
}
async function handleOidcSave(data) {
const { client_id, client_secret, name, redirect_uris, isNew } = data
try {
if (client_secret) {
const secret_hash = await sha256Hex(client_secret)
if (isNew) {
await apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
} else {
await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } })
}
} else {
await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
}
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
await loadAdminData()
window.location.hash = '#overview'
} catch (e) {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
}
}
function handleOidcCancel() {
goOverview()
}
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
function openOrg(o) {
@@ -368,34 +541,41 @@ function openUser(u) {
const selectedUser = computed(() => {
if (!currentUserId.value) return null
for (const o of orgs.value) {
for (const r of o.roles) {
const u = r.users.find(x => x.uuid === currentUserId.value)
if (u) return { ...u, org: o.uuid, role_display_name: r.display_name }
const u = o.users[currentUserId.value]
if (u) {
const role = o.roles[u.role]
return { ...u, uuid: currentUserId.value, org: o.uuid, role_display_name: role?.display_name }
}
}
return null
})
const pageHeading = computed(() => {
if (selectedUser.value) return 'Admin: User'
if (selectedOrg.value) return 'Admin: Org'
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
})
// Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => {
const entries = [
{ label: 'Auth', href: makeUiHref() },
{ label: 'Admin', href: adminUiPath() }
{ label: 'My Profile', href: makeUiHref() }
]
// For org admins, combine Admin and their org
if (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) {
const org = orgs.value[0]
entries.push({ label: `Admin: ${org.org.display_name}`, href: `#org/${org.uuid}` })
} else {
entries.push({ label: 'Admin', href: adminUiPath() })
}
// Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null
if (selectedUser.value) {
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
}
const orgToShow = selectedOrg.value || orgForUser
if (orgToShow) {
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
// Add org breadcrumb only if it's not already included in the Admin entry
const adminOrg = (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) ? orgs.value[0] : null
if (orgToShow && (!adminOrg || orgToShow.uuid !== adminOrg.uuid)) {
entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` })
}
if (currentOidcId.value) {
const label = editingOidcClient.value?.isNew ? 'New Client' : (editingOidcClient.value?.name || 'OIDC Client')
entries.push({ label, href: `#oidc:${currentOidcId.value}` })
}
if (selectedUser.value) {
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
@@ -406,7 +586,7 @@ const breadcrumbEntries = computed(() => {
watch(selectedUser, async (u) => {
if (!u) { userDetail.value = null; return }
try {
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org}/users/${u.uuid}`)
userDetail.value = await apiJson(`/auth/api/admin/users/${u.uuid}`)
} catch (e) {
userDetail.value = { error: e.message }
}
@@ -418,18 +598,27 @@ function generateUserRegistrationLink(u) {
}
async function toggleOrgPermission(org, permId, checked) {
// Build next permission list
const has = org.permissions.includes(permId)
// org.permissions is dict[UUID, Permission]
const has = permId in org.permissions
if (checked && has) return
if (!checked && !has) return
const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId)
// Optimistic update
const prev = [...org.permissions]
org.permissions = next
const prev = { ...org.permissions }
if (checked) {
// Need to fetch the permission object to add it
const perm = permissions.value.find(p => p.uuid === permId)
if (perm) {
org.permissions = { ...org.permissions, [permId]: perm }
}
} else {
const next = { ...org.permissions }
delete next[permId]
org.permissions = next
}
try {
const params = new URLSearchParams({ permission_uuid: permId })
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
await loadOrgs()
await loadAdminData()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
org.permissions = prev // revert
@@ -539,18 +728,15 @@ function handlePanelNavigateOut(direction) {
}
async function refreshUserDetail() {
await loadOrgs()
await loadAdminData()
if (selectedUser.value) {
try {
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org}/users/${selectedUser.value.uuid}`)
userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`)
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
}
}
async function onUserNameSaved() {
await refreshUserDetail()
authStore.showMessage('User renamed', 'success', 1500)
}
async function submitDialog() {
if (!dialog.value.type || dialog.value.busy) return
@@ -565,7 +751,7 @@ async function submitDialog() {
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
.then(() => {
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
Promise.all([loadOrgs(), loadPermissions()])
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to create organization', 'error')
@@ -579,7 +765,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
.then(() => {
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update organization', 'error')
@@ -593,7 +779,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
.then(() => {
authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to create role', 'error')
@@ -604,10 +790,10 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation
closeDialog()
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
.then(() => {
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update role', 'error')
@@ -621,7 +807,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
.then(() => {
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to add user', 'error')
@@ -632,10 +818,10 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation
closeDialog()
apiJson(`/auth/api/admin/orgs/${user.org}/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } })
.then(() => {
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
onUserNameSaved()
refreshUserDetail()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update user name', 'error')
@@ -667,7 +853,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
.then(() => {
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
loadPermissions()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update permission', 'error')
@@ -683,12 +869,58 @@ async function submitDialog() {
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } })
.then(() => {
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
loadPermissions()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to create permission', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'oidc-edit') {
const { client_id, client_secret, isNew } = dialog.value.data
const name = dialog.value.data.name?.trim()
const uris = dialog.value.data.redirect_uris?.trim()
if (!name) throw new Error('Client name required')
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
// Close dialog immediately, then perform async operation
closeDialog()
const req = client_secret
? sha256Hex(client_secret).then(secret_hash => isNew
? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } }))
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
req
.then(() => {
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
})
return // Don't call closeDialog() again
} else if (t === 'server-config') {
const rp_name = dialog.value.data.rp_name?.trim() || ''
const auth_host = dialog.value.data.auth_host?.trim() || ''
// Origins are stored as-is (hostnames); backend normalizes with https://
const origins = dialog.value.data.origins
.map(o => o.trim())
.filter(o => o)
closeDialog()
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
.then(() => {
authStore.showMessage('Server configuration updated.', 'success', 2500)
// Reload settings to reflect rp_name changes
authStore.loadSettings().then(() => {
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
})
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'confirm') {
const action = dialog.value.data.action
// Close dialog first, then perform action (errors shown via showMessage)
@@ -728,7 +960,6 @@ async function submitDialog() {
/>
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
<header class="view-header">
<h1>{{ pageHeading }}</h1>
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
</header>
@@ -736,11 +967,12 @@ async function submitDialog() {
<div class="section-body admin-section-body">
<div class="admin-panels">
<AdminOverview
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)"
v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
ref="adminOverviewRef"
:info="info"
:orgs="orgs"
:permissions="permissions"
:oidc-clients="oidcClients"
:navigation-disabled="hasActiveModal"
:permission-summary="permissionSummary"
@create-org="createOrg"
@@ -751,6 +983,10 @@ async function submitDialog() {
@open-dialog="openDialog"
@delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay"
@create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient"
@open-server-config="openServerConfig"
@navigate-out="handlePanelNavigateOut"
/>
@@ -764,13 +1000,12 @@ async function submitDialog() {
:show-reg-modal="showRegModal"
:navigation-disabled="hasActiveModal"
@generate-user-registration-link="generateUserRegistrationLink"
@go-overview="goOverview"
@open-org="openOrg"
@on-user-name-saved="onUserNameSaved"
@refresh-user-detail="refreshUserDetail"
@edit-user-name="editUserName"
@close-reg-modal="showRegModal = false"
@navigate-out="handlePanelNavigateOut"
@delete-user="deleteUser(selectedUser, userDetail)"
/>
<AdminOrgDetail
v-else-if="selectedOrg"
@@ -785,10 +1020,23 @@ async function submitDialog() {
@create-user-in-role="createUserInRole"
@open-user="openUser"
@toggle-role-permission="toggleRolePermission"
@on-role-drag-over="onRoleDragOver"
@move-user-to-role="moveUserToRoleFromDrag"
@navigate-out="handlePanelNavigateOut"
/>
<AdminOidcDetail
v-else-if="currentOidcId && editingOidcClient"
ref="adminOidcDetailRef"
:client="editingOidcClient"
:permissions="permissions"
:is-new="editingOidcClient.isNew"
:navigation-disabled="hasActiveModal"
@save="handleOidcSave"
@cancel="handleOidcCancel"
@delete="deleteOidcClient"
@reset-secret="resetOidcSecret"
@create-permission="createPermissionForClient"
@navigate-out="handlePanelNavigateOut"
@on-role-drop="onRoleDrop"
@on-user-drag-start="onUserDragStart"
/>
</div>
@@ -802,14 +1050,14 @@ async function submitDialog() {
:settings="authStore.settings"
@submit-dialog="submitDialog"
@close-dialog="closeDialog"
@reset-oidc-secret="resetOidcSecret"
@create-permission-for-client="createPermissionForClient"
/>
</div>
</template>
<style scoped>
.view-admin { padding-bottom: var(--space-3xl); }
.view-header { display: flex; flex-direction: column; gap: var(--space-sm); }
.admin-section { margin-top: var(--space-xl); }
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
</style>
+3 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin</title>
<title>Admin</title>
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>
<div id="admin-app"></div>
-2
View File
@@ -1,8 +1,6 @@
import { initThemeFromCache } from '@/utils/theme'
initThemeFromCache()
import '@/assets/style.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import AdminApp from './AdminApp.vue'
+2
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Profile</title>
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>
<div id="app"></div>
-2
View File
@@ -1,8 +1,6 @@
import { initThemeFromCache } from '@/utils/theme'
initThemeFromCache()
import '@/assets/style.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
+22 -2
View File
@@ -2,6 +2,7 @@
<RestrictedAuth
:mode="authMode"
:remote-auth-token="remoteAuthToken"
:oidc-query-string="oidcQueryString"
@authenticated="handleAuthenticated"
@back="handleBack"
/>
@@ -15,6 +16,9 @@ import RestrictedAuth from '@/components/RestrictedAuth.vue'
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
const remoteAuthToken = ref(null)
// For OIDC flow, pass the raw query string to preserve exact param values
const oidcQueryString = window.location.search.includes('client_id=') ? window.location.search : null
function extractRemoteToken() {
const path = window.location.pathname
// Match /auth/{token} where token is a passphrase with dots
@@ -32,7 +36,18 @@ function extractRemoteToken() {
// Parse URL hash fragment
const hashParams = new URLSearchParams(window.location.hash.slice(1))
const authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
// Determine auth mode based on URL path
// - /auth/restricted/oidc: OIDC flow, no session dependency
// - /auth/restricted/iframe: iframe embedding, mode from hash params
// - Other paths: forward auth, mode from hash params
let authMode
if (window.location.pathname === '/auth/restricted/oidc') {
authMode = 'oidc'
} else {
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth)
authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
}
function postToParent(message) {
if (window.parent && window.parent !== window) {
@@ -41,10 +56,15 @@ function postToParent(message) {
}
function handleAuthenticated(result) {
if (result.redirect_url) {
// OIDC flow: redirect to client with auth code
window.location.href = result.redirect_url
return
}
postToParent({
type: 'auth-success',
authenticated: true,
sessionToken: result.session_token
exchangeCode: result.exchange_code
})
}
+3 -1
View File
@@ -1,8 +1,10 @@
<!DOCTYPE html>
<html lang="en" style="background: transparent">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>{let t=localStorage.getItem('paskia-theme');if(!t){let p=new URLSearchParams(location.hash.slice(1)).get('theme');if(p==='light'||p==='dark')t=p}(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>
<div id="app"></div>
-1
View File
@@ -1,7 +1,6 @@
import './theme.js'
import { createApp } from 'vue'
import RestrictedApi from './RestrictedApi.vue'
import '@/assets/style.css'
import { initKeyboardNavigation } from '@/utils/keynav'
createApp(RestrictedApi).mount('#app')
+6 -6
View File
@@ -1,11 +1,11 @@
// Early theme for restricted app - first URL param wins, then localStorage
import { themeColors, applyTheme, getCachedTheme } from '@/utils/theme.js'
// Early theme for restricted app - user preference (localStorage) wins, then URL param
import { applyTheme, getCachedTheme } from '@/utils/theme.js'
function getTheme() {
const params = new URLSearchParams(location.hash.slice(1))
return params.get('theme') || getCachedTheme() || ''
return getCachedTheme() || params.get('theme') || ''
}
// Use .surface selector to preserve transparent background
applyTheme(getTheme(), '.surface')
addEventListener('hashchange', () => applyTheme(getTheme(), '.surface'))
// Apply theme class to document root
applyTheme(getTheme())
addEventListener('hashchange', () => applyTheme(getTheme()))
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Restricted</title>
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
</head>
<body>
<div id="app"></div>
+21 -19
View File
@@ -1,14 +1,14 @@
<template>
<div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;">
<div v-if="status.show" class="global-status show">
<div :class="['status', status.type]">
{{ status.message }}
</div>
</div>
<main class="view-root">
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
<header class="view-header" style="text-align: center;">
<div class="surface surface--tight reset-container">
<header class="view-header center">
<h1>🔑 Registration</h1>
<p class="view-lede">
{{ subtitleMessage }}
@@ -23,7 +23,7 @@
<section class="section-block" v-else-if="!canRegister">
<div class="section-body center">
<div class="button-row center" style="justify-content: center;">
<div class="button-row button-row--center">
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div>
</div>
@@ -59,7 +59,8 @@
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
const status = reactive({
show: false,
@@ -80,7 +81,7 @@ const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your en
const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing your secure enrollment…'
if (!canRegister.value) return 'This authentication link is no longer valid.'
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
return `Finish up ${sessionDescriptor.value}. The name entered will be stored on your passkey and on our system.`
})
const basePath = computed(() => uiBasePath())
@@ -117,6 +118,7 @@ async function fetchTokenInfo() {
headers: { 'Authorization': `Bearer ${token.value}` },
})
displayName.value = tokenInfo.value.display_name
if (tokenInfo.value.theme) updateThemeFromSession({ user: { theme: tokenInfo.value.theme } })
} catch (error) {
console.error('Failed to load token info', error)
const message = error instanceof ApiError
@@ -144,7 +146,7 @@ async function registerPasskey() {
}
try {
await setSessionCookie(result)
await exchangeCode(result)
} catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
@@ -156,15 +158,14 @@ async function registerPasskey() {
setTimeout(() => { loading.value = false; goHome() }, 800)
}
async function setSessionCookie(result) {
if (!result?.session_token) {
throw new Error('Registration response missing session_token')
async function exchangeCode(result) {
if (!result?.exchange_code) {
throw new Error('Registration response missing exchange_code')
}
return await apiJson('/auth/api/set-session', {
method: 'POST',
headers: {
Authorization: `Bearer ${result.session_token}`
}
headers: { 'Authorization': `Bearer ${result.exchange_code}` },
timeout: paskiaSettings.auth_ms,
})
}
@@ -203,13 +204,14 @@ onMounted(async () => {
</script>
<style scoped>
.center {
text-align: center;
}
.button-row.center {
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.reset-container {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex;
justify-content: center;
flex-direction: column;
gap: 1.75rem;
}
.section-body {
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Complete Passkey Setup</title>
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
</head>
<body>
<div id="app"></div>
+3 -1
View File
@@ -14,7 +14,9 @@
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"sirv": "^3.0.2",
"vue": "^3.5.17"
"uuidv7": "^1.1.0",
"vue": "^3.5.17",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
+226 -9
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
dialog: Object,
@@ -9,10 +10,164 @@ const props = defineProps({
settings: Object
})
const emit = defineEmits(['submitDialog', 'closeDialog'])
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
const NO_SUBMIT_TYPES = new Set([])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
// Initialize validation properties
if (props.dialog?.data && props.dialog.type === 'server-config') {
if (!('authHostValidation' in props.dialog.data)) {
props.dialog.data.authHostValidation = null
}
}
const isValidationInvalid = computed(() => {
if (props.dialog?.type !== 'server-config') return false
const d = props.dialog.data
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
return false
})
// Copy-to-clipboard helper
const authStore = useAuthStore()
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
function addOrigin() {
const d = props.dialog?.data
if (d) {
d.origins.push(rpId.value)
d.originValidation.push(null)
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
}
}
function removeOrigin(i) {
const d = props.dialog?.data
if (d) {
d.origins.splice(i, 1)
d.originValidation.splice(i, 1)
}
}
function stripScheme(val, i) {
const d = props.dialog?.data
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function stripSchemeAuthHost() {
const d = props.dialog?.data
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function focusOriginStart(e) {
e.target.setSelectionRange(0, 0)
}
function validateOriginDomain(origin, rpId) {
if (!origin.trim()) return false
try {
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
const hostname = url.hostname
return hostname === rpId || hostname.endsWith('.' + rpId)
} catch {
return false
}
}
async function validateOriginConnectivity(origin, i) {
const d = props.dialog?.data
if (!d) return
d.originValidation[i] = 'validating'
try {
const cleanOrigin = origin.replace(/\/+$/, '')
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id)
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the origin hasn't changed
if (d.origins[i] === origin) {
d.originValidation[i] = result
}
} else {
if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid'
}
}
} catch (e) {
if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid'
}
}
}
function validateOrigin(origin, i) {
const d = props.dialog?.data
if (!d) return
const id = rpId.value
if (validateOriginDomain(origin, id)) {
validateOriginConnectivity(origin, i)
} else {
d.originValidation[i] = 'invalid'
}
}
async function validateAuthHostConnectivity(authHost) {
const d = props.dialog?.data
if (!d) return
d.authHostValidation = 'validating'
try {
const cleanAuthHost = authHost.replace(/\/+$/, '')
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id)
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the auth_host hasn't changed
if (d.auth_host === authHost) {
d.authHostValidation = result
}
} else {
if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity'
}
}
} catch (e) {
if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity'
}
}
}
function validateAuthHost() {
const d = props.dialog?.data
if (!d || !d.auth_host?.trim()) {
d.authHostValidation = null // Allow empty
return
}
const id = rpId.value
if (validateOriginDomain(d.auth_host, id)) {
validateAuthHostConnectivity(d.auth_host)
} else {
d.authHostValidation = 'invalid-domain'
}
}
</script>
<template>
@@ -25,6 +180,8 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='server-config'">Server Options</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -76,19 +233,51 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
</label>
<label>Permission Scope
<input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
<input v-model="dialog.data.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
</label>
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
<label>Domain Scope
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" />
<input v-model="dialog.data.domain" data-form-type="other" />
</label>
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
</template>
<template v-else-if="dialog.type==='server-config'">
<label>Site Branding (rp-name)
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
</label>
<label>Dedicated Authentication Site (auth-host)
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
</label>
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
<div class="origin-label">
Allowed Origins
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin"></button>
</div>
<div v-if="dialog.data.origins.length" class="origin-list">
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
<input
:value="dialog.data.origins[i]"
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
@focus="focusOriginStart"
class="origin-input"
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
/>
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin"></button>
</div>
</div>
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-secondary"
@@ -100,18 +289,46 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<button
type="submit"
class="btn-primary"
:disabled="dialog.busy"
:disabled="dialog.busy || isValidationInvalid"
>
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button>
</div>
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-primary"
@click="$emit('closeDialog')"
>
Close
</button>
</div>
</form>
</Modal>
</template>
<style scoped>
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; }
/* Server config origins */
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
.origin-row .delete-icon { flex-shrink: 0; }
.origin-add-btn { font-size: 1.2rem; }
.input-error {
border-color: var(--color-error);
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
client: Object,
permissions: Array,
isNew: { type: Boolean, default: false },
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['save', 'cancel', 'delete', 'resetSecret', 'createPermission', 'navigateOut'])
const authStore = useAuthStore()
const headerRef = ref(null)
// Helper function to build URLs
function authSitePath(path) {
const url = new URL(authStore.settings.auth_site_url)
url.pathname = path
return url.toString()
}
// Local form state
const name = ref('')
const redirectUris = ref('')
const clientSecret = ref(null)
// Computed
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
const discoveryUrl = computed(() => authSitePath('/.well-known/openid-configuration'))
const iconUrl = computed(() => authSitePath('/favicon.ico'))
// Groups (permissions) scoped to this client
const clientGroups = computed(() => {
if (!props.client || !props.permissions) return []
const clientUuid = props.client.uuid || props.client.client_id
return props.permissions.filter(p => p.domain === clientUuid).sort((a, b) => a.scope.localeCompare(b.scope))
})
// Initialize form data from props
watch(() => props.client, (c) => {
if (c) {
name.value = c.name || ''
redirectUris.value = Array.isArray(c.redirect_uris) ? c.redirect_uris.join('\n') : (c.redirect_uris || '')
clientSecret.value = c.client_secret || null
}
}, { immediate: true })
// Copy-to-clipboard helper
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
function handleResetSecret() {
emit('resetSecret', clientId.value)
}
// When parent resets secret, update local state
watch(() => props.client?.client_secret, (newSecret) => {
if (newSecret) {
clientSecret.value = newSecret
}
})
function handleSave() {
const trimmedName = name.value.trim()
if (!trimmedName) {
authStore.showMessage('Client name is required', 'error')
return
}
const uris = redirectUris.value.trim()
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
emit('save', {
client_id: clientId.value,
client_secret: clientSecret.value,
name: trimmedName,
redirect_uris,
isNew: props.isNew
})
}
function handleDelete() {
emit('delete', props.client)
}
function handleCreatePermission() {
emit('createPermission', clientId.value)
}
function handleCancel() {
emit('cancel')
}
// Keyboard navigation
function handleHeaderKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(headerRef.value, event.target, direction, { itemSelector: 'button, a' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
}
}
function focusFirstElement() {
const firstFocusable = headerRef.value?.querySelector('button, a, input')
if (firstFocusable) firstFocusable.focus()
}
defineExpose({ focusFirstElement })
</script>
<template>
<div class="oidc-detail">
<form @submit.prevent="handleSave" class="oidc-form">
<!-- Client credentials section -->
<section class="section-block">
<div class="section-header">
<h2>Client Configuration</h2>
<p class="section-description">Configure these values in the client application.</p>
</div>
<div class="section-body">
<dl class="oidc-dl">
<dt>Authentication Name</dt>
<dd>
<output @click="copyText(authStore.settings.rp_name, 'Authentication Name')" title="Click to copy">{{ authStore.settings.rp_name }}</output>
<span class="small muted"> (Login With, may affect URLs optional)</span>
</dd>
<dt>Client ID</dt>
<dd><output @click="copyText(clientId, 'Client ID')" title="Click to copy">{{ clientId }}</output></dd>
<dt>Client Secret <button v-if="!clientSecret" type="button" class="icon-btn" @click="handleResetSecret" title="Revoke and re-generate secret">🔄</button></dt>
<dd>
<output v-if="clientSecret" @click="copyText(clientSecret, 'Client Secret')" title="Click to copy">{{ clientSecret }}</output>
<span v-else class="small muted">(only stored in hashed form)</span>
</dd>
<dt>Auto Discovery URL</dt>
<dd><output @click="copyText(discoveryUrl, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ discoveryUrl }}</output></dd>
<dt>Icon URL</dt>
<dd>
<output @click="copyText(iconUrl, 'Icon URL')" title="Click to copy">{{ iconUrl }}</output>
<span class="small muted"> (optional)</span>
</dd>
<template v-if="clientGroups.length">
<dt>Groups Claim Name</dt>
<dd>
<output @click="copyText('groups', 'Groups Claim Name')" title="Click to copy">groups</output>
</dd>
</template>
<dt>Groups <button type="button" class="icon-btn" @click="handleCreatePermission" title="Add permission scoped to this client"></button></dt>
<dd class="oidc-groups">
<template v-if="clientGroups.length">
<output v-for="group in clientGroups" :key="group.uuid" class="oidc-group" @click="copyText(group.scope, 'Group Value')" :title="group.display_name">{{ group.scope }}</output>
</template>
<span v-else class="small muted">(no permissions defined)</span>
</dd>
</dl>
<span class="warning-text">
<strong v-if="clientSecret"> {{ isNew ? 'Save the secret now it cannot be retrieved later.' : 'Saving will prevent access with the old secret.' }}</strong>
<span v-else> The client may use groups to check for required permissions.</span>
</span>
</div>
</section>
<!-- Editable fields -->
<section class="section-block">
<div class="section-header">
<h2>Paskia Configuration</h2>
</div>
<div class="section-body">
<label>Client Name
<input v-model="name" required />
</label>
<label>Redirect URIs
<p class="small muted">This should be provided by the client application.</p>
<textarea v-model="redirectUris" placeholder="(autodiscover one on first use)" rows="3"></textarea>
</label>
</div>
</section>
<!-- Actions -->
<div class="oidc-actions">
<button type="button" class="btn-secondary" @click="handleCancel">Cancel</button>
<button v-if="!isNew" type="button" class="btn-danger" @click="handleDelete">Delete Client</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
</template>
<style scoped>
.oidc-detail {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.oidc-header {
margin-bottom: 0;
}
.oidc-header h2 {
margin: 0;
}
.oidc-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.oidc-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 1rem;
align-items: baseline;
margin: var(--space-sm) 0;
}
.oidc-dl dt {
font-size: 0.85rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.oidc-dl dd {
margin: 0;
overflow: hidden;
display: flex;
align-items: baseline;
gap: 0.5em;
}
.oidc-dl output {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.warning-text {
display: block;
font-size: 0.9rem;
min-height: 1.4em;
}
.oidc-group { display: block; }
.oidc-group { white-space: normal; word-break: break-all; }
.section-body label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
.oidc-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
margin-top: var(--space-md);
}
</style>
+100 -61
View File
@@ -1,5 +1,7 @@
<script setup>
import { computed, ref } from 'vue'
import draggable from 'vuedraggable'
import ProfilePicture from '@/components/ProfilePicture.vue'
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
const props = defineProps({
@@ -8,7 +10,7 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart', 'navigateOut'])
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'moveUserToRole', 'navigateOut'])
// Template refs for navigation
const orgTitleRef = ref(null)
@@ -16,7 +18,8 @@ const permMatrixRef = ref(null)
const rolesGridRef = ref(null)
const sortedRoles = computed(() => {
return [...props.selectedOrg.roles].sort((a, b) => {
// o.roles is dict[UUID, Role], convert to array for sorting with uuid added
return Object.entries(props.selectedOrg.roles).map(([uuid, r]) => ({ uuid, ...r })).sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) {
@@ -28,12 +31,45 @@ const sortedRoles = computed(() => {
// Get org's grantable permissions as full permission objects (with UUIDs)
const orgPermissions = computed(() => {
const uuidSet = new Set(props.selectedOrg.permissions || [])
// props.selectedOrg.permissions is dict[UUID, Permission]
const uuidSet = new Set(Object.keys(props.selectedOrg.permissions || {}))
return props.permissions.filter(p => uuidSet.has(p.uuid))
})
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
// Get users for a role as sorted array of { uuid, ...user }
function getNormalizedName(name) {
let cleaned = name.replace(/\([^)]*\)/g, '').trim();
if (cleaned.includes(',')) {
return cleaned.toLowerCase();
} else {
const parts = cleaned.split(/\s+/);
const last = parts.pop();
const first = parts.join(' ');
return `${last}, ${first}`.toLowerCase();
}
}
function roleUsers(roleUuid) {
return Object.entries(props.selectedOrg.users)
.filter(([_, u]) => u.role === roleUuid)
.map(([uuid, u]) => ({ uuid, ...u }))
.sort((a, b) => {
const normA = getNormalizedName(a.display_name);
const normB = getNormalizedName(b.display_name);
return normA.localeCompare(normB);
})
}
function roleUserCount(roleUuid) {
return Object.values(props.selectedOrg.users).filter(u => u.role === roleUuid).length
}
function onUserChange(evt, targetRoleUuid) {
// Only handle 'added' events (when a user is dropped into this role)
if (evt.added) {
const userUuid = evt.added.element.uuid
emit('moveUserToRole', userUuid, targetRoleUuid)
}
}
function toggleRolePermission(role, pid, checked) {
@@ -83,7 +119,7 @@ function handleMatrixKeydown(event) {
// Calculate grid dimensions
const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length
const rows = Object.keys(props.selectedOrg.permissions).length
const currentRow = Math.floor(currentIndex / cols)
const currentCol = currentIndex % cols
@@ -219,7 +255,7 @@ function handleRoleHeaderKeydown(event, roleIndex) {
if (checkboxes?.length) {
// Focus the checkbox in the corresponding column
const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length
const rows = Object.keys(props.selectedOrg.permissions).length
const targetIndex = (rows - 1) * cols + roleIndex
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
else checkboxes[checkboxes.length - 1].focus()
@@ -287,7 +323,7 @@ defineExpose({ focusFirstElement })
<template>
<h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span>
<span class="org-name">{{ selectedOrg.org.display_name }}</span>
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
</h2>
@@ -317,7 +353,7 @@ defineExpose({ focusFirstElement })
>
<input
type="checkbox"
:checked="r.permissions.includes(p.uuid)"
:checked="p.uuid in (r.permissions || {})"
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
/>
</div>
@@ -332,84 +368,87 @@ defineExpose({ focusFirstElement })
v-for="(r, roleIndex) in sortedRoles"
:key="r.uuid"
class="role-column"
@dragover="$emit('onRoleDragOver', $event)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
>
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
<strong class="role-name" :title="r.uuid">
<span>{{ r.display_name }}</span>
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button>
<button v-if="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role"></button>
</strong>
<div class="role-actions">
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user"></button>
</div>
</div>
<template v-if="r.users.length > 0">
<ul class="user-list" @keydown="handleUserListKeydown">
<li
v-for="u in r.users.slice().sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) {
return nameA.localeCompare(nameB)
}
return a.uuid.localeCompare(b.uuid)
})"
:key="u.uuid"
class="user-chip"
tabindex="0"
draggable="true"
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
@click="$emit('openUser', u)"
@keydown.enter="$emit('openUser', u)"
:title="u.uuid"
>
<span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
</li>
</ul>
</template>
<div v-else class="empty-role" @keydown="e => handleEmptyRoleKeydown(e, roleIndex)">
<p class="empty-text muted">No members</p>
<button @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role"></button>
<div class="user-list-wrapper">
<draggable
:list="roleUsers(r.uuid)"
group="users"
item-key="uuid"
tag="ul"
class="user-list"
@change="evt => onUserChange(evt, r.uuid)"
@keydown="handleUserListKeydown"
>
<template #item="{ element: u }">
<li
class="user-chip"
tabindex="0"
@click="$emit('openUser', u)"
@keydown.enter="$emit('openUser', u)"
:title="u.uuid"
>
<ProfilePicture
class="user-chip-picture"
:src="u.avatar_url"
:title="u.display_name"
width="3.25rem"
height="100%"
radius="0"
fallback-size="1.3rem"
/>
<span class="user-chip-body">
<span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
</span>
</li>
</template>
</draggable>
<div v-if="roleUserCount(r.uuid) === 0" class="empty-role">
<p class="empty-text muted">No members</p>
</div>
</div>
</div>
</div>
<p v-if="sortedRoles.length >= 2" class="roles-hint muted">Members can be drag&dropped to different roles.</p>
</template>
<style scoped>
.card.surface { padding: var(--space-lg); }
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); font-size: 1.65rem; }
.org-name { font-weight: 600; color: var(--color-heading); }
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-matrix-grid .add-role-head { cursor: pointer; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
.roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
.role-column { flex: 0 0 17em; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
.role-actions { display: flex; gap: var(--space-xs); }
.plus-btn { background: var(--color-accent-soft); color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
.plus-btn { background: none; color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
.user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0; display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); align-items: stretch; gap: 0; cursor: grab; overflow: hidden; min-height: 3.25rem; }
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
.user-chip-picture { align-self: stretch; }
.user-chip-body { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 0.1rem; padding: 0.45rem 0.6rem; }
.user-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-chip .meta { font-size: 0.7rem; opacity: 0.85; }
.user-chip.sortable-ghost { opacity: 0.5; }
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
.user-list:has(.sortable-ghost) + .empty-role { display: none; }
.empty-text { margin: 0; }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.muted { color: var(--color-text-muted); }
@media (max-width: 720px) {
.roles-grid { flex-direction: column; }
+109 -30
View File
@@ -1,19 +1,20 @@
<script setup>
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
info: Object,
orgs: Array,
permissions: Array,
oidcClients: Array,
permissionSummary: Object,
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut'])
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
// Template refs for navigation
const orgSection = ref(null)
const orgActionsRef = ref(null)
const orgTableRef = ref(null)
const permMatrixRef = ref(null)
@@ -21,27 +22,56 @@ const permActionsRef = ref(null)
const permTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name)
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
}))
// Map OIDC client UUIDs to display names for permission domain column
const oidcClientNames = computed(() => {
const map = {}
for (const c of props.oidcClients || []) map[c.uuid] = c.name
return map
})
function domainDisplay(domain) {
if (!domain) return '—'
return oidcClientNames.value[domain] || domain
}
// Map OIDC client UUIDs to their group permissions (sorted by scope)
const clientGroups = computed(() => {
const map = {}
for (const p of props.permissions || []) {
if (p.domain) {
if (!map[p.domain]) map[p.domain] = []
map[p.domain].push(p)
}
}
// Sort each group array by scope
for (const key in map) {
map[key].sort((a, b) => a.scope.localeCompare(b.scope))
}
return map
})
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
// Derive admin status from permissions (info contains ctx from validate response)
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function getRoleNames(org) {
return org.roles
// org.roles is dict[UUID, Role]
return Object.values(org.roles)
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.map(r => r.display_name)
.join(', ')
}
function orgUserCount(org) {
return Object.keys(org.users).length
}
// Table navigation for both org and permissions tables
function handleTableKeydown(event, tableType) {
if (props.navigationDisabled) return
@@ -265,11 +295,11 @@ defineExpose({ focusFirstElement })
<tbody>
<tr v-for="o in sortedOrgs" :key="o.uuid">
<td>
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.org.display_name }}</a>
<button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button>
</td>
<td class="role-names">{{ getRoleNames(o) }}</td>
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
<td class="center">{{ orgUserCount(o) }}</td>
<td v-if="isMasterAdmin" class="center">
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td>
@@ -291,9 +321,9 @@ defineExpose({ focusFirstElement })
v-for="o in sortedOrgs"
:key="'head-' + o.uuid"
class="grid-head org-head"
:title="o.display_name"
:title="o.org.display_name"
>
<span>{{ o.display_name }}</span>
<span>{{ o.org.display_name }}</span>
</div>
<template v-for="p in sortedPermissions" :key="p.uuid">
@@ -307,7 +337,7 @@ defineExpose({ focusFirstElement })
>
<input
type="checkbox"
:checked="o.permissions.includes(p.uuid)"
:checked="p.uuid in o.permissions"
@change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)"
/>
</div>
@@ -339,7 +369,7 @@ defineExpose({ focusFirstElement })
<span class="id-text">{{ p.scope }}</span>
</div>
</td>
<td class="perm-domain">{{ p.domain || '—' }}</td>
<td class="perm-domain">{{ domainDisplay(p.domain) }}</td>
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
<td class="perm-actions center">
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
@@ -348,13 +378,67 @@ defineExpose({ focusFirstElement })
</tbody>
</table>
</div>
<div v-if="isMasterAdmin" class="oidc-clients-section">
<div class="section-header">
<h2>OAuth2 / OpenID Connect</h2>
<p class="section-description">
Allow external websites and applications to securely authenticate users through this system.
The clients are remote sites or applications that we allow to use Paskia for Single Sign-On.
</p>
</div>
<div ref="oidcActionsRef">
<button @click="$emit('createOidcClient')">+ Add Site</button>
</div>
<table class="org-table" ref="oidcTableRef">
<thead>
<tr>
<th scope="col">Client</th>
<th scope="col">Groups</th>
<th scope="col" class="center">Sessions</th>
<th scope="col" class="center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="!oidcClients || oidcClients.length === 0">
<td colspan="4" class="center muted">No OIDC clients configured</td>
</tr>
<tr v-for="client in oidcClients" :key="client.uuid">
<td class="perm-name-cell">
<div class="perm-title">
<a :href="'#oidc:' + client.uuid" @click.prevent="$emit('openOidcClient', client)">{{ client.name }}</a>
</div>
<div class="perm-id-info">
<span class="id-text">{{ client.uuid }}</span>
</div>
</td>
<td class="client-groups">
<span v-if="clientGroups[client.uuid]?.length">{{ clientGroups[client.uuid].map(g => g.scope).join(' ') }}</span>
<span v-else class="muted"></span>
</td>
<td class="center">{{ client.active_sessions || 0 }}</td>
<td class="center">
<button @click="$emit('deleteOidcClient', client)" class="icon-btn delete-icon" aria-label="Delete OIDC client" title="Delete OIDC client"></button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="isMasterAdmin" class="server-options-section">
<div class="section-header">
<h2>Server</h2>
<p class="section-description">
Configure core server settings such as the display name, authentication host, and allowed origins.
</p>
</div>
<button @click="$emit('openServerConfig')"> Server Options</button>
</div>
</template>
<style scoped>
.permissions-section { margin-bottom: var(--space-xl); }
.permissions-section h2 { margin-bottom: var(--space-md); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.actions button { width: auto; }
.org-table a { text-decoration: none; color: var(--color-link); }
.org-table a:hover { text-decoration: underline; }
.org-table .center { width: 6rem; min-width: 6rem; }
@@ -363,24 +447,19 @@ defineExpose({ focusFirstElement })
.perm-title { font-weight: 600; color: var(--color-heading); }
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.perm-domain { color: var(--color-text-muted); font-size: 0.9rem; }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.display-text { margin-right: var(--space-xs); }
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
.perm-actions { text-align: center; }
.center { text-align: center; }
.muted { color: var(--color-text-muted); }
/* OIDC Clients Section */
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
/* Server Options Section */
.server-options-section { margin-top: var(--space-2xl); }
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
</style>
+96 -46
View File
@@ -2,6 +2,7 @@
import { ref, computed } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import CredentialList from '@/components/CredentialList.vue'
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth'
@@ -17,12 +18,19 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut'])
const emit = defineEmits(['generateUserRegistrationLink', 'openOrg', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
const authStore = useAuthStore()
const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const showPictureDialog = ref(false)
const avatarRenderVersion = ref(0)
// Convert credentials dict to array with uuid attached as 'credential'
const credentials = computed(() =>
Object.entries(props.userDetail?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
// Template refs for navigation
const userInfoRef = ref(null)
@@ -43,32 +51,48 @@ function handleEditName() {
emit('editUserName', props.selectedUser)
}
function openPictureDialog() {
if (!props.userDetail || props.userDetail.error) return
showPictureDialog.value = true
}
function closePictureDialog() {
showPictureDialog.value = false
}
function handlePictureUpdated() {
avatarRenderVersion.value += 1
emit('refreshUserDetail')
}
async function handleDelete(credential) {
try {
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
if (data.status === 'ok') {
emit('onUserNameSaved') // Reuse to refresh user detail
emit('refreshUserDetail')
authStore.showMessage('Passkey removed', 'success', 2500)
} else {
console.error('Failed to delete credential', data)
authStore.showMessage(data.detail || 'Failed to remove passkey', 'error')
}
} catch (err) {
authStore.showMessage(err.message || 'Failed to remove passkey', 'error')
console.error('Delete credential error', err)
}
}
async function handleTerminateSession(session) {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try {
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionKey}`, { method: 'DELETE' })
if (data.status === 'ok') {
if (data.current_session_terminated) {
sessionStorage.clear()
location.reload()
return
}
emit('refreshUserDetail') // Refresh without showing rename message
emit('refreshUserDetail')
authStore.showMessage('Session terminated', 'success', 2500)
} else {
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
@@ -78,11 +102,15 @@ async function handleTerminateSession(session) {
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
delete next[sessionKey]
terminatingSessions.value = next
}
}
async function handleDeleteUser() {
emit('deleteUser')
}
// Handle user info section keynav
function handleUserInfoKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
@@ -93,7 +121,7 @@ function handleUserInfoKeydown(event) {
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.mini-btn' })
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.user-picture-btn, .mini-btn' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
} else if (direction === 'down') {
@@ -115,7 +143,7 @@ function handleRegActionsKeydown(event) {
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move to user info edit button
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
} else if (direction === 'down') {
// Move to credential list
credentialListRef.value?.$el?.focus()
@@ -165,10 +193,22 @@ function handleBackButtonKeydown(event) {
// Focus helper for external navigation
function focusFirstElement() {
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
}
defineExpose({ focusFirstElement })
const currentPictureEndpoint = computed(() => {
if (!props.selectedUser?.uuid) return null
return `/auth/api/user/${props.selectedUser.uuid}/profile.webp`
})
const adminPictureTitle = computed(() => {
const username = props.userDetail?.user?.preferred_username || props.selectedUser?.preferred_username
const displayName = props.userDetail?.user?.display_name || props.selectedUser?.display_name
const label = username || displayName || 'User'
return `Profile Picture for ${label}`
})
</script>
<template>
@@ -176,31 +216,40 @@ defineExpose({ focusFirstElement })
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name"
:visits="userDetail.visits"
:created-at="userDetail.created_at"
:last-seen="userDetail.last_seen"
:name="userDetail.user.display_name || selectedUser.display_name"
:avatar-url="userDetail.user.avatar_url"
:avatar-render-version="avatarRenderVersion"
avatar-clickable
:visits="userDetail.user.visits"
:created-at="userDetail.user.created_at"
:last-seen="userDetail.user.last_seen"
:email="userDetail.user.email"
:telephone="userDetail.user.telephone"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/display-name`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
/>
:role-name="userDetail.role.display_name"
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
@avatar-click="openPictureDialog"
@edit="handleEditName"
>
<div class="admin-actions">
<button
class="btn-primary"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
title="Generate a one-time link for this user"
>{{ userDetail?.credentials && Object.keys(userDetail.credentials).length > 0 ? 'Recovery Link' : 'Registration Link' }}</button>
<button
class="btn-danger"
@click="handleDeleteUser"
:disabled="loading"
title="Delete this user"
>Delete User</button>
</div>
</UserBasicInfo>
</div>
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error">
<div class="registration-actions" ref="regActionsRef" @keydown="handleRegActionsKeydown">
<button
class="btn-secondary reg-token-btn"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
>Generate Registration Token</button>
<p class="matrix-hint muted">
Generate a one-time registration link so this user can register or add another passkey.
Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device.
</p>
</div>
<section class="section-block" data-section="registered-passkeys">
<div class="section-header">
<h2>Registered Passkeys</h2>
@@ -208,7 +257,7 @@ defineExpose({ focusFirstElement })
<div class="section-body">
<CredentialList
ref="credentialListRef"
:credentials="userDetail.credentials"
:credentials="credentials"
:aaguid-info="userDetail.aaguid_info"
:allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid"
@@ -222,7 +271,7 @@ defineExpose({ focusFirstElement })
</section>
<SessionList
ref="sessionListRef"
:sessions="userDetail.sessions || []"
:sessions="userDetail.sessions || {}"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
@@ -238,24 +287,25 @@ defineExpose({ focusFirstElement })
</div>
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/create-link`"
:user-name="userDetail?.display_name || selectedUser.display_name"
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
:user-name="userDetail?.user?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')"
@copied="onLinkCopied"
/>
<ProfilePictureEditorModal
v-if="showPictureDialog && currentPictureEndpoint"
:endpoint="currentPictureEndpoint"
:picture-url="userDetail?.user?.avatar_url"
:render-version="avatarRenderVersion"
:title="adminPictureTitle"
@close="closePictureDialog"
@updated="handlePictureUpdated"
/>
</div>
</template>
<style scoped>
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.admin-actions { display: flex; gap: 0.5rem; }
.ancillary-actions { margin-top: -0.5rem; }
.reg-token-btn { align-self: flex-start; }
.registration-actions { display: flex; flex-direction: column; gap: 0.5rem; }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
</style>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="340" height="340">
<path fill="#DDD" d="m169,.5a169,169 0 1,0 2,0zm0,86a76,76 0 1
1-2,0zM57,287q27-35 67-35h92q40,0 67,35a164,164 0 0,1-226,0"/>
</svg>

After

Width:  |  Height:  |  Size: 220 B

+287 -86
View File
@@ -1,68 +1,90 @@
@property --hue {
syntax: '<angle>';
inherits: true;
initial-value: 0.72turn;
}
:root {
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--color-canvas: #ffffff;
--color-surface: #eff6ff;
--color-surface-subtle: #dbeafe;
--color-border: #2563eb;
--color-border-strong: #1e40af;
--color-heading: #1e3a8a;
--color-text: #1e293b;
--color-text-muted: #475569;
--color-link: #1d4ed8;
--color-link-hover: #1e40af;
--color-accent: #2563eb;
--color-accent-strong: #1e40af;
--color-accent-contrast: #ffffff;
--color-success-text: #166534;
--color-success-bg: #dcfce7;
--color-error-text: #b91c1c;
--color-error-bg: #fee2e2;
--color-info-text: #1e40af;
--color-info-bg: #dbeafe;
--color-danger: #dc2626;
--shadow-soft: 0 10px 30px rgba(30, 64, 175, 0.15);
--radius-none: 0;
--color-canvas: white;
--color-surface: #def;
--color-surface-subtle: #bcf;
--color-surface-hover: oklab(0.97 -0.01 -0.02);
--color-dialog: oklab(0.96 -0.01 -0.03);
--color-border: oklab(0.82 -0.02 -0.06);
--color-border-strong: oklab(0.55 -0.05 -0.14);
--color-heading: oklab(0.22 -0.03 -0.09);
--color-text: oklab(0.2 -0.02 -0.05);
--color-text-muted: oklab(0.42 -0.02 -0.06);
--color-link: oklab(0.5 -0.06 -0.17);
--color-link-hover: oklab(0.45 -0.06 -0.19);
--color-accent: oklab(0.55 -0.06 -0.19);
--color-accent-strong: #46f;
--color-accent-contrast: white;
--color-secondary: oklab(0.55 -0.02 -0.05);
--color-secondary-strong: oklab(0.45 -0.02 -0.05);
--color-success-text: oklab(0.4 -0.12 0.09);
--color-success-bg: oklab(0.95 -0.02 0.02);
--color-error-text: oklab(0.45 0.18 0.09);
--color-error-bg: oklab(0.95 0.03 0.01);
--color-info-text: oklab(0.45 -0.05 -0.14);
--color-info-bg: oklab(0.95 -0.01 -0.02);
--color-danger: oklab(0.55 0.20 0.09);
--color-primary: oklab(0.55 -0.06 -0.19);
--color-error: oklab(0.45 0.18 0.09);
--color-success: oklab(0.4 -0.12 0.09);
--color-bg: oklab(0.94 -0.01 -0.03);
--color-accent-soft: oklab(0.92 -0.03 -0.08);
--shadow-soft: 0 0 .2rem black;
--shadow-xl: 0 10px 40px rgba(0, 0, 0, 0.15);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--space-xxs: 0.25rem;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2.25rem;
--space-xxl: 3.5rem;
--space-3xl: 5rem;
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
--transition-base: 160ms ease;
--focus-ring: 0 0 0 2px var(--color-accent);
}
@media (prefers-color-scheme: dark) {
:root {
--color-canvas: #0f172a;
--color-surface: #141b2f;
--color-surface-subtle: #1b243b;
--color-border: #25304a;
--color-border-strong: #3d4d6b;
--color-heading: #fff;
--color-text: #e2e8f0;
--color-text-muted: #94a3b8;
--color-link: #60a5fa;
--color-link-hover: #93c5fd;
--color-accent: #60a5fa;
--color-accent-strong: #3b82f6;
--color-accent-contrast: #0b1120;
--color-success-text: #34d399;
--color-success-bg: #1a4d2e;
--color-error-text: #fca5a5;
--color-error-bg: #4a1f1f;
--color-info-text: #bae6fd;
--color-info-bg: #1e3a5f;
--color-danger: #f87171;
--shadow-soft: 0 0 0 #000000;
}
:root.dark {
--color-canvas: oklab(0.17 -0.02 -0.05);
--color-surface: oklab(0.22 -0.02 -0.05);
--color-surface-subtle: oklab(0.25 -0.02 -0.05);
--color-surface-hover: oklab(0.28 -0.02 -0.05);
--color-dialog: oklab(0.22 -0.02 -0.05);
--color-border: oklab(0.3 -0.02 -0.05);
--color-border-strong: oklab(0.4 -0.02 -0.05);
--color-heading: white;
--color-text: oklab(0.9 0.00 -0.01);
--color-text-muted: oklab(0.7 -0.01 -0.02);
--color-link: oklab(0.7 -0.05 -0.14);
--color-link-hover: oklab(0.8 -0.04 -0.11);
--color-accent: oklab(0.7 -0.05 -0.14);
--color-accent-strong: oklab(0.6 -0.06 -0.17);
--color-accent-contrast: oklab(0.12 -0.01 -0.03);
--color-secondary: oklab(0.6 -0.02 -0.05);
--color-secondary-strong: oklab(0.5 -0.02 -0.05);
--color-success-text: oklab(0.75 -0.12 0.09);
--color-success-bg: oklab(0.3 -0.07 0.05);
--color-error-text: oklab(0.8 0.11 0.05);
--color-error-bg: oklab(0.3 0.07 0.03);
--color-info-text: oklab(0.8 -0.03 -0.10);
--color-info-bg: oklab(0.3 -0.02 -0.05);
--color-danger: oklab(0.7 0.16 0.08);
--color-primary: oklab(0.7 -0.05 -0.14);
--color-error: oklab(0.8 0.11 0.05);
--color-success: oklab(0.75 -0.12 0.09);
--color-bg: oklab(0.18 -0.01 -0.03);
--color-accent-soft: oklab(0.25 -0.03 -0.08);
--shadow-soft: 0 0 0 black;
--shadow-xl: 0 10px 40px rgba(0, 0, 0, 0.4);
}
*,
@@ -150,6 +172,25 @@ a:focus-visible {
max-width: 540px;
}
.view-root--profile .view-header-wrapper {
width: 100%;
max-width: calc(1200px - 2 * var(--layout-padding));
margin: 0 auto;
position: relative;
}
.view-root--profile .theme-toggle {
position: absolute;
top: 0;
right: 0;
}
.section-block--constrained {
width: 100%;
max-width: calc(1200px - 2 * var(--layout-padding));
margin: 0 auto;
}
.view-header {
display: flex;
flex-direction: column;
@@ -197,6 +238,7 @@ a:focus-visible {
flex-wrap: nowrap;
gap: 0.75rem;
justify-content: flex-start;
width: 100%;
}
.button-row button {
@@ -229,6 +271,7 @@ button {
gap: 0.4rem;
background: var(--color-surface);
color: var(--color-text);
transition: box-shadow var(--transition-base), background var(--transition-base), border-color var(--transition-base);
}
button:disabled {
@@ -236,49 +279,53 @@ button:disabled {
filter: opacity(0.6);
}
output[title="Click to copy"] {
cursor: pointer;
}
.btn-primary {
background: var(--color-accent);
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent);
color: var(--color-accent-contrast);
border-color: var(--color-accent);
}
.btn-primary:hover:not(:disabled),
.btn-primary:focus-visible {
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent-strong);
border-color: var(--color-accent-strong);
box-shadow: var(--shadow-soft);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-strong);
border-color: var(--color-accent-strong);
}
.btn-secondary {
background: transparent;
color: var(--color-text);
border-color: var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--color-border-strong);
background: var(--color-surface-subtle);
}
.btn-danger {
background: var(--color-danger);
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary);
color: var(--color-accent-contrast);
border-color: transparent;
}
.btn-danger:hover:not(:disabled) {
filter: brightness(0.92);
.btn-secondary:hover:not(:disabled),
.btn-secondary:focus-visible {
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary-strong);
box-shadow: var(--shadow-soft);
}
/* Focus-visible outlines for buttons */
.btn-primary:focus-visible,
.btn-secondary:focus-visible,
.btn-danger {
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
color: var(--color-accent-contrast);
border-color: transparent;
}
.btn-danger:hover:not(:disabled),
.btn-danger:focus-visible {
outline: 1px solid var(-webkit-focus-ring-color);
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
filter: brightness(0.92);
box-shadow: var(--shadow-soft);
}
input:not([type]),
input[type="text"],
input[type="search"],
input[type="email"],
input[type="tel"],
textarea,
select {
font: inherit;
@@ -291,6 +338,18 @@ select {
transition: border-color var(--transition-base), box-shadow var(--transition-base);
}
input:not([type]):focus,
input[type="text"]:focus,
input[type="search"]:focus,
input[type="email"]:focus,
input[type="tel"]:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: var(--focus-ring);
}
label {
display: flex;
flex-direction: column;
@@ -331,10 +390,118 @@ th {
text-align: left;
}
/* Permission matrix styles */
.matrix-wrapper {
margin: var(--space-md) 0;
padding: var(--space-lg);
}
.matrix-scroll {
overflow-x: auto;
}
.matrix-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.perm-matrix-grid {
display: inline-grid;
gap: 0.25rem;
align-items: stretch;
}
.perm-matrix-grid > * {
padding: 0.35rem 0.45rem;
font-size: 0.75rem;
}
.perm-matrix-grid .grid-head {
color: var(--color-text-muted);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
}
.perm-matrix-grid .perm-head {
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 0.35rem 0.45rem;
font-size: 0.75rem;
}
.perm-matrix-grid .rotated-head {
display: flex;
align-items: flex-end;
justify-content: center;
}
.perm-matrix-grid .rotated-head span {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 0.65rem;
}
.perm-name {
font-weight: 600;
color: var(--color-heading);
padding: 0.35rem 0.45rem;
font-size: 0.75rem;
}
.center {
text-align: center;
}
/* Utility classes */
.muted {
color: var(--color-text-muted);
}
.error {
color: var(--color-danger-text);
}
.small {
font-size: 0.9rem;
}
.icon-btn {
background: none;
border: none;
color: var(--color-text-muted);
padding: 0.2rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.icon-btn:hover {
color: var(--color-heading);
background: var(--color-surface-subtle);
}
.delete-icon {
color: var(--color-danger);
}
.delete-icon:hover {
background: var(--color-error-bg);
color: var(--color-error-text);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
align-items: center;
}
.button-row--center {
justify-content: center;
}
.badge {
display: inline-flex;
align-items: center;
@@ -353,13 +520,17 @@ th {
left: 0;
right: 0;
margin: 0 auto;
z-index: 1200;
z-index: 2000;
width: fit-content;
min-width: min(520px, calc(100% - 2rem));
max-width: calc(100% - 2rem);
display: none;
}
.global-status.show {
display: block;
}
.global-status .status {
display: flex;
align-items: center;
@@ -407,7 +578,7 @@ th {
.device-dialog,
.modal {
background: var(--color-surface);
background: var(--color-dialog);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
width: min(520px, 100%);
@@ -429,8 +600,7 @@ th {
.qr-code {
padding: 1rem;
background: #fff;
box-shadow: var(--shadow-soft);
background: white;
}
.link-container,
@@ -487,9 +657,18 @@ th {
.record-item.is-current,
.credential-item.current-session,
.session-item.is-current {
border-color: var(--color-accent);
background-color: var(--color-surface-subtle);
}
.credential-item.is-hovered,
.session-item.is-current,
.session-item.is-hovered { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
.session-item.is-hovered {
border-color: var(--color-accent);
background-color: var(--color-surface-subtle);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
transform: translateY(-1px);
}
.credential-item.is-linked-session,
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
@@ -567,7 +746,7 @@ th {
}
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.btn-card-delete:hover:not(:disabled) { background: #fee; }
.btn-card-delete:hover:not(:disabled) { filter: brightness(0.85); }
.btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
@@ -644,9 +823,6 @@ th {
.user-info {
display: grid;
border-radius: var(--radius-md);
background: var(--color-surface);
padding: 1.1rem 1.25rem;
}
.user-details {
@@ -692,8 +868,8 @@ th {
width: 100vw;
height: 100vh;
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
backdrop-filter: blur(.2rem) brightness(0.7);
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
display: flex;
align-items: center;
justify-content: center;
@@ -711,7 +887,7 @@ th {
width: 100%;
max-width: 480px;
padding: 2rem;
background: var(--color-surface);
background: var(--color-dialog);
border-radius: var(--radius-lg);
box-shadow: 0 20px 60px #1e293b;
border: 1px solid var(--color-border);
@@ -733,7 +909,7 @@ th {
.slot-machine {
padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
@@ -754,3 +930,28 @@ th {
height: 1.8em;
position: relative;
}
/* Spinner utilities */
.spinner {
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner--sm {
width: 16px;
height: 16px;
border-width: 2px;
}
.spinner--md {
width: 40px;
height: 40px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+2 -2
View File
@@ -148,8 +148,8 @@ const getCredentialAuthIcon = (credential) => {
const info = props.aaguidInfo?.[credential.aaguid]
if (!info) return null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
return info[iconKey] || null
// Fall back to icon if icon_dark is not available
return (isDarkMode && info.icon_dark) || info.icon || null
}
</script>
+10 -16
View File
@@ -1,5 +1,5 @@
<template>
<section class="view-root host-view" data-view="host-profile">
<section class="view-root view-root--wide host-view" data-view="host-profile">
<header class="view-header">
<h1>{{ headingTitle }}</h1>
<p class="view-lede">{{ subheading }}</p>
@@ -10,9 +10,12 @@
<UserBasicInfo
v-if="ctx"
:name="ctx.user.display_name"
:visits="authStore.userInfo?.visits || 0"
:created-at="authStore.userInfo?.created_at"
:last-seen="authStore.userInfo?.last_seen"
:avatar-url="authStore.userInfo.user.avatar_url"
:visits="authStore.userInfo.user.visits"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:email="ctx.user.email"
:telephone="ctx.user.telephone"
:org-display-name="orgDisplayName"
:role-name="roleDisplayName"
:can-edit="false"
@@ -78,9 +81,9 @@ const currentHost = window.location.host
const userInfoSection = ref(null)
const buttonRow = ref(null)
const ctx = computed(() => authStore.userInfo?.ctx || null)
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '')
const ctx = computed(() => authStore.userInfo || null)
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
const headingTitle = computed(() => {
const service = authStore.settings?.rp_name
@@ -125,12 +128,3 @@ const handleButtonRowKeydown = (event) => {
// Down does nothing (no elements below to navigate to)
}
</script>
<style scoped>
.host-view { padding: 3rem 1.5rem 4rem; }
.host-actions { display: flex; flex-direction: column; gap: 0.75rem; }
.host-actions .button-row { gap: 0.75rem; flex-wrap: wrap; }
.host-actions .button-row button { flex: 1 1 0; }
.note { margin: 0; color: var(--color-text-muted); }
.empty-state { margin: 0; color: var(--color-text-muted); }
</style>
+2 -7
View File
@@ -27,17 +27,12 @@ defineProps({
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-border);
border-top: 4px solid var(--color-primary);
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-container p {
color: var(--color-text-muted);
margin: 0;
+30 -45
View File
@@ -1,12 +1,15 @@
<template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<slot />
</dialog>
<div class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
<slot />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const props = defineProps({
// Optional: provide a fallback element to focus if original element is gone
@@ -14,10 +17,12 @@ const props = defineProps({
// Optional: index to help find next sibling when item is deleted
focusIndex: { type: Number, default: -1 },
// Optional: selector for finding siblings when restoring focus
focusSiblingSelector: { type: String, default: '' }
focusSiblingSelector: { type: String, default: '' },
// Optional: extra class name(s) for the modal panel
panelClass: { type: [String, Array, Object], default: '' }
})
defineEmits(['close'])
const emit = defineEmits(['close'])
// Dialog element reference
const dialog = ref(null)
@@ -76,6 +81,13 @@ const restoreFocus = () => {
}
const handleDialogKeydown = (event) => {
// ESC to close (previously handled by <dialog> natively)
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
const direction = getDirection(event)
if (!direction) return
@@ -111,11 +123,11 @@ onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Show the dialog as a modal
holdGlobalBackdrop()
// Focus the most appropriate element
nextTick(() => {
if (dialog.value) {
dialog.value.showModal()
// Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback)
@@ -131,14 +143,16 @@ onMounted(() => {
})
onUnmounted(() => {
releaseGlobalBackdrop()
// Restore focus when modal closes
restoreFocus()
})
</script>
<style scoped>
dialog {
background: var(--color-surface);
.modal-panel {
background: var(--color-dialog);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
@@ -147,65 +161,36 @@ dialog {
width: min(500px, 90vw);
max-height: 90vh;
overflow-y: auto;
position: fixed;
inset: 0;
margin: auto;
height: fit-content;
}
dialog::backdrop {
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
dialog :deep(.modal-title),
dialog :deep(h3) {
.modal-panel :deep(.modal-title),
.modal-panel :deep(h3) {
margin: 0 0 var(--space-md);
font-size: 1.25rem;
font-weight: 600;
color: var(--color-heading);
}
dialog :deep(form) {
.modal-panel :deep(form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
dialog :deep(.modal-form) {
.modal-panel :deep(.modal-form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
dialog :deep(.modal-form label) {
.modal-panel :deep(.modal-form label) {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
dialog :deep(.modal-form input),
dialog :deep(.modal-form textarea) {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 1rem;
line-height: 1.4;
min-height: 2.5rem;
}
dialog :deep(.modal-form input:focus),
dialog :deep(.modal-form textarea:focus) {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px #c7d2fe;
}
dialog :deep(.modal-actions) {
.modal-panel :deep(.modal-actions) {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
-8
View File
@@ -84,12 +84,4 @@ function handleCancel() {
flex-direction: column;
gap: var(--space-md);
}
.error {
color: var(--color-danger-text);
}
.small {
font-size: 0.9rem;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<template>
<component
:is="rootTag"
v-bind="rootAttrs"
class="profile-picture"
:class="{ 'profile-picture-btn': clickable }"
:style="pictureStyle"
@click="handleClick"
>
<img
v-if="showPicture"
:key="`${src || 'none'}:${renderVersion}`"
:src="src"
alt=""
class="profile-picture-image"
@error="handleError"
/>
<img
v-else
:src="profileGeneric"
alt=""
class="profile-picture-fallback"
/>
</component>
</template>
<script setup>
import profileGeneric from '@/assets/profile-generic.svg'
import { computed, ref, watch } from 'vue'
const props = defineProps({
src: { type: String, default: null },
clickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
title: { type: String, default: '' },
renderVersion: { type: [Number, String], default: 0 },
width: { type: String, default: '3rem' },
height: { type: String, default: '3rem' },
radius: { type: String, default: '0.9rem' },
fit: { type: String, default: 'cover' },
filter: { type: String, default: 'none' },
fallbackSize: { type: String, default: '2em' }
})
const emit = defineEmits(['click'])
const pictureAvailable = ref(true)
const rootTag = computed(() => (props.clickable ? 'button' : 'div'))
const showPicture = computed(() => !!props.src && pictureAvailable.value)
const pictureStyle = computed(() => ({
'--profile-picture-width': props.width,
'--profile-picture-height': props.height,
'--profile-picture-radius': props.radius,
'--profile-picture-fit': props.fit,
'--profile-picture-filter': props.filter,
'--profile-picture-fallback-size': props.fallbackSize
}))
const rootAttrs = computed(() => {
if (!props.clickable) return { title: props.title || undefined }
return {
type: 'button',
disabled: props.loading,
title: props.title || undefined
}
})
watch(() => props.src, () => {
pictureAvailable.value = true
})
const handleError = () => {
pictureAvailable.value = false
}
const handleClick = () => {
if (!props.clickable || props.loading) return
emit('click')
}
</script>
<style scoped>
.profile-picture {
display: flex;
align-items: center;
justify-content: center;
width: var(--profile-picture-width);
height: var(--profile-picture-height);
font-size: var(--profile-picture-fallback-size);
line-height: 1;
overflow: hidden;
border-radius: var(--profile-picture-radius);
background: transparent;
flex-shrink: 0;
}
.profile-picture-btn {
padding: 0;
border: 0;
transition: transform 0.12s ease, box-shadow 0.12s ease;
cursor: pointer;
}
.profile-picture-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.profile-picture-btn:disabled {
cursor: progress;
}
.profile-picture-image {
width: 100%;
height: 100%;
object-fit: var(--profile-picture-fit);
display: block;
filter: var(--profile-picture-filter);
}
.profile-picture-fallback {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
</style>
@@ -0,0 +1,489 @@
<template>
<Modal panel-class="modal-panel--avatar" @close="closeEditor">
<h3>{{ title }}</h3>
<input
ref="pictureInput"
type="file"
accept="image/*"
class="profile-picture-editor-input"
:disabled="saving"
@change="handlePictureSelected"
/>
<div ref="picturePreview" class="profile-picture-editor-preview" :style="previewStyle">
<img
v-if="editorImageUrl && displayMetrics"
:src="editorImageUrl"
alt=""
class="profile-picture-editor-image"
:style="editorImageStyle"
/>
<img
v-if="editorImageUrl && displayMetrics"
:src="editorImageUrl"
alt=""
class="profile-picture-editor-image profile-picture-editor-image--overlay"
:style="editorOverlayStyle"
/>
<div
v-if="editorImageUrl && displayMetrics"
class="profile-picture-editor-crop"
:style="cropBoxStyle"
@pointerdown="startMove"
>
<div class="profile-picture-editor-guides" aria-hidden="true">
<div class="profile-picture-editor-guide profile-picture-editor-guide--circle"></div>
<div class="profile-picture-editor-guide profile-picture-editor-guide--eyes"></div>
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-left"></div>
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-right"></div>
</div>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--nw"
@pointerdown.stop="startResize($event, 'nw')"
></button>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--ne"
@pointerdown.stop="startResize($event, 'ne')"
></button>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--sw"
@pointerdown.stop="startResize($event, 'sw')"
></button>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--se"
@pointerdown.stop="startResize($event, 'se')"
></button>
</div>
<ProfilePicture
v-else
class="profile-picture-editor-trigger"
:src="pictureUrl"
:render-version="renderVersion"
clickable
:loading="saving"
title="Choose profile picture"
width="100%"
height="100%"
radius="0"
fit="contain"
fallback-size="5rem"
@click="triggerPictureSelect"
/>
</div>
<div v-if="errorMessage" class="error small">{{ errorMessage }}</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" :disabled="saving" @click="closeEditor">Back</button>
<button
v-if="!editorImageUrl && pictureUrl"
type="button"
class="btn-danger"
:disabled="saving"
@click="removePicture"
>Delete</button>
<button
v-if="editorImageUrl"
type="button"
class="btn-primary"
:disabled="saving"
@click="savePicture"
>Save</button>
</div>
</Modal>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { apiJson } from 'paskia'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
import ProfilePicture from '@/components/ProfilePicture.vue'
const AVATAR_UPLOAD_SIZE = 720
const MIN_CROP_SIZE = 36
const props = defineProps({
endpoint: { type: String, required: true },
pictureUrl: { type: String, default: null },
renderVersion: { type: [Number, String], default: 0 },
title: { type: String, default: 'Profile Picture' }
})
const emit = defineEmits(['close', 'updated'])
const authStore = useAuthStore()
const pictureInput = ref(null)
const picturePreview = ref(null)
const editorImage = ref(null)
const editorImageUrl = ref('')
const previewObjectUrl = ref(null)
const saving = ref(false)
const errorMessage = ref('')
const cropRect = reactive({ x: 0, y: 0, size: 0 })
const previewRect = reactive({ width: 0, height: 0 })
const viewportSize = reactive({ width: 0, height: 0 })
let dragState = null
let previewObserver = null
onMounted(async () => {
viewportSize.width = window.innerWidth
viewportSize.height = window.innerHeight
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', endPointerInteraction)
window.addEventListener('resize', syncPreviewRect)
await nextTick()
syncPreviewRect()
if (picturePreview.value && typeof ResizeObserver !== 'undefined') {
previewObserver = new ResizeObserver(() => syncPreviewRect())
previewObserver.observe(picturePreview.value)
}
})
onUnmounted(() => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', endPointerInteraction)
window.removeEventListener('resize', syncPreviewRect)
previewObserver?.disconnect()
clearPreviewObjectUrl()
})
watch(editorImage, async (image) => {
if (!image) return
await nextTick()
syncPreviewRect()
initializeCrop()
})
const clearPreviewObjectUrl = () => {
if (!previewObjectUrl.value) return
URL.revokeObjectURL(previewObjectUrl.value)
previewObjectUrl.value = null
}
const resetEditor = () => {
clearPreviewObjectUrl()
editorImage.value = null
editorImageUrl.value = ''
cropRect.x = 0
cropRect.y = 0
cropRect.size = 0
errorMessage.value = ''
if (pictureInput.value) pictureInput.value.value = ''
}
const syncPreviewRect = () => {
viewportSize.width = window.innerWidth
viewportSize.height = window.innerHeight
const element = picturePreview.value
if (!element) return
previewRect.width = element.clientWidth
previewRect.height = element.clientHeight
}
const previewStyle = computed(() => {
const image = editorImage.value
if (!image) {
const size = Math.min(viewportSize.width * 0.72, viewportSize.height * 0.42, 352)
return {
width: `${Math.max(160, Math.round(size))}px`,
height: `${Math.max(160, Math.round(size))}px`
}
}
const maxWidth = Math.min(viewportSize.width * 0.88, 928)
const maxHeight = Math.min(viewportSize.height * 0.62, 620)
const scale = Math.min(maxWidth / image.naturalWidth, maxHeight / image.naturalHeight)
return {
width: `${Math.max(1, Math.round(image.naturalWidth * scale))}px`,
height: `${Math.max(1, Math.round(image.naturalHeight * scale))}px`
}
})
const displayMetrics = computed(() => {
const image = editorImage.value
if (!image || !previewRect.width || !previewRect.height) return null
const scale = Math.min(previewRect.width / image.naturalWidth, previewRect.height / image.naturalHeight)
const width = image.naturalWidth * scale
const height = image.naturalHeight * scale
return {
x: (previewRect.width - width) / 2,
y: (previewRect.height - height) / 2,
width,
height
}
})
const editorImageStyle = computed(() => {
const metrics = displayMetrics.value
if (!metrics) return null
return {
width: `${metrics.width}px`,
height: `${metrics.height}px`,
left: `${metrics.x}px`,
top: `${metrics.y}px`
}
})
const editorOverlayStyle = computed(() => {
const metrics = displayMetrics.value
if (!metrics || !cropRect.size) return editorImageStyle.value
const left = cropRect.x
const top = cropRect.y
const right = cropRect.x + cropRect.size
const bottom = cropRect.y + cropRect.size
return {
...editorImageStyle.value,
clipPath: `polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${left}px ${top}px, ${left}px ${bottom}px, ${right}px ${bottom}px, ${right}px ${top}px, ${left}px ${top}px)`
}
})
const cropBoxStyle = computed(() => {
const metrics = displayMetrics.value
if (!metrics || !cropRect.size) return null
return {
left: `${metrics.x + cropRect.x}px`,
top: `${metrics.y + cropRect.y}px`,
width: `${cropRect.size}px`,
height: `${cropRect.size}px`
}
})
const initializeCrop = () => {
const metrics = displayMetrics.value
if (!metrics) return
const size = Math.min(metrics.width, metrics.height)
cropRect.size = size
cropRect.x = (metrics.width - size) / 2
cropRect.y = (metrics.height - size) / 2
}
const triggerPictureSelect = () => {
pictureInput.value?.click()
}
const handlePictureSelected = async (event) => {
const nextFile = event.target.files?.[0] || null
resetEditor()
if (!nextFile) return
previewObjectUrl.value = URL.createObjectURL(nextFile)
editorImageUrl.value = previewObjectUrl.value
const image = new Image()
image.decoding = 'async'
image.src = editorImageUrl.value
try {
await image.decode()
editorImage.value = image
} catch {
errorMessage.value = 'Failed to load image'
resetEditor()
}
}
const startMove = (event) => {
if (!displayMetrics.value || saving.value) return
event.preventDefault()
dragState = {
mode: 'move',
startX: event.clientX,
startY: event.clientY,
initialX: cropRect.x,
initialY: cropRect.y,
initialSize: cropRect.size
}
}
const startResize = (event, handle) => {
if (!displayMetrics.value || saving.value) return
event.preventDefault()
dragState = {
mode: 'resize',
handle,
startX: event.clientX,
startY: event.clientY,
initialX: cropRect.x,
initialY: cropRect.y,
initialSize: cropRect.size
}
}
const handlePointerMove = (event) => {
if (!dragState) return
const metrics = displayMetrics.value
if (!metrics) return
const dx = event.clientX - dragState.startX
const dy = event.clientY - dragState.startY
if (dragState.mode === 'move') {
cropRect.x = Math.max(0, Math.min(metrics.width - dragState.initialSize, dragState.initialX + dx))
cropRect.y = Math.max(0, Math.min(metrics.height - dragState.initialSize, dragState.initialY + dy))
return
}
const directionMap = {
nw: { deltaX: -1, deltaY: -1 },
ne: { deltaX: 1, deltaY: -1 },
sw: { deltaX: -1, deltaY: 1 },
se: { deltaX: 1, deltaY: 1 }
}
const direction = directionMap[dragState.handle]
if (!direction) return
const delta = Math.max(dx * direction.deltaX, dy * direction.deltaY)
const nextSize = Math.max(
MIN_CROP_SIZE,
Math.min(getResizeLimit(metrics, dragState), dragState.initialSize + delta)
)
applyResize(dragState, nextSize)
}
const endPointerInteraction = () => {
dragState = null
}
const getResizeLimit = (metrics, state) => {
const { initialX, initialY, initialSize, handle } = state
if (handle === 'nw') return Math.min(initialX + initialSize, initialY + initialSize)
if (handle === 'ne') return Math.min(metrics.width - initialX, initialY + initialSize)
if (handle === 'sw') return Math.min(initialX + initialSize, metrics.height - initialY)
return Math.min(metrics.width - initialX, metrics.height - initialY)
}
const applyResize = (state, size) => {
const { initialX, initialY, initialSize, handle } = state
if (handle === 'nw') {
cropRect.x = initialX + initialSize - size
cropRect.y = initialY + initialSize - size
cropRect.size = size
return
}
if (handle === 'ne') {
cropRect.x = initialX
cropRect.y = initialY + initialSize - size
cropRect.size = size
return
}
if (handle === 'sw') {
cropRect.x = initialX + initialSize - size
cropRect.y = initialY
cropRect.size = size
return
}
cropRect.x = initialX
cropRect.y = initialY
cropRect.size = size
}
const renderPictureBlob = async () => {
const image = editorImage.value
if (!image) throw new Error('No image selected')
const metrics = displayMetrics.value
if (!metrics || !cropRect.size) throw new Error('Crop selection unavailable')
const canvas = document.createElement('canvas')
canvas.width = AVATAR_UPLOAD_SIZE
canvas.height = AVATAR_UPLOAD_SIZE
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas unavailable')
const sourceScale = image.naturalWidth / metrics.width
const sourceX = cropRect.x * sourceScale
const sourceY = cropRect.y * sourceScale
const sourceSize = cropRect.size * sourceScale
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, AVATAR_UPLOAD_SIZE, AVATAR_UPLOAD_SIZE)
return await new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to export cropped picture'))
return
}
resolve(blob)
}, 'image/webp', 0.9)
})
}
const reloadPictureFromCache = async () => {
const response = await fetch(props.endpoint, {
method: 'GET',
credentials: 'same-origin',
cache: 'reload'
})
if (!response.ok) throw new Error('Failed to refresh profile picture')
}
const savePicture = async () => {
try {
saving.value = true
errorMessage.value = ''
const blob = await renderPictureBlob()
const formData = new FormData()
formData.append('file', blob, 'profile.webp')
await apiJson(props.endpoint, { method: 'PUT', body: formData })
await reloadPictureFromCache()
authStore.showMessage('Profile picture updated.', 'success', 3000)
emit('updated')
closeEditor()
} catch (error) {
errorMessage.value = error.message || 'Failed to update profile picture'
} finally {
saving.value = false
}
}
const removePicture = async () => {
try {
saving.value = true
errorMessage.value = ''
await apiJson(props.endpoint, { method: 'DELETE' })
authStore.showMessage('Profile picture removed.', 'success', 3000)
emit('updated')
closeEditor()
} catch (error) {
errorMessage.value = error.message || 'Failed to remove profile picture'
} finally {
saving.value = false
}
}
const closeEditor = () => {
resetEditor()
emit('close')
}
</script>
<style scoped>
.profile-picture-editor-input { display: none; }
.profile-picture-editor-preview { position: relative; display: flex; justify-content: center; align-items: center; width: auto; max-width: min(58rem, 88vw); min-height: 0; margin: 0 auto; overflow: visible; }
.profile-picture-editor-trigger { min-width: 0; }
.profile-picture-editor-image { position: absolute; user-select: none; pointer-events: none; object-fit: contain; }
.profile-picture-editor-image--overlay { filter: grayscale(0.45) saturate(0.7) brightness(0.68); }
.profile-picture-editor-crop { position: absolute; border: 2px solid white; cursor: move; touch-action: none; }
.profile-picture-editor-guides { position: absolute; inset: 0; pointer-events: none; }
.profile-picture-editor-guide { position: absolute; border-color: rgba(255, 255, 255, 0.52); }
.profile-picture-editor-guide--circle { inset: 0; border: 1.5px solid rgba(255, 255, 255, 0.62); border-radius: 999px; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); }
.profile-picture-editor-guide--eyes { left: 18%; right: 18%; top: 38%; border-top: 1.5px solid rgba(255, 255, 255, 0.56); }
.profile-picture-editor-guide--cheek-left { top: 24%; bottom: 18%; left: 24%; border-left: 1.5px solid rgba(255, 255, 255, 0.48); }
.profile-picture-editor-guide--cheek-right { top: 24%; bottom: 18%; right: 24%; border-right: 1.5px solid rgba(255, 255, 255, 0.48); }
.profile-picture-editor-handle { position: absolute; width: 1.1rem; height: 1.1rem; border-radius: 999px; border: 2px solid white; background: var(--color-accent); padding: 0; }
.profile-picture-editor-handle--nw { left: -0.55rem; top: -0.55rem; cursor: nwse-resize; }
.profile-picture-editor-handle--ne { right: -0.55rem; top: -0.55rem; cursor: nesw-resize; }
.profile-picture-editor-handle--sw { left: -0.55rem; bottom: -0.55rem; cursor: nesw-resize; }
.profile-picture-editor-handle--se { right: -0.55rem; bottom: -0.55rem; cursor: nwse-resize; }
:deep(.modal-panel--avatar) { width: fit-content; max-width: min(58rem, 94vw); }
@media (max-width: 720px) {
.profile-picture-editor-preview { max-width: 100%; }
}
</style>
+179 -100
View File
@@ -1,33 +1,36 @@
<template>
<section class="view-root" data-view="profile">
<div class="theme-toggle">
<button class="theme-btn" @click="themeMenuOpen = !themeMenuOpen" :title="themeTitle">
{{ themeEmoji }}
</button>
<div v-if="themeMenuOpen" class="theme-menu" @click="themeMenuOpen = false">
<button class="theme-option top" :class="{ active: selectedTheme === '' }" @click.stop="setTheme('')" title="Auto">🌓</button>
<button class="theme-option left" :class="{ active: selectedTheme === 'light' }" @click.stop="setTheme('light')" title="Light"></button>
<button class="theme-option right" :class="{ active: selectedTheme === 'dark' }" @click.stop="setTheme('dark')" title="Dark">🌙</button>
<section class="view-root view-root--profile" data-view="profile">
<div class="view-header-wrapper">
<div class="theme-toggle">
<ThemeSelector />
</div>
<header class="view-header">
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
<p class="view-lede">Account dashboard to manage your profile and authentications.</p>
</header>
</div>
<header class="view-header">
<h1>User Profile</h1>
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
</header>
<section class="section-block" ref="userInfoSection">
<section class="section-block section-block--constrained" ref="userInfoSection">
<UserBasicInfo
v-if="authStore.userInfo?.ctx"
v-if="authStore.userInfo?.user"
ref="userBasicInfo"
:name="authStore.userInfo.ctx.user.display_name"
:visits="authStore.userInfo.visits"
:created-at="authStore.userInfo.created_at"
:last-seen="authStore.userInfo.last_seen"
:name="authStore.userInfo.user.display_name"
:avatar-url="authStore.userInfo.user.avatar_url"
:avatar-render-version="avatarRenderVersion"
avatar-clickable
:email="authStore.userInfo.user.email"
:preferred_username="authStore.userInfo.user.preferred_username"
:telephone="authStore.userInfo.user.telephone"
:visits="authStore.userInfo.user.visits"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name"
:org-display-name="authStore.userInfo.org.display_name"
:role-name="authStore.userInfo.role.display_name"
update-endpoint="/auth/api/user/info"
@saved="authStore.loadUserInfo()"
@edit-name="openNameDialog"
@avatar-click="openAvatarDialog"
@edit="openEditDialog"
@keydown="handleUserInfoKeydown"
>
<div class="remote-auth-inline">
@@ -41,11 +44,11 @@
@device-info-visible="showDeviceInfo = $event"
/>
</div>
<p class="remote-auth-description">Provided by another device requesting remote auth.</p>
<p class="remote-auth-description">Login from another device</p>
</UserBasicInfo>
</section>
<section class="section-block">
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
<div class="section-header">
<h2>Your Passkeys</h2>
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
@@ -53,8 +56,8 @@
<div class="section-body">
<CredentialList
ref="credentialList"
:credentials="authStore.userInfo?.credentials || []"
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
:credentials="credentials"
:aaguid-info="authStore.userInfo.aaguid_info"
:loading="authStore.isLoading"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential"
@@ -77,25 +80,14 @@
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
:section-class="useWideLayout ? '' : 'section-block--constrained'"
@terminate="terminateSession"
@session-hover="hoveredSession = $event"
@navigate-out="handleSessionNavigateOut"
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
/>
<Modal v-if="showNameDialog" @close="showNameDialog = false">
<h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form">
<NameEditForm
label="Display Name"
v-model="newName"
:busy="saving"
@cancel="showNameDialog = false"
/>
</form>
</Modal>
<section class="section-block">
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
<div class="button-row" ref="logoutButtons">
<button
type="button"
@@ -111,9 +103,47 @@
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
</template>
</div>
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p>
<div class="logout-footer">
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}.</p>
<a class="paskia-version" href="https://git.zi.fi/leovasanko/paskia" target="_blank" rel="noopener noreferrer">Paskia {{ paskiaVersion }}</a>
</div>
</section>
<Modal v-if="showEditDialog" @close="showEditDialog = false">
<h3>Edit Profile</h3>
<form @submit.prevent="saveProfile" class="modal-form">
<div class="profile-edit-form">
<label for="edit-display-name">Display Name
<input id="edit-display-name" type="text" v-model="editName" :disabled="saving" required />
</label>
<label for="edit-email">Email
<input id="edit-email" type="email" v-model="editEmail" :disabled="saving" />
</label>
<label for="edit-username">Preferred Username
<input id="edit-username" type="text" v-model="editUsername" :disabled="saving" placeholder="username" />
</label>
<label for="edit-telephone">Telephone
<input id="edit-telephone" type="tel" v-model="editTelephone" :disabled="saving" />
</label>
<div v-if="editError" class="error small">{{ editError }}</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" @click="showEditDialog = false" :disabled="saving">Cancel</button>
<button type="submit" class="btn-primary" :disabled="saving" data-nav-primary>Save</button>
</div>
</div>
</form>
</Modal>
<ProfilePictureEditorModal
v-if="showAvatarDialog && currentAvatarEndpoint"
:endpoint="currentAvatarEndpoint"
:picture-url="authStore.userInfo?.user?.avatar_url"
:render-version="avatarRenderVersion"
@close="closeAvatarDialog"
@updated="handleProfilePictureUpdated"
/>
<RegistrationLinkModal
v-if="showRegLink"
endpoint="/auth/api/user/create-link"
@@ -127,9 +157,10 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue'
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
import ThemeSelector from '@/components/ThemeSelector.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
@@ -139,14 +170,19 @@ import passkey from '@/utils/passkey'
import { goBack } from '@/utils/helpers'
import { apiJson } from 'paskia'
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
import { updateThemeFromSession } from '@/utils/theme'
const authStore = useAuthStore()
const updateInterval = ref(null)
const showNameDialog = ref(false)
const showEditDialog = ref(false)
const showAvatarDialog = ref(false)
const showRegLink = ref(false)
const newName = ref('')
const editName = ref('')
const editEmail = ref('')
const editUsername = ref('')
const editTelephone = ref('')
const avatarRenderVersion = ref(0)
const saving = ref(false)
const editError = ref('')
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const showDeviceInfo = ref(false)
@@ -156,35 +192,49 @@ const credentialButtons = ref(null)
const sessionList = ref(null)
const logoutButtons = ref(null)
const breadcrumbs = ref(null)
const userBasicInfo = ref(null)
const userInfoSection = ref(null)
// Theme preference
const selectedTheme = ref('')
const themeMenuOpen = ref(false)
const themeEmoji = computed(() => ({ '': '🌓', light: '', dark: '🌙' })[selectedTheme.value] || '🌓')
const themeTitle = computed(() => ({ '': 'Auto (system)', light: 'Light mode', dark: 'Dark mode' })[selectedTheme.value] || 'Theme')
watch(() => authStore.userInfo?.ctx?.user?.theme, (t) => { selectedTheme.value = t || '' }, { immediate: true })
function setTheme(theme) {
selectedTheme.value = theme
themeMenuOpen.value = false
// Apply immediately for instant feedback
updateThemeFromSession({ user: { theme } }, true)
// Save to server in background
apiJson('/auth/api/user/theme', { method: 'PATCH', body: { theme } })
.catch(e => authStore.showMessage(e.message, 'error'))
}
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.value || showRegLink.value)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' })
watch(showEditDialog, (open) => {
if (!open) {
return
}
const user = authStore.userInfo.user
editName.value = user.display_name ?? ''
editEmail.value = user.email ?? ''
editUsername.value = user.preferred_username ?? ''
editTelephone.value = user.telephone ?? ''
editError.value = ''
})
onMounted(() => {
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
})
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
onUnmounted(() => {
if (updateInterval.value) clearInterval(updateInterval.value)
})
const currentAvatarEndpoint = computed(() => {
const userUuid = authStore.userInfo?.user?.uuid
if (!userUuid) return null
return `/auth/api/user/${userUuid}/profile.webp`
})
const openAvatarDialog = () => {
showAvatarDialog.value = true
}
const closeAvatarDialog = () => {
showAvatarDialog.value = false
}
const handleProfilePictureUpdated = async () => {
await authStore.loadUserInfo()
avatarRenderVersion.value += 1
}
const addNewCredential = async () => {
try {
@@ -233,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
if (direction === 'down') {
event.preventDefault()
// Move to user info section - always focus edit button first
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
}
// ArrowUp at the top does nothing
}
@@ -245,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
if (!direction) return
event.preventDefault()
const itemSelector = '.mini-btn, .pairing-input'
const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
@@ -266,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
focusPreferredButton(credentialButtons.value)
} else if (direction === 'up' || direction === 'left') {
// Focus user info section - always focus edit button first
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
}
}
@@ -328,67 +378,96 @@ const handleDelete = async (credential) => {
}
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
const sessions = computed(() => authStore.userInfo?.sessions || [])
const paskiaVersion = computed(() => authStore.settings?.version || '')
const sessions = computed(() => authStore.userInfo.sessions)
const currentSessionHost = computed(() => {
const currentSession = sessions.value.find(session => session.is_current)
const currentSession = Object.values(sessions.value).find(session => session.is_current)
return currentSession?.host || 'this host'
})
const terminatingSessions = ref({})
const terminateSession = async (session) => {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
try { await authStore.terminateSession(sessionId) }
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try { await authStore.terminateSession(sessionKey) }
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
delete next[sessionKey]
terminatingSessions.value = next
}
}
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
const logout = async () => { await authStore.logout() }
const openNameDialog = () => { newName.value = authStore.userInfo?.ctx.user.display_name ?? ''; showNameDialog.value = true }
const openEditDialog = () => { showEditDialog.value = true }
const isAdmin = computed(() => {
const perms = authStore.userInfo?.ctx.permissions
const perms = Object.values(authStore.userInfo.permissions).map(p => p.scope)
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
})
const hasMultipleSessions = computed(() => sessions.value.length > 1)
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
const credentials = computed(() =>
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
const useWideLayout = computed(() => {
// Check if any single site has more than 8 sessions
const groups = {}
for (const session of Object.values(sessions.value)) {
const host = session.host || ''
if (!groups[host]) groups[host] = []
groups[host].push(session)
}
const hasLargeSessionGroup = Object.values(groups).some(group => group.length > 8)
const saveName = async () => {
const name = newName.value.trim()
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
// Check if passkeys exceed 8
const hasManyCredentials = credentials.value.length > 8
return hasLargeSessionGroup || hasManyCredentials
})
const breadcrumbEntries = computed(() => { const entries = [{ label: 'My Profile', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const saveProfile = async () => {
const name = editName.value.trim()
if (!name) { editError.value = 'Name cannot be empty'; return }
const user = authStore.userInfo.user
const emailVal = editEmail.value.trim() || null
const usernameVal = editUsername.value.trim() || null
const telephoneVal = editTelephone.value.trim() || null
try {
editError.value = ''
saving.value = true
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })
showNameDialog.value = false
await authStore.loadUserInfo()
authStore.showMessage('Name updated successfully!', 'success', 3000)
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
finally { saving.value = false }
let changed = false
const body = {}
if (name !== user.display_name) body.display_name = name
if (emailVal !== (user.email || null)) body.email = emailVal
if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
if (Object.keys(body).length) {
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
changed = true
}
if (changed) {
await authStore.loadUserInfo()
authStore.showMessage('Profile updated!', 'success', 3000)
}
showEditDialog.value = false
} catch (e) {
editError.value = e.message || 'Failed to update profile'
} finally { saving.value = false }
}
</script>
<style scoped>
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
.logout-note { margin: 0; color: var(--color-text-muted); }
.logout-footer { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-top: 0.75rem; }
.paskia-version { color: var(--color-text-muted); text-decoration: none; white-space: nowrap; font-weight: 700; }
.paskia-version:hover { color: var(--color-link-hover); }
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
.theme-btn { background: none; border: none; padding: 0.25rem; font-size: 1.25rem; cursor: pointer; opacity: 0.5; transition: opacity 0.15s; }
.theme-btn:hover { opacity: 0.8; }
.theme-menu { position: absolute; top: 100%; right: 0; width: 5rem; height: 4rem; margin-top: 0.25rem; }
.theme-option { position: absolute; background: none; border: none; font-size: 1.25rem; cursor: pointer; opacity: 0.5; padding: 0.25rem; border-radius: var(--radius-sm); transition: opacity 0.15s, transform 0.15s; }
.theme-option:hover { opacity: 1; transform: scale(1.2); }
.theme-option.active { opacity: 1; }
.theme-option.top { top: 0; left: 50%; transform: translateX(-50%); }
.theme-option.top:hover { transform: translateX(-50%) scale(1.2); }
.theme-option.left { bottom: 0; left: 0; }
.theme-option.right { bottom: 0; right: 0; }
.profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
</style>
@@ -1,16 +1,17 @@
<template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div v-if="linkUrl" class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div class="reg-header-row">
<h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
📱 <span v-if="userName">{{ tokenType === 'account recovery' ? 'Recovery' : 'Registration' }} for {{ userName }}</span><span v-else>Add Another Device</span>
</h2>
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1"></button>
</div>
<div class="device-link-section">
<p class="reg-help">
Scan this QR code on the new device, or copy the link and open it there.
{{ helpText }}
</p>
<QRCodeDisplay
@@ -28,14 +29,15 @@
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
<button class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div>
</div>
</dialog>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from 'paskia'
import { apiJson, AuthCancelledError, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
@@ -51,32 +53,46 @@ const authStore = useAuthStore()
const dialog = ref(null)
const linkUrl = ref(null)
const expiresAt = ref(null)
const tokenType = ref(null)
const actionsRow = ref(null)
// Store the element that had focus before modal opened
const previouslyFocusedElement = ref(null)
// Determine if this is an admin action for another user
const isAdminAction = computed(() => !!props.userName)
// Compute the help text based on token type and context
const helpText = computed(() => {
if (!isAdminAction.value) {
// User adding their own device
return 'Scan this QR code on the new device, or copy the link and open it there.'
}
// Admin action for another user
return `Send this link to ${props.userName}, or have them scan the QR code.`
})
async function generateLink() {
try {
const data = await apiJson(props.endpoint, { method: 'POST' })
if (data.url) {
linkUrl.value = data.url
expiresAt.value = data.expires ? new Date(data.expires) : null
tokenType.value = data.token_type || null
// Show the dialog as modal
holdGlobalBackdrop()
// Focus primary button (or first button if no primary) after content renders
await nextTick()
if (dialog.value) {
dialog.value.showModal()
// Focus primary button (or first button if no primary) after content renders
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
}
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
} else {
emit('close')
}
} catch (e) {
authStore.showMessage(e.message || 'Failed to generate link', 'error')
if (!(e instanceof AuthCancelledError)) {
authStore.showMessage(e.message || 'Failed to generate link', 'error')
}
emit('close')
}
}
@@ -86,7 +102,12 @@ function onCopied() {
}
const handleDialogKeydown = (event) => {
// ESC is handled automatically by <dialog>
// ESC to close
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
// Handle other key navigation
const direction = getDirection(event)
if (!direction) return
@@ -132,6 +153,7 @@ onMounted(() => {
})
onUnmounted(() => {
if (linkUrl.value) releaseGlobalBackdrop()
// Restore focus when modal closes
const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) {
@@ -141,23 +163,6 @@ onUnmounted(() => {
</script>
<style scoped>
dialog {
border: none;
background: transparent;
padding: 0;
max-width: none;
width: fit-content;
height: fit-content;
position: fixed;
inset: 0;
margin: auto;
}
dialog::backdrop {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
}
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity: 1; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
+14 -12
View File
@@ -53,17 +53,16 @@
<!-- Device info display (shown when 3 words match a request) -->
<div v-else-if="deviceInfo" class="device-info">
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p>
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
<p v-if="error" class="error-message">{{ error }}</p>
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
<div class="button-row device-actions">
<button
type="button"
class="btn-secondary"
:disabled="loading"
@click="deny"
style="flex: 1;"
>
Deny
</button>
@@ -72,7 +71,6 @@
type="submit"
:disabled="loading"
class="btn-primary"
style="flex: 1;"
>
{{ loading ? 'Authenticating…' : 'Authorize' }}
</button>
@@ -773,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
.input-wrapper {
position: relative;
display: flex;
width: 280px;
max-width: 100%;
}
@@ -921,10 +918,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.device-info {
display: flex;
flex-direction: column;
@@ -945,9 +938,18 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
}
.error-message {
margin: 0;
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: var(--color-error, #ef4444);
margin-bottom: 1rem;
}
.device-actions {
margin-top: 0.75rem;
display: flex;
gap: 0.5rem;
}
.device-actions button {
flex: 1;
}
</style>
@@ -8,7 +8,7 @@
<!-- Error state -->
<div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p>
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button>
<button class="btn-primary" @click="retry">Try Again</button>
</div>
<!-- Connecting phase -->
@@ -163,6 +163,9 @@ async function startRemoteAuth() {
// PoW challenge
const powChallenge = await ws.receive_json()
if (powChallenge.status) {
throw new Error(powChallenge.detail || `Failed to connect: ${powChallenge.status}`)
}
if (powChallenge.pow) {
const challenge = b64dec(powChallenge.pow.challenge)
const nonces = await solvePoW(challenge, powChallenge.pow.work)
@@ -196,7 +199,7 @@ async function startRemoteAuth() {
} else if (msg.status === 'authenticated') {
// Success
completed.value = true
emit('authenticated', { session_token: msg.session_token })
emit('authenticated', { exchange_code: msg.exchange_code })
break
} else if (msg.status === 'denied') {
// Explicitly denied by the authenticating device
@@ -293,10 +296,6 @@ defineExpose({ retry, cancel })
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.auth-display {
display: flex;
flex-direction: column;
@@ -499,6 +498,10 @@ defineExpose({ retry, cancel })
color: var(--color-error, #ef4444);
}
.error-section button {
margin-top: 0.75rem;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.auth-content {
+32 -16
View File
@@ -1,6 +1,6 @@
<template>
<div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;">
<div v-if="status.show" class="global-status show">
<div :class="['status', status.type]">
{{ status.message }}
</div>
@@ -18,7 +18,7 @@
<div class="section-body center">
<!-- Local passkey authentication view -->
<div v-if="authView === 'local'" class="auth-view">
<div class="button-row center" ref="buttonRow">
<div class="button-row button-row--center" ref="buttonRow">
<slot name="actions"
:loading="loading"
:can-authenticate="canAuthenticate"
@@ -58,15 +58,20 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
import { fetchJson, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav'
import { updateThemeFromSession } from '@/utils/theme'
const props = defineProps({
mode: {
type: String,
default: 'login',
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
validator: (value) => ['login', 'reauth', 'forbidden', 'oidc'].includes(value)
},
oidcQueryString: {
type: String,
default: null
}
})
@@ -142,7 +147,8 @@ async function fetchSettings() {
async function validateSession() {
try {
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
session.value = await fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
updateThemeFromSession(session.value?.ctx)
if (isAuthenticated.value && props.mode !== 'reauth') {
currentView.value = 'forbidden'
emit('forbidden', session.value)
@@ -163,7 +169,7 @@ async function authenticateUser() {
loading.value = true
showMessage('Starting authentication…', 'info')
let result
try { result = await passkey.authenticate() } catch (error) {
try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) {
loading.value = false
const message = error?.message || 'Passkey authentication cancelled'
const cancelled = message === 'Passkey authentication cancelled'
@@ -171,7 +177,13 @@ async function authenticateUser() {
emit('auth-error', { message, cancelled })
return
}
try { await setSessionCookie(result) } catch (error) {
// OIDC flow: no session cookie, just emit the redirect_url
if (result.redirect_url) {
loading.value = false
emit('authenticated', result)
return
}
try { await exchangeCode(result) } catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
@@ -186,7 +198,7 @@ async function logoutUser() {
if (loading.value) return
loading.value = true
try {
await fetchJson('/auth/api/logout', { method: 'POST' })
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
session.value = null
currentView.value = 'login'
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
@@ -202,13 +214,13 @@ function openProfile() {
if (profileWindow) profileWindow.focus()
}
async function setSessionCookie(result) {
if (!result?.session_token) {
console.error('setSessionCookie called with missing session_token:', result)
throw new Error('Authentication response missing session_token')
async function exchangeCode(result) {
if (!result?.exchange_code) {
console.error('exchangeCode called with missing exchange_code:', result)
throw new Error('Authentication response missing exchange_code')
}
return await fetchJson('/auth/api/set-session', {
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` }
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }, timeout: paskiaSettings.auth_ms
})
}
@@ -223,7 +235,7 @@ function switchToLocal() {
async function handleRemoteAuthenticated(result) {
showMessage('Authenticated from another device!', 'success', 2000)
try {
await setSessionCookie(result)
await exchangeCode(result)
} catch (error) {
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
@@ -265,7 +277,12 @@ watch(initializing, (newVal) => {
onMounted(async () => {
await fetchSettings()
await validateSession()
// OIDC mode doesn't depend on session state - skip validation
if (props.mode !== 'oidc') {
await validateSession()
} else {
currentView.value = 'login'
}
initializing.value = false
// Add click handler for inline links
@@ -284,7 +301,6 @@ defineExpose({
</script>
<style scoped>
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.surface.surface--tight {
+34 -34
View File
@@ -1,25 +1,26 @@
<template>
<section class="section-block" data-component="session-list-section">
<section :class="['section-block', sectionClass]" data-component="session-list-section">
<div class="section-header">
<h2>Active Sessions</h2>
<p class="section-description">{{ sectionDescription }}</p>
</div>
<div class="section-body">
<div>
<template v-if="Array.isArray(sessions) && sessions.length">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
<template v-if="sessionsArray.length">
<div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
<span class="session-group-icon">🌐</span>
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
<template v-if="group.isOIDC">{{ group.displayName }}</template>
<a v-else-if="key" :href="hostUrl(key)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ key }}</a>
<template v-else>Unbound host</template>
</span>
<div class="session-list">
<div
v-for="session in group.sessions"
:key="session.id"
:key="session.key"
:class="['session-item', {
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
'is-hovered': hoveredSession?.id === session.id,
'is-hovered': hoveredSession?.key === session.key,
'is-linked-credential': hoveredCredentialUuid === session.credential
}]"
tabindex="-1"
@@ -30,17 +31,17 @@
@keydown="handleItemKeydown($event, session)"
>
<div class="item-top">
<h4 class="item-title">{{ session.user_agent }}</h4>
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
<div class="item-actions">
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSession?.key === session.key" class="badge badge-current">Selected</span>
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
<button
@click="$emit('terminate', session)"
class="btn-card-delete"
:disabled="isTerminating(session.id)"
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
:disabled="isTerminating(session.key)"
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
tabindex="-1"
></button>
</div>
@@ -69,12 +70,13 @@ import { hostIP } from '@/utils/helpers'
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
const props = defineProps({
sessions: { type: Array, default: () => [] },
sessions: { type: Object, default: () => ({}) },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) },
hoveredCredentialUuid: { type: String, default: null },
navigationDisabled: { type: Boolean, default: false },
sectionClass: { type: String, default: '' },
})
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
@@ -106,7 +108,7 @@ const handleCardClick = (event) => {
}
}
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
const handleGroupKeydown = (event, host) => {
const group = event.currentTarget
@@ -149,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal)
handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session)
if (!isTerminating(session.key)) emit('terminate', session)
})
if (event.defaultPrevented) return
@@ -208,9 +210,14 @@ const copyIp = async (ip) => {
const displayIp = ip => hostIP(ip) ?? ip
// Convert sessions dict to array with key attached
const sessionsArray = computed(() =>
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
)
const currentHostIP = computed(() => {
if (hoveredIp.value) return hostIP(hoveredIp.value)
const current = props.sessions.find(s => s.is_current)
const current = sessionsArray.value.find(s => s.is_current)
return current ? hostIP(current.ip) : null
})
@@ -218,27 +225,20 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
const groupedSessions = computed(() => {
const groups = {}
for (const session of props.sessions) {
const host = session.host || ''
if (!groups[host]) {
groups[host] = { sessions: [], isCurrentSite: false }
}
groups[host].sessions.push(session)
if (session.is_current_host) {
groups[host].isCurrentSite = true
for (const session of sessionsArray.value) {
const groupKey = session.client || session.host || ''
if (!groups[groupKey]) {
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
}
groups[groupKey].sessions.push(session)
if (session.is_current_host) groups[groupKey].isCurrentSite = true
}
// Sort sessions within each group by last_renewed descending
for (const host in groups) {
groups[host].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
}
// Sort groups by host name (natural sort)
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const sortedHosts = Object.keys(groups).sort(collator.compare)
const sortedGroups = {}
for (const host of sortedHosts) {
sortedGroups[host] = groups[host]
}
return sortedGroups
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
return collator.compare(a.displayName, b.displayName) || collator.compare(a.sessions[0]?.client || '', b.sessions[0]?.client || '')
})
return Object.fromEntries(sorted)
})
</script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div v-if="authStore.status.show" class="global-status" style="display: block;">
<div v-if="authStore.status.show" class="global-status show">
<div :class="['status', authStore.status.type]">
{{ authStore.status.message }}
</div>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="theme-selector" @click.stop>
<button v-for="t in themes" :key="t.value" class="theme-icon" :class="{ hidden: isHidden(t.value) }"
:style="{ top: getPos(t.value).y + 'px', left: getPos(t.value).x + 'px' }" :title="t.title"
@click="handleClick(t.value)">{{ t.icon }}</button>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { apiJson } from 'paskia'
import { updateThemeFromSession, getCachedTheme } from '@/utils/theme'
const open = ref(false), closing = ref(false), closingValue = ref(null), selected = ref(getCachedTheme())
const themes = [{ value: '', icon: '🌓', title: 'Auto' }, { value: 'light', icon: '☀️', title: 'Light' }, { value: 'dark', icon: '🌙', title: 'Dark' }]
const center = { x: 16, y: 16 }
const expanded = { '': { x: 16, y: 0 }, light: { x: 0, y: 28 }, dark: { x: 32, y: 28 } }
const getPos = v => closing.value ? (v === closingValue.value ? center : expanded[v]) : open.value ? expanded[v] : (v === selected.value ? center : expanded[v])
const isHidden = v => closing.value ? v !== closingValue.value : !open.value && v !== selected.value
function close(v) {
closingValue.value = v
closing.value = true
setTimeout(() => { open.value = closing.value = false; closingValue.value = null }, 200)
}
function handleClick(v) {
if (!open.value) { open.value = true; return }
close(v)
setTimeout(() => {
selected.value = v
updateThemeFromSession({ user: { theme: v } }, true)
apiJson('/auth/api/user/theme', { method: 'PATCH', body: { theme: v } }).catch(() => {})
}, 200)
}
function onOutside(e) { if (open.value && !closing.value && !e.target.closest('.theme-selector')) close(selected.value) }
onMounted(() => document.addEventListener('click', onOutside))
onUnmounted(() => document.removeEventListener('click', onOutside))
</script>
<style scoped>
.theme-selector { position: relative; width: 2rem; height: 2rem; }
.theme-icon { position: absolute; transform: translate(-50%, -50%); background: none; border: none; font-size: 1.25rem; cursor: pointer; padding: 0.25rem; transition: top 0.2s, left 0.2s, opacity 0.15s; }
.theme-icon:hover, .theme-icon:focus-visible { transform: translate(-50%, -50%) scale(1.15); }
.theme-icon.hidden { opacity: 0; pointer-events: none; }
</style>
+91 -60
View File
@@ -1,23 +1,49 @@
<template>
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
<h3 class="user-name-heading">
<span class="icon">👤</span>
<span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name"></button>
</span>
</h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub">
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div>
</div>
<div class="user-details">
<span class="date-label"><strong>Visits:</strong></span>
<span class="date-value">{{ visits || 0 }}</span>
<span class="date-label"><strong>Registered:</strong></span>
<span class="date-value">{{ formatDate(createdAt) }}</span>
<span class="date-label"><strong>Last seen:</strong></span>
<span class="date-value">{{ formatDate(lastSeen) }}</span>
<div class="user-info-content">
<ProfilePicture
:src="avatarUrl"
:render-version="avatarRenderVersion"
:clickable="avatarClickable"
:loading="loading"
:title="avatarClickable ? 'Change profile picture' : ''"
width="5.25rem"
height="5.25rem"
radius="var(--radius-sm)"
fallback-size="2.8em"
class="user-picture"
:class="avatarClickable ? 'user-picture-btn' : ''"
@click="emit('avatar-click')"
/>
<h3 class="user-name-heading">
<span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('edit')" title="Edit profile"></button>
</span>
</h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub">
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div>
</div>
<div class="info-fields-block">
<div v-if="preferred_username" class="contact-item">🆔 {{ preferred_username }}</div>
<a v-if="email" :href="`mailto:${email}`" class="contact-link"> {{ email }}</a>
<a v-if="telephone" :href="`tel:${telephone}`" class="contact-link">📞 {{ telephone }}</a>
</div>
<div class="info-line">
<span v-if="visits">
<span class="info-date">{{ formatDate(createdAt) }}</span>
<span class="info-punct"> </span>
<span class="info-date">{{ formatDate(lastSeen) }}</span>
<span class="info-punct"> ×</span>
<span class="info-count">{{ visits }}</span>
</span>
<span v-else>
<span class="info-label">Created </span>
<span class="info-date">{{ formatDate(createdAt) }}</span>
<span class="info-punct"> Never signed in</span>
</span>
</div>
</div>
<div v-if="$slots.default" class="user-info-extra">
<slot></slot>
@@ -26,80 +52,85 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { computed } from 'vue'
import ProfilePicture from '@/components/ProfilePicture.vue'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
name: { type: String, required: true },
avatarUrl: { type: String, default: null },
avatarRenderVersion: { type: [Number, String], default: 0 },
email: { type: String, default: null },
preferred_username: { type: String, default: null },
telephone: { type: String, default: null },
visits: { type: [Number, String], default: 0 },
createdAt: { type: [String, Number, Date], default: null },
lastSeen: { type: [String, Number, Date], default: null },
updateEndpoint: { type: String, default: null },
canEdit: { type: Boolean, default: true },
avatarClickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
orgDisplayName: { type: String, default: '' },
roleName: { type: String, default: '' }
})
const emit = defineEmits(['saved', 'editName'])
const authStore = useAuthStore()
const emit = defineEmits(['saved', 'edit', 'avatar-click'])
const userLoaded = computed(() => !!props.name)
</script>
<style scoped>
.user-info.has-extra {
grid-template-columns: auto 1fr 2fr;
grid-template-columns: minmax(0, 1fr) 14rem;
grid-template-areas:
"heading heading extra"
"org org extra"
"label1 value1 extra"
"label2 value2 extra"
"label3 value3 extra";
"content extra";
gap: 1.5rem;
}
.user-info:not(.has-extra) {
grid-template-columns: auto 1fr;
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3";
"content";
}
@media (max-width: 720px) {
.user-info.has-extra {
grid-template-columns: auto 1fr;
grid-template-columns: 1fr;
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3"
"extra extra";
"content"
"extra";
}
}
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
.info-label:nth-of-type(1) { grid-area: label1; }
.info-value:nth-of-type(2) { grid-area: value1; }
.info-label:nth-of-type(3) { grid-area: label2; }
.info-value:nth-of-type(4) { grid-area: value2; }
.info-label:nth-of-type(5) { grid-area: label3; }
.info-value:nth-of-type(6) { grid-area: value3; }
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
.user-name-row.editing { flex: 1 1 auto; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
.user-name-heading .name-input { width: auto; }
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
.user-info-content {
grid-area: content;
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"picture heading fields"
"picture org fields"
"picture info info";
gap: 0 1rem;
min-width: 0;
}
:deep(.user-picture) { grid-area: picture; align-self: stretch; }
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
.info-fields-block { grid-area: fields; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.contact-item { display: block; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-link { color: var(--color-text); text-decoration: none; display: block; transition: transform 0.1s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-link:hover { transform: scale(1.01); }
.info-line { grid-area: info; line-height: 1.4; font-size: 0.9em; }
.info-date { color: var(--color-text) !important; }
.info-label { color: var(--color-text) !important; }
.info-punct { color: var(--color-text-muted) !important; }
.info-count { color: var(--color-text-muted) !important; }
.user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; background: transparent; }
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+9 -7
View File
@@ -1,13 +1,14 @@
import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
import { apiJson } from 'paskia'
import { apiJson, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
export const useAuthStore = defineStore('auth', {
state: () => ({
// Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
ctx: null, // Session context from validate
isLoading: false,
// Settings
@@ -49,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
return await apiJson('/auth/api/set-session', {
method: 'POST',
headers: {'Authorization': `Bearer ${result.session_token}`},
timeout: paskiaSettings.auth_ms,
})
},
async register() {
@@ -86,8 +88,8 @@ export const useAuthStore = defineStore('auth', {
},
async loadUserInfo() {
try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
updateThemeFromSession(this.userInfo?.ctx)
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
updateThemeFromSession(this.userInfo)
console.log('User info loaded:', this.userInfo)
} catch (error) {
// Suppress toast for 401/403 errors - the auth iframe will handle these
@@ -103,9 +105,9 @@ export const useAuthStore = defineStore('auth', {
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
await this.loadUserInfo()
},
async terminateSession(sessionId) {
async terminateSession(sessionKey) {
try {
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
const payload = await apiJson(`/auth/api/user/session/${sessionKey}`, { method: 'DELETE' })
if (payload?.current_session_terminated) {
sessionStorage.clear()
location.reload()
@@ -120,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
},
async logout() {
try {
await apiJson('/auth/api/logout', {method: 'POST'})
await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear()
location.reload()
} catch (error) {
@@ -133,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
},
async logoutEverywhere() {
try {
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
await apiJson('/auth/api/user/logout-all', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear()
location.reload()
} catch (error) {
-32
View File
@@ -1,32 +0,0 @@
// Cache for auth iframe URL by mode
const authIframeUrlCache = {}
/**
* Get the auth iframe URL for a given mode.
* Fetches from /auth/api/forward which returns URL in the auth.iframe field.
* Results are cached per mode.
* @param {string} mode - The auth mode ('login', 'reauth', 'forbidden')
* @returns {Promise<string>} - The URL for the iframe
*/
export async function getAuthIframeUrl(mode = 'login') {
if (authIframeUrlCache[mode]) {
return authIframeUrlCache[mode]
}
// Fetch from forward endpoint - it returns URL in auth.iframe on 401/403
const response = await fetch('/auth/api/forward')
if (response.status === 401 || response.status === 403) {
const data = await response.json()
if (data.auth?.iframe) {
// The iframe field now contains a URL with hash fragment
// If mode differs, update the hash param
let url = data.auth.iframe
if (mode !== data.auth.mode) {
url = url.replace(/mode=[^&]*/, `mode=${mode}`)
}
authIframeUrlCache[mode] = url
return url
}
}
throw new Error('Unable to fetch auth iframe URL')
}
+7 -2
View File
@@ -54,8 +54,13 @@ export async function register(resetToken = null, displayName = null, onstartreg
}
}
export async function authenticate() {
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
export async function authenticate(queryString = null) {
// Build URL, optionally appending raw query string (e.g. for OIDC params)
let url = await makeUrl('/auth/ws/authenticate')
if (queryString) {
url += queryString.startsWith('?') ? queryString : `?${queryString}`
}
const ws = await aWebSocket(url)
try {
let res = await ws.receive_json()
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
+6 -61
View File
@@ -1,61 +1,11 @@
// Theme override utilities - shared across apps
// User preference or URL hash can force light/dark mode
export const themeColors = {
light: {
'color-canvas': '#ffffff',
'color-surface': '#eff6ff',
'color-surface-subtle': '#dbeafe',
'color-border': '#2563eb',
'color-border-strong': '#1e40af',
'color-heading': '#1e3a8a',
'color-text': '#1e293b',
'color-text-muted': '#475569',
'color-link': '#1d4ed8',
'color-link-hover': '#1e40af',
'color-accent': '#2563eb',
'color-accent-strong': '#1e40af',
'color-accent-contrast': '#ffffff',
'color-success-text': '#166534',
'color-success-bg': '#dcfce7',
'color-error-text': '#b91c1c',
'color-error-bg': '#fee2e2',
'color-info-text': '#1e40af',
'color-info-bg': '#dbeafe',
'color-danger': '#dc2626',
'shadow-soft': '0 10px 30px rgba(30, 64, 175, 0.15)',
},
dark: {
'color-canvas': '#0f172a',
'color-surface': '#141b2f',
'color-surface-subtle': '#1b243b',
'color-border': '#25304a',
'color-border-strong': '#3d4d6b',
'color-heading': '#fff',
'color-text': '#e2e8f0',
'color-text-muted': '#94a3b8',
'color-link': '#60a5fa',
'color-link-hover': '#93c5fd',
'color-accent': '#60a5fa',
'color-accent-strong': '#3b82f6',
'color-accent-contrast': '#0b1120',
'color-success-text': '#34d399',
'color-success-bg': '#1a4d2e',
'color-error-text': '#fca5a5',
'color-error-bg': '#4a1f1f',
'color-info-text': '#bae6fd',
'color-info-bg': '#1e3a5f',
'color-danger': '#f87171',
'shadow-soft': '0 0 0 #000000',
}
}
const STYLE_ID = 'theme-override'
const TRANSITION_ID = 'theme-transition'
const STORAGE_KEY = 'paskia-theme'
/** Apply theme override CSS - selector targets .surface for restricted app, :root for main apps */
export function applyTheme(theme, selector = ':root', animate = false) {
/** 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)
@@ -67,14 +17,9 @@ export function applyTheme(theme, selector = ':root', animate = false) {
}
setTimeout(() => document.getElementById(TRANSITION_ID)?.remove(), 350)
}
document.getElementById(STYLE_ID)?.remove()
if (theme && themeColors[theme]) {
const css = `${selector} { ${Object.entries(themeColors[theme]).map(([k, v]) => `--${k}: ${v}`).join('; ')}; }`
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = css
document.head.appendChild(style)
}
// 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 */
@@ -97,5 +42,5 @@ export function initThemeFromCache() {
export function updateThemeFromSession(ctx, animate = false) {
const theme = ctx?.user?.theme || ''
setCachedTheme(theme)
applyTheme(theme, ':root', animate)
applyTheme(theme, document.documentElement, animate)
}
+6 -5
View File
@@ -1,17 +1,18 @@
/**
* FastAPI-Vue Vite Plugin
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
*
* Configures Vite for FastAPI backend integration:
* - Proxies /api/* requests to the FastAPI backend
* - Builds to the Python module's frontend-build directory
*
* Environment variables (with defaults):
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
* Options:
* paths - Array of paths to proxy (default: ["/api"])
*/
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
// Build proxy configuration for each path
const proxy = {}
for (const path of paths) {
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
}
return {
name: "fastapi-vite",
name: "vite-plugin-fastapi-paskia",
config: () => ({
server: { proxy },
build: {
+13
View File
@@ -16,6 +16,7 @@ export default defineConfig(({ command }) => ({
fastapiVue({ paths: [
"/auth/api",
"/auth/ws",
"/.well-known/openid-configuration",
// Passphrase links: /auth/word1.word2.word3.word4.word5
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
// Passphrase links: /word1.word2.word3.word4.word5
@@ -46,6 +47,18 @@ export default defineConfig(({ command }) => ({
})
}
},
{
name: 'restricted-endpoints-rewrite',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
// Rewrite /auth/restricted/iframe and /auth/restricted/oidc to /auth/restricted/
if (req.url === '/auth/restricted/iframe' || req.url === '/auth/restricted/oidc') {
req.url = '/auth/restricted/'
}
next()
})
}
},
{
name: 'serve-examples',
configureServer(server) {
+92
View File
@@ -0,0 +1,92 @@
# OIDC Provider Implementation
OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication.
## Data Models
**User** — Added: `email`, `preferred_username`
**Session** — Added: `client_uuid` (None = native, set = OIDC)
- `key: bytes` — hashed DB key, never stored raw
- `secret``hash_secret("session", secret)` → DB lookup
- OIDC `sid``base64url.encode(hash_secret("oidc", session.key))`
**OIDClient**`uuid, client_secret_hash, name, redirect_uris`
## Auth Codes (In-Memory Only)
60-second lifetime, auto-cleaned:
```python
from paskia.authcode import AuthCode, OIDC, codes
class AuthCode(msgspec.Struct):
session_key: str # Session DB key
created: datetime
oidc: OIDC | None # Only for OIDC mode
class OIDC(msgspec.Struct):
redirect_uri, scope, nonce, code_challenge, code_challenge_method: str
```
Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)`
## Authorization Flows
### OIDC (Authorization Code)
1. `GET /auth/restricted/oidc?client_id=UUID&redirect_uri=...&scope=openid&nonce=...&code_challenge=...`
2. Frontend → WebSocket: `/auth/ws/authenticate?client_id=...&redirect_uri=...&...`
3. Validate client/redirect_uri, authenticate via passkey
4. `db.oidc_login()``(secret, session_key)`
5. Create `AuthCode(session_key, oidc=OIDC(...))` → code
6. Return: `{"redirect_url": "{redirect_uri}?code={code}&state={state}"}`
7. Client exchanges code at `/auth/oidc/token` with `code_verifier` (PKCE S256)
**Token:** `access_token, id_token, refresh_token={secret}, expires_in=3600`
**ID token:** `sub, sid (base64url), name, preferred_username, email, groups`
### Native (Cookie)
1. WebSocket: `/auth/ws/authenticate` (no OIDC params)
2. Authenticate via passkey
3. `db.login()` → secret
4. Create `AuthCode(session_key=secret, oidc=None)` → exchange_code
5. Return: `{"user": "UUID", "exchange_code": "..."}`
6. `POST /auth/api/exchange` with code → sets cookie
## Refresh & Logout
**Refresh:** `POST /auth/oidc/token` with `grant_type=refresh_token&refresh_token={secret}&client_id=...&client_secret=...`
- Looks up session, validates client match
- Extends expiry +24h (sliding window)
- Returns new tokens with same `sid`
**Back-channel logout:** `POST /auth/oidc/backchannel-logout` with `logout_token={jwt}`
- Verify signature, extract `sid` or `sub`
- Delete matched sessions
- Return 200 OK
Discovery: `backchannel_logout_supported: true`
## Endpoints
- `GET /.well-known/openid-configuration` — Discovery
- `GET /auth/oidc/keys` — Keys (EdDSA)
- `POST /auth/oidc/token` — Exchange/refresh
- `GET /auth/oidc/userinfo` — User (bearer token, includes `picture` when `profile` scope is granted and avatar exists)
- `POST /auth/oidc/backchannel-logout` — Logout
- `POST /auth/api/exchange` — Native auth code → cookie
## Claims
- `profile` scope may include `name`, `preferred_username`, and `picture`
- `email` scope may include `email`
- `groups` is emitted from client-scoped permissions
## Files
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/globals.py](paskia/globals.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)
+25 -1
View File
@@ -64,11 +64,35 @@ When a 401/403 response includes an auth iframe URL, the request automatically p
The JSON variants set headers automatically, with body and response in JSON.
### Timeout Settings
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
```js
import { settings } from 'paskia'
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
settings.fetch_ms = 10000
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
settings.auth_ms = 1000
// SessionValidator polling and idle timers
settings.poll_ms = 60000
settings.idle_ms = 300000
```
You can still override timeout per request:
```js
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
```
### Authentication Overlay
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/iframe#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
```js
import { showAuthIframe, AuthCancelledError } from 'paskia'
+13 -4
View File
@@ -1,7 +1,15 @@
{
"name": "paskia",
"version": "0.1.2",
"version": "1.4.0",
"description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko",
"license": "Unlicense",
"homepage": "https://git.zi.fi/LeoVasanko/paskia",
"repository": {
"type": "git",
"url": "https://github.com/LeoVasanko/paskia",
"directory": "paskia-js"
},
"type": "module",
"main": "./dist/paskia.js",
"types": "./dist/paskia.d.ts",
@@ -26,7 +34,8 @@
"keywords": [
"auth",
"authentication",
"paskia"
],
"license": "Unlicense"
"paskia",
"passkey",
"webauthn"
]
}
+2 -3
View File
@@ -1,9 +1,8 @@
import { showAuthIframe, AuthCancelledError } from './overlay'
import settings from './settings'
export { AuthCancelledError }
const DEFAULT_TIMEOUT_MS = 1000
export interface ApiFetchOptions extends RequestInit {
timeout?: number
}
@@ -40,7 +39,7 @@ export class NetworkError extends Error {
}
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
const { timeout = settings.fetch_ms, ...fetchOptions } = options
fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) {
+2 -2
View File
@@ -12,14 +12,14 @@ export {
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
export { default as settings } from './settings'
export {
holdGlobalBackdrop,
releaseGlobalBackdrop,
isAuthIframeOpen,
hideAuthIframe,
showAuthIframe,
createAuthIframe,
removeAuthIframe,
} from './overlay'
export { SessionValidator } from './validate'
+3 -2
View File
@@ -14,8 +14,8 @@ body::before {
transition: all 0.2s ease-out;
}
body.paskia-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.7);
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
visibility: visible;
}
body.paskia-backdrop {
@@ -57,6 +57,7 @@ export class AuthCancelledError extends Error {
}
export function holdGlobalBackdrop(): void {
injectStyles()
backdropHolders++
document.body.classList.add('paskia-backdrop')
}
+6
View File
@@ -0,0 +1,6 @@
export default {
fetch_ms: 10000,
auth_ms: 1000,
poll_ms: 60000,
idle_ms: 300000,
}
+4 -6
View File
@@ -1,7 +1,5 @@
import { apiJson } from './fetch'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
import settings from './settings'
export class SessionValidator {
private userUuidGetter: () => string | undefined
@@ -19,12 +17,12 @@ export class SessionValidator {
resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer)
if (!this.active) this.startPolling()
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
}
async validate(): Promise<void> {
try {
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' })
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST', timeout: settings.auth_ms })
const newUuid = data.ctx?.user?.uuid
if (newUuid !== this.userUuidGetter()) {
window.location.reload()
@@ -40,7 +38,7 @@ export class SessionValidator {
startPolling(): void {
if (this.active) return
this.active = true
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
}
stopPolling(): void {
+181
View File
@@ -0,0 +1,181 @@
import argparse
import asyncio
import logging
import os
import sys
from pathlib import Path
import msgspec
from fastapi_vue import server
from fastapi_vue.hostutil import parse_endpoints
from kanta import Kanta
from paskia._version import __version__
from paskia.db.paths import db_file_path
from paskia.db.structs import DB, Config
from paskia.util import startupbox
from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.hostutil import (
normalize_auth_host_and_origins,
normalize_origin,
validate_auth_host,
)
from paskia.util.runtime import RuntimeConfig
EPILOG = """\
Example:
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 _load_stored_config(db_path: Path, *, rp_id: str) -> Config:
"""Load the stored Config from disk using Kanta in read-only mode.
This must not depend on PASKIA_CONFIG or the global lifecycle Kanta.
If the database file does not exist, a default config is returned.
"""
if not db_path.exists():
return Config(rp_id=rp_id)
kanta = Kanta(
str(db_path),
DB(config=Config(rp_id=rp_id)),
migrations="paskia.db.migrations",
)
kanta.ctx.rp_id = rp_id
async def _read() -> Config:
await kanta.open(readonly=True)
try:
return kanta.data.config
finally:
await kanta.close()
try:
return asyncio.run(_read())
except Exception as e:
logging.exception("Failed to load database")
raise SystemExit(f"{e}") from e
def main():
# Configure logging to remove the "ERROR:root:" prefix
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
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 using a local read-only Kanta instance.
# This happens before PASKIA_CONFIG is set, so we must not import
# modules that initialize the global database lifecycle.
db_path = db_file_path(rp_id=args.rp_id, create_root=True)
try:
config = _load_stored_config(db_path, rp_id=args.rp_id)
except SystemExit as e:
print(f"🛑 Paskia {__version__} could not load")
sys.exit(str(e))
# Override stored config with CLI args, or clear with empty string
if args.rp_name is not None:
config.rp_name = args.rp_name or None
if args.auth_host is not None:
config.auth_host = args.auth_host or None
if args.origins is not None:
config.origins = None if args.origins == [""] else args.origins
if args.listen is not None:
config.listen = None if args.listen == [""] else args.listen
# Process and normalize auth_host and origins
try:
validate_auth_host(config.auth_host, config.rp_id) if config.auth_host else None
except ValueError as e:
raise SystemExit(str(e))
if config.origins:
config.origins = [normalize_origin(o) for o in config.origins]
config.auth_host, config.origins = normalize_auth_host_and_origins(
config.auth_host, config.origins
)
# Parse first endpoint for site_url fallback
ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
port = ep.get("port")
# Compute site_url and site_path
# Priority: auth_host > origins[0] > PASKIA_VITE_URL > http://localhost:port > https://rp_id
site_path = "/auth/"
if config.auth_host:
site_url, site_path = config.auth_host, "/"
elif config.origins:
site_url = config.origins[0]
elif vite_url := os.environ.get("PASKIA_VITE_URL"):
site_url = vite_url.rstrip("/") # Devserver
elif config.rp_id == "localhost" and port:
site_url = f"http://localhost:{port}" # Backend directly if we can
else:
site_url = f"https://{config.rp_id}" # Assume external reverse proxy
# Build runtime configuration for the server
runtime = RuntimeConfig(
config=config,
site_url=site_url,
site_path=site_path,
save=args.save,
)
startupbox.print_startup_config(runtime)
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(runtime).decode()
# Run the server (spawns processes in dev mode)
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
server.run(
"paskia.fastapi.mainapp:app",
listen=config.listen,
default_port=DEFAULT_PORT,
log_level="warning",
access_log=False,
**dev,
)
if __name__ == "__main__":
main()
+13 -1
View File
@@ -30,4 +30,16 @@ def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
Dictionary mapping AAGUID string to authenticator information for only
the AAGUIDs that the user has and that we have data for
"""
return {(s := str(a)): AAGUID[s] for a in aaguids if (s := str(a)) in AAGUID}
result = {}
for a in aaguids:
s = str(a)
if s in AAGUID:
info = AAGUID[s].copy()
# Rename icon_light to icon
if "icon_light" in info:
info["icon"] = info.pop("icon_light")
# If icons are the same, set dark to None to save space
if info.get("icon") == info.get("icon_dark"):
info["icon_dark"] = None
result[s] = info
return result
+113
View File
@@ -0,0 +1,113 @@
"""
Authorization code management for OIDC and cookie exchange flows.
Codes are short-lived (60 seconds) and stored in-memory only.
Two separate stores maintain full isolation between OIDC and cookie flows.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
from datetime import UTC, datetime, timedelta
import msgspec
_logger = logging.getLogger(__name__)
# Auth codes expire after this duration
AUTH_CODE_LIFETIME = timedelta(seconds=60)
class OIDCCode(msgspec.Struct):
"""An OIDC authorization code pending token exchange.
PKCE uses S256 only when provided (verified at token exchange).
"""
session_key: str
created: datetime
redirect_uri: str
scope: str
nonce: str | None = None
code_challenge: str | None = None
class CookieCode(msgspec.Struct):
"""A cookie exchange code for setting session cookie after WebSocket auth."""
session_key: str
created: datetime
# Separate stores for each code type
oidc_codes: dict[str, OIDCCode] = {}
cookie_codes: dict[str, CookieCode] = {}
# Background cleanup task
_cleanup_task: asyncio.Task | None = None
async def start():
"""Start the cleanup background task."""
global _cleanup_task
if _cleanup_task is None:
_cleanup_task = asyncio.create_task(_cleanup_loop())
async def stop():
"""Stop the cleanup background task."""
global _cleanup_task
if _cleanup_task:
_cleanup_task.cancel()
try:
await _cleanup_task
except asyncio.CancelledError:
pass
_cleanup_task = None
async def _cleanup_loop():
while True:
try:
await asyncio.sleep(30) # Check every 30 seconds
_cleanup_expired()
except asyncio.CancelledError:
break
except Exception:
_logger.exception("Error in auth code cleanup loop")
def _cleanup_expired():
oldest = datetime.now(UTC) - AUTH_CODE_LIFETIME
for code, auth_code in list(oidc_codes.items()):
if auth_code.created < oldest:
del oidc_codes[code]
for code, auth_code in list(cookie_codes.items()):
if auth_code.created < oldest:
del cookie_codes[code]
def store_oidc(code: OIDCCode) -> str:
"""Store an OIDC authorization code and return the code string."""
token = secrets.token_urlsafe(12)
oidc_codes[token] = code
return token
def consume_oidc(token: str) -> OIDCCode | None:
"""Consume an OIDC code, returning it if valid. Atomic removal."""
return oidc_codes.pop(token, None)
def store_cookie(code: CookieCode) -> str:
"""Store a cookie exchange code and return the code string."""
token = secrets.token_urlsafe(12)
cookie_codes[token] = code
return token
def consume_cookie(token: str) -> CookieCode | None:
"""Consume a cookie exchange code, returning it if valid. Atomic removal."""
return cookie_codes.pop(token, None)
+9 -3
View File
@@ -14,6 +14,7 @@ from uuid import UUID
from paskia import db
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
from paskia.db.structs import ResetToken
from paskia.util import hostutil
if TYPE_CHECKING:
@@ -22,6 +23,11 @@ if TYPE_CHECKING:
EXPIRES = SESSION_LIFETIME
def session_ctx(auth: str, host: str | None = None):
"""Get session context with normalized host."""
return db.data().session_ctx(auth, hostutil.normalize_host(host))
def expires() -> datetime:
return datetime.now(UTC) + EXPIRES
@@ -30,10 +36,10 @@ def reset_expires() -> datetime:
return datetime.now(UTC) + RESET_LIFETIME
def get_reset(token: str) -> "ResetToken":
def get_reset(token: str) -> ResetToken:
"""Validate a credential reset token."""
record = db.get_reset_token(token)
record = ResetToken.by_passphrase(token)
if record:
return record
raise ValueError("This authentication link is no longer valid.")
@@ -41,7 +47,7 @@ def get_reset(token: str) -> "ResetToken":
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
"""Delete a specific credential for the current user."""
ctx = db.data().session_ctx(auth, hostutil.normalize_host(host))
ctx = session_ctx(auth, host)
if not ctx:
raise ValueError("Session expired")
db.delete_credential(credential_uuid, ctx.user.uuid)
+48 -64
View File
@@ -4,44 +4,37 @@ Bootstrap module for passkey authentication system.
This module handles initial system setup when a new database is created,
including creating default admin user, organization, permissions, and
generating a reset link for initial admin setup.
The actual database seeding is performed by the module-level kanta bootstrap
callback defined in :mod:`paskia.db.bootstrap` and registered during
:func:`paskia.db.lifecycle.init`.
"""
import asyncio
import logging
from paskia import authsession, db, globals
from paskia.util import hostutil, passphrase
from paskia import authsession, db
from paskia.db.bootstrap import log_reset_link
from paskia.db.structs import Config
logger = logging.getLogger(__name__)
# Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """
👤 Admin %s
- Use this link to register a Passkey for the admin user!
"""
def _configure_logger() -> None:
if logger.handlers:
return
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
_configure_logger()
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
"""Log a reset link message and return the URL."""
reset_link = hostutil.reset_link_url(passphrase)
if message:
logger.info(message)
logger.info(ADMIN_RESET_MESSAGE, reset_link)
return reset_link
async def bootstrap_system() -> None:
"""
Bootstrap the entire system with default data.
Uses db.bootstrap() which performs all operations in a single transaction.
The transaction log will show a single "bootstrap" action with all changes.
"""
# Call the single-transaction bootstrap function
reset_passphrase = db.bootstrap()
# Log the reset link (this is separate from the transaction log)
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
return log_reset_link(passphrase, message)
async def check_admin_credentials() -> bool:
@@ -52,17 +45,24 @@ async def check_admin_credentials() -> bool:
bool: True if a reset link was created, False if admin already has credentials
"""
try:
# Get permission organizations to find admin users
# Find the auth:admin permission
p = next(
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
)
if not p or not p.orgs:
if not p:
return False
# Get users from the first organization with admin permission
first_org_uuid = next(iter(p.orgs))
org_users = db.get_organization_users(first_org_uuid)
admin_users = [user for user, role in org_users if role == "Administration"]
perm_uuid = p.uuid
# Find all roles that have the auth:admin permission
admin_roles = [
r for r in db.data().roles.values() if perm_uuid in r.permissions
]
# Collect all users from those roles
admin_users = []
for role in admin_roles:
admin_users.extend(role.users)
if not admin_users:
return False
@@ -70,15 +70,13 @@ async def check_admin_credentials() -> bool:
# Check first admin user for credentials
admin_user = admin_users[0]
if not db.get_user_credential_ids(admin_user.uuid):
if not admin_user.credential_ids:
# Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!")
token = passphrase.generate()
expiry = authsession.reset_expires()
db.create_reset_token(
token = db.create_reset_token(
user_uuid=admin_user.uuid,
passphrase=token,
expiry=expiry,
token_type="admin registration",
)
@@ -91,34 +89,20 @@ async def check_admin_credentials() -> bool:
return False
async def bootstrap_if_needed() -> bool:
async def bootstrap_if_needed(config: Config | None = None) -> bool:
"""
Check if system needs bootstrapping and perform it if necessary.
Check if admin needs credentials and create a reset link if needed.
Database bootstrapping itself is now handled automatically during
``db.init()`` via the registered kanta bootstrap callback. This function
remains as a post-init hook for credential checks.
Args:
config: Kept for backwards compatibility; config is now applied during
``db.init()``.
Returns:
bool: True if bootstrapping was performed, False if system was already set up
bool: Always returns False (bootstrapping is performed during init).
"""
# Check if the admin permission exists - if it does, system is already bootstrapped
if any(p.scope == "auth:admin" for p in db.data().permissions.values()):
# Permission exists, system is already bootstrapped
# Check if admin needs credentials (only for already-bootstrapped systems)
await check_admin_credentials()
return False
# No admin permission found, need to bootstrap
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
await bootstrap_system()
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())
await check_admin_credentials()
return False
-17
View File
@@ -1,4 +1,3 @@
from dataclasses import dataclass
from datetime import timedelta
# Shared configuration constants for session management.
@@ -6,19 +5,3 @@ SESSION_LIFETIME = timedelta(hours=24)
# Lifetime for reset links created by admins
RESET_LIFETIME = timedelta(days=14)
@dataclass
class PaskiaConfig:
"""Runtime configuration for the Paskia authentication server."""
rp_id: str
rp_name: str | None
origins: list[str] | None
auth_host: str | None
site_url: str # Base URL without trailing path (e.g. https://example.com)
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
# Listen address (one of host:port or uds)
host: str | None = None
port: int | None = None
uds: str | None = None
+23 -39
View File
@@ -1,7 +1,7 @@
"""
Database module for WebAuthn passkey authentication.
Read: Access data() directly, use build_* to convert to public structs.
Read: Access data() directly for structs.
CTX: data().session_ctx(key) returns SessionContext with effective permissions.
Write: Functions validate and commit, or raise ValueError.
@@ -10,7 +10,6 @@ Usage:
# Read (after init)
user_data = db.data().users[user_uuid]
user = db.build_user(user_uuid)
# Context
ctx = db.data().session_ctx(session_key)
@@ -20,26 +19,20 @@ Usage:
"""
import paskia.db.operations as operations
from paskia.db.background import (
start_background,
start_cleanup,
stop_background,
stop_cleanup,
)
from paskia.db.bootstrap import bootstrap
from paskia.db.operations import (
add_permission_to_org,
add_permission_to_role,
bootstrap,
cleanup_expired,
create_credential,
create_credential_session,
create_oid_client,
create_org,
create_permission,
create_reset_token,
create_role,
create_session,
create_user,
delete_credential,
delete_oid_client,
delete_org,
delete_permission,
delete_reset_token,
@@ -47,27 +40,28 @@ from paskia.db.operations import (
delete_session,
delete_sessions_for_user,
delete_user,
get_organization_users,
get_reset_token,
get_user_credential_ids,
get_user_organization,
init,
is_username_taken,
login,
oidc_login,
remove_permission_from_org,
remove_permission_from_role,
reset_oid_client_secret,
set_session_host,
update_config,
update_credential_sign_count,
update_oid_client,
update_org_name,
update_permission,
update_role_name,
update_session,
update_user_display_name,
update_user_info,
update_user_role,
update_user_role_in_organization,
update_user_theme,
)
from paskia.db.structs import (
DB,
Client,
Config,
Credential,
Org,
Permission,
@@ -86,8 +80,10 @@ def data() -> DB:
__all__ = [
# Types
"Config",
"Credential",
"DB",
"Client",
"Org",
"Permission",
"ResetToken",
@@ -97,36 +93,17 @@ __all__ = [
"User",
# Instance
"data",
"init",
# Background
"start_background",
"stop_background",
"start_cleanup",
"stop_cleanup",
# Builders
"build_credential",
"build_permission",
"build_reset_token",
"build_role",
"build_session",
"build_user",
# Read ops
"get_organization_users",
"get_reset_token",
"get_user_credential_ids",
"get_user_organization",
# Write ops
"add_permission_to_org",
"add_permission_to_role",
"bootstrap",
"cleanup_expired",
"create_credential",
"create_credential_session",
"create_org",
"create_permission",
"create_reset_token",
"create_role",
"create_session",
"create_user",
"delete_credential",
"delete_org",
@@ -137,16 +114,23 @@ __all__ = [
"delete_sessions_for_user",
"delete_user",
"login",
"oidc_login",
"remove_permission_from_org",
"remove_permission_from_role",
"set_session_host",
"update_config",
"update_credential_sign_count",
"update_org_name",
"update_permission",
"update_role_name",
"update_session",
"update_user_display_name",
"update_user_info",
"update_user_role",
"update_user_role_in_organization",
"update_user_theme",
"is_username_taken",
# OIDC
"create_oid_client",
"update_oid_client",
"reset_oid_client_secret",
"delete_oid_client",
]
+11 -36
View File
@@ -1,62 +1,38 @@
"""
Background task for database maintenance.
Periodically flushes pending changes to disk and cleans up expired items.
Kanta handles periodic flushing to disk. This module keeps a small
companion task that periodically cleans up expired sessions/tokens.
"""
import asyncio
import logging
from datetime import UTC, datetime
from paskia.db.operations import _store, cleanup_expired
from paskia.db.lifecycle import cleanup_expired
FLUSH_INTERVAL = 0.1 # Flush to disk
CLEANUP_INTERVAL = 1 # Expired item cleanup
_logger = logging.getLogger(__name__)
_background_task: asyncio.Task | None = None
async def flush() -> None:
"""Write all pending database changes to disk."""
if _store is None:
_logger.warning("flush() called but _store is None")
return
await _store.flush()
async def _background_loop():
"""Background task that periodically flushes changes and cleans up."""
"""Background task that periodically cleans up expired items."""
# Run cleanup immediately on startup to clear old expired items
cleanup_expired()
await flush()
last_cleanup = datetime.now(UTC)
while True:
try:
await asyncio.sleep(FLUSH_INTERVAL)
# Flush pending changes to disk
await flush()
# Run cleanup periodically
now = datetime.now(UTC)
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
cleanup_expired()
await flush() # Flush cleanup changes
last_cleanup = now
await asyncio.sleep(CLEANUP_INTERVAL)
cleanup_expired()
except asyncio.CancelledError:
# Final flush before exit
await flush()
break
except Exception:
_logger.debug("Error in database background loop", exc_info=True)
async def start_background():
"""Start the background flush/cleanup task."""
"""Start the background cleanup task."""
global _background_task
# Check if task exists but is no longer running (e.g., after uvicorn reload)
@@ -70,16 +46,15 @@ async def start_background():
# Check if task is in current event loop
loop = asyncio.get_running_loop()
task_loop = _background_task.get_loop()
if loop is not task_loop:
_logger.debug("Background task in different event loop, restarting")
_background_task = None
else:
if loop is task_loop:
# Task is already running in same loop - idempotent, just return
# This happens with dual IPv4+IPv6 endpoints sharing the same process
_logger.debug(
"Background task already running in same loop, skipping"
)
return
_logger.debug("Background task in different event loop, restarting")
_background_task = None
except Exception as e:
_logger.debug("Error checking background task loop: %s, restarting", e)
_background_task = None
@@ -89,7 +64,7 @@ async def start_background():
async def stop_background():
"""Stop the background task and flush any pending changes."""
"""Stop the background cleanup task."""
global _background_task
if _background_task:
_background_task.cancel()
+161
View File
@@ -0,0 +1,161 @@
"""
Bootstrap operations for initial system setup.
"""
import logging
import sys
from datetime import UTC, datetime
import uuid7
from paskia.authsession import reset_expires
from paskia.db.structs import DB, Config, Org, Permission, ResetToken, Role, User
from paskia.util.crypto import secret_key
from paskia.util.hostutil import reset_link_url
_reset_link_logger = logging.getLogger("paskia.reset_link")
def _configure_reset_link_logger() -> None:
if _reset_link_logger.handlers:
return
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s"))
_reset_link_logger.addHandler(handler)
_reset_link_logger.setLevel(logging.INFO)
_reset_link_logger.propagate = False
_configure_reset_link_logger()
ADMIN_RESET_MESSAGE = """
👤 Admin %s
- Use this link to register a Passkey for the admin user!
"""
def log_reset_link(passphrase: str, message: str | None = None) -> str:
"""Log a reset link message and return the URL."""
reset_link = reset_link_url(passphrase)
if message:
_reset_link_logger.info(message)
_reset_link_logger.info(ADMIN_RESET_MESSAGE, reset_link)
return reset_link
def bootstrap(
data: DB,
org_name: str = "Organization",
admin_name: str = "Admin",
reset_passphrase: str | None = None,
reset_expiry: datetime | None = None,
config: Config | None = None,
) -> str:
"""Bootstrap the entire system by seeding an empty database.
This is intended to be called from a ``@kanta.bootstrap`` callback during
``kanta.open()``. It mutates the provided root ``data`` object directly;
kanta queues the resulting state as the initial "bootstrap" change record.
Creates:
- auth:admin permission (Master Admin)
- auth:org:admin permission (Org Admin)
- Organization with Administration role
- Admin user with Administration role
- Reset token for admin registration
- Config (if provided)
Args:
data: The live root database object (usually a ``DB`` instance).
org_name: Display name for the organization (default: "Organization")
admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided)
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 data.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()
# Create auth:admin permission
perm_admin = Permission(
scope="auth:admin",
display_name="Master Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_admin.uuid = perm_admin_uuid
# Create auth:org:admin permission
perm_org_admin = Permission(
scope="auth:org:admin",
display_name="Org Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_org_admin.uuid = perm_org_admin_uuid
# Create organization
new_org = Org.create(display_name=org_name)
new_org.uuid = org_uuid
# Create Administration role with both permissions
admin_role = Role(
org_uuid=org_uuid,
display_name="Administration",
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
)
admin_role.uuid = role_uuid
# Create admin user
admin_user = User(
display_name=admin_name,
role_uuid=role_uuid,
created_at=now,
last_seen=None,
visits=0,
theme="",
)
admin_user.uuid = user_uuid
# Create reset token
reset_token, reset_passphrase = ResetToken.create(
user=user_uuid,
expiry=reset_expiry,
token_type="admin bootstrap",
passphrase=reset_passphrase,
)
# Set config if provided
if config is not None:
data.config = config
# Generate OIDC signing key
data.oidc.key = secret_key()
# Store all bootstrapped objects in the live data object
data.permissions[perm_admin_uuid] = perm_admin
data.permissions[perm_org_admin_uuid] = perm_org_admin
data.orgs[org_uuid] = new_org
data.roles[role_uuid] = admin_role
data.users[user_uuid] = admin_user
data.reset_tokens[reset_token.key] = reset_token
return reset_passphrase
-282
View File
@@ -1,282 +0,0 @@
"""
JSONL persistence layer for the database.
"""
import copy
import logging
from collections import deque
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import UUID
import aiofiles
import jsondiff
import msgspec
from paskia.db.logging import log_change
from paskia.db.migrations import DBVER, apply_all_migrations
from paskia.db.structs import DB, SessionContext
_logger = logging.getLogger(__name__)
# Default database path
DB_PATH_DEFAULT = "paskia.jsonl"
class _ChangeRecord(msgspec.Struct, omit_defaults=True):
"""A single change record in the JSONL file."""
ts: datetime
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
v: int # schema version after this change
u: str | None = None # user UUID who performed the action (None for system)
diff: dict = {}
# msgspec encoder for change records
_change_encoder = msgspec.json.Encoder()
def compute_diff(previous: dict, current: dict) -> dict | None:
"""Compute JSON diff between two states.
Args:
previous: Previous state (JSON-compatible dict)
current: Current state (JSON-compatible dict)
Returns:
The diff, or None if no changes
"""
diff = jsondiff.diff(previous, current, marshal=True)
return diff if diff else None
def create_change_record(
action: str, version: int, diff: dict, user: str | None = None
) -> _ChangeRecord:
"""Create a change record for persistence."""
return _ChangeRecord(
ts=datetime.now(UTC),
a=action,
v=version,
u=user,
diff=diff,
)
# Actions that are allowed to create a new database file
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap", "migrate:sql"})
async def flush_changes(
db_path: Path,
pending_changes: deque[_ChangeRecord],
) -> bool:
"""Write all pending changes to disk.
Args:
db_path: Path to the JSONL database file
pending_changes: Queue of pending change records (will be cleared on success)
Returns:
True if flush succeeded, False otherwise
"""
if not pending_changes:
return True
if not db_path.exists():
first_action = pending_changes[0].a
if first_action not in _BOOTSTRAP_ACTIONS:
_logger.error(
"Refusing to create database file with action '%s' - "
"only bootstrap or migrate can create a new database",
first_action,
)
pending_changes.clear()
return False
changes_to_write = list(pending_changes)
pending_changes.clear()
try:
lines = [_change_encoder.encode(change) for change in changes_to_write]
if not lines:
return True
async with aiofiles.open(db_path, "ab") as f:
await f.write(b"\n".join(lines) + b"\n")
return True
except OSError:
_logger.exception("Failed to flush database changes")
# Re-queue the changes on failure
for change in reversed(changes_to_write):
pending_changes.appendleft(change)
return False
class JsonlStore:
"""JSONL persistence layer for a DB instance."""
def __init__(self, db: DB, db_path: str = DB_PATH_DEFAULT):
self.db: DB = db
self.db_path = Path(db_path)
self._previous_builtins: dict[str, Any] = {}
self._pending_changes: deque[_ChangeRecord] = deque()
self._current_action: str = "system"
self._current_user: str | None = None
self._in_transaction: bool = False
self._transaction_snapshot: dict[str, Any] | None = None
self._current_version: int = DBVER # Schema version for new databases
async def load(self, db_path: str | None = None) -> None:
"""Load data from JSONL change log."""
if db_path is not None:
self.db_path = Path(db_path)
if not self.db_path.exists():
return
# Replay change log to reconstruct state
data_dict: dict = {}
try:
async with aiofiles.open(self.db_path, "rb") as f:
content = await f.read()
for line_num, line in enumerate(content.split(b"\n"), 1):
line = line.strip()
if not line:
continue
try:
change = msgspec.json.decode(line)
data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True)
self._current_version = change.get("v", 0)
except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {e}")
except (OSError, ValueError, msgspec.DecodeError) as e:
raise ValueError(f"Failed to load database: {e}")
if not data_dict:
return
# Set previous state for diffing (will be updated by _queue_change)
self._previous_builtins = copy.deepcopy(data_dict)
# Callback to persist each migration
async def persist_migration(
action: str, new_version: int, current: dict
) -> None:
self._current_version = new_version
self._queue_change(action, new_version, current)
# Apply schema migrations one at a time
await apply_all_migrations(data_dict, self._current_version, persist_migration)
# Decode to msgspec struct
decoder = msgspec.json.Decoder(DB)
self.db = decoder.decode(msgspec.json.encode(data_dict))
self.db._store = self
# Normalize via msgspec round-trip (handles omit_defaults etc.)
# This ensures _previous_builtins matches what msgspec would produce
normalized_dict = msgspec.to_builtins(self.db)
await persist_migration(
"migrate:msgspec", self._current_version, normalized_dict
)
def _queue_change(
self, action: str, version: int, current: dict, user: str | None = None
) -> None:
"""Queue a change record and log it.
Args:
action: The action name for the change record
version: The schema version for the change record
current: The current state as a plain dict
user: Optional user UUID who performed the action
"""
diff = compute_diff(self._previous_builtins, current)
if not diff:
return
self._pending_changes.append(create_change_record(action, version, diff, user))
# Log the change with user display name if available
user_display = None
if user:
try:
user_uuid = UUID(user)
if user_uuid in self.db.users:
user_display = self.db.users[user_uuid].display_name
except (ValueError, KeyError):
user_display = user
log_change(action, diff, user_display, self._previous_builtins)
self._previous_builtins = copy.deepcopy(current)
@contextmanager
def transaction(
self,
action: str,
ctx: SessionContext | None = None,
*,
user: str | None = None,
):
"""Wrap writes in transaction. Queues change on successful exit.
Args:
action: Describes the operation (e.g., "Created user", "Login")
ctx: Session context of user performing the action (None for system operations)
user: User UUID string (alternative to ctx when full context unavailable)
"""
if self._in_transaction:
raise RuntimeError("Nested transactions are not supported")
# Check for out-of-transaction modifications
current_state = msgspec.to_builtins(self.db)
if current_state != self._previous_builtins:
# Allow bootstrap/migrate to create a new database from empty state
is_bootstrap = action in _BOOTSTRAP_ACTIONS or action.startswith("migrate:")
if is_bootstrap and not self._previous_builtins:
pass # Expected: creating database from scratch
else:
diff = compute_diff(self._previous_builtins, current_state)
diff_json = msgspec.json.encode(diff).decode()
_logger.critical(
"Database state modified outside of transaction! "
"This indicates a bug where DB changes occurred without a transaction wrapper.\n"
f"Changes detected:\n{diff_json}"
)
raise SystemExit(1)
old_action = self._current_action
old_user = self._current_user
self._current_action = action
# Prefer ctx.user.uuid if ctx provided, otherwise use user param
self._current_user = str(ctx.user.uuid) if ctx else user
self._in_transaction = True
self._transaction_snapshot = current_state
try:
yield
current = msgspec.to_builtins(self.db)
self._queue_change(
self._current_action, self._current_version, current, self._current_user
)
except Exception:
# Rollback on error: restore from snapshot
_logger.warning("Transaction '%s' failed, rolling back changes", action)
if self._transaction_snapshot is not None:
decoder = msgspec.json.Decoder(DB)
self.db = decoder.decode(
msgspec.json.encode(self._transaction_snapshot)
)
self.db._store = self
raise
finally:
self._current_action = old_action
self._current_user = old_user
self._in_transaction = False
self._transaction_snapshot = None
async def flush(self) -> bool:
"""Write all pending changes to disk."""
return await flush_changes(self.db_path, self._pending_changes)
+158
View File
@@ -0,0 +1,158 @@
"""
Database lifecycle: initialization and maintenance.
"""
import asyncio
import logging
import os
import signal
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated, Any, Optional
from uuid import UUID
from kanta import Kanta
from kanta.exceptions import DatabaseError
import paskia.db.operations as _ops
from paskia import oidc_notify
from paskia.authsession import EXPIRES
from paskia.db.bootstrap import bootstrap, log_reset_link
from paskia.db.paths import db_file_path
from paskia.db.structs import DB
from paskia.util.runtime import config as runtime_config
logger = logging.getLogger(__name__)
runtime = runtime_config()
if runtime is None:
raise RuntimeError("PASKIA_CONFIG must be defined before importing db.lifecycle")
kanta = Kanta(
str(db_file_path(rp_id=runtime.config.rp_id, create_root=False)),
_ops._db,
migrations="paskia.db.migrations",
)
kanta.ctx.rp_id = runtime.config.rp_id
_ops._db._store = kanta
def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
"""Resolve UUID to label from serialized state dict."""
if not state:
return None
# Display-name based entities.
for bucket in ("users", "orgs", "roles", "permissions"):
entity = state.get(bucket, {}).get(uuid_str)
if isinstance(entity, dict):
display_name = entity.get("display_name")
if isinstance(display_name, str) and display_name:
return display_name
# OIDC clients use "name" instead of "display_name".
client = state.get("oidc", {}).get("clients", {}).get(uuid_str)
if isinstance(client, dict):
name = client.get("name")
if isinstance(name, str) and name:
return name
return None
def _resolve_uuid_label(
uuid_str: str,
*,
previous: dict | None = None,
current: dict | None = None,
) -> str | None:
"""Resolve known entity UUIDs to human-readable labels."""
# Prefer previous state so deletions/renames still show a useful label.
label = _lookup_uuid_in_state(previous, uuid_str)
if label:
return label
label = _lookup_uuid_in_state(current, uuid_str)
if label:
return label
try:
uid = UUID(uuid_str)
except ValueError:
return None
if uid in _ops._db.users:
return _ops._db.users[uid].display_name
if uid in _ops._db.orgs:
return _ops._db.orgs[uid].display_name
if uid in _ops._db.roles:
return _ops._db.roles[uid].display_name
if uid in _ops._db.permissions:
return _ops._db.permissions[uid].display_name
if uid in _ops._db.oidc.clients:
return _ops._db.oidc.clients[uid].name
return None
@kanta.logfmt
def format_log_uuid(
value: Any,
path: str,
previous: Annotated[dict, "pre"] | None = None,
current: Annotated[dict, "post"] | None = None,
) -> Optional[str]: # noqa: UP045
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
# Censor sensitive OIDC key material regardless of value type, but only
# when formatting the value: path components are passed with the component
# itself as value and must stay visible ("oidc.key = <hidden>").
if (path == "oidc.key" or path.endswith(".oidc.key")) and value != "key":
return "<hidden>"
if not isinstance(value, str):
return None
# Works for transaction actor metadata ($user), values, and path components.
return _resolve_uuid_label(value, previous=previous, current=current)
@kanta.fatal_error
def terminate(error: DatabaseError) -> None:
"""Fatal error callback: terminate the process on background write failures."""
logger.error("Fatal database error: %s", error)
os.kill(os.getpid(), signal.SIGTERM)
@kanta.bootstrap
def bootstrap_db(data: DB) -> None:
reset_passphrase = bootstrap(data, config=runtime.config)
log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
async def init():
"""Load database from JSONL file using kanta.
If the database file is empty, the configured bootstrap callback seeds it
with default permissions, organization, role, admin user and a reset token.
"""
rootpath = Path(kanta.filename).parent
try:
await asyncio.to_thread(rootpath.mkdir, parents=True, exist_ok=True)
await kanta.open()
except Exception as e:
raise SystemExit(f"{e}") from e
def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
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 kanta.transaction("expiry"):
for k in expired_sessions:
del _ops._db.sessions[k]
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]
return len(expired_sessions) + len(expired_tokens)
-318
View File
@@ -1,318 +0,0 @@
"""
Database change logging with pretty-printed diffs.
Provides a logger for JSONL database changes that formats diffs
in a human-readable path.notation style with color coding.
"""
import logging
import re
import sys
from typing import Any
logger = logging.getLogger("paskia.db")
# Pattern to match control characters and bidirectional overrides
_UNSAFE_CHARS = re.compile(
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
r"\u200e\u200f" # LRM, RLM
r"\u202a-\u202e" # LRE, RLE, PDF, LRO, RLO
r"\u2066-\u2069" # LRI, RLI, FSI, PDI
r"]"
)
# ANSI color codes (matching FastAPI logging style)
_RESET = "\033[0m"
_DIM = "\033[2m"
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
_DELETE = "\033[1;31m" # Red for deletions
_ADD = "\033[0;32m" # Green for additions
_ACTION = "\033[1;34m" # Bold blue for action name
_USER = "\033[0;34m" # Blue for user display
def _use_color() -> bool:
"""Check if we should use color output."""
return sys.stderr.isatty()
def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
"""Format a value for display, truncating if needed."""
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):
# 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 "{}"
# For small dicts, show inline
if len(value) == 1:
k, v = next(iter(value.items()))
return "{" + f"{k}: {_format_value(v, use_color, max_len=30)}" + "}"
return f"{{...{len(value)} keys}}"
if isinstance(value, list):
if not value:
return "[]"
if len(value) == 1:
return "[" + _format_value(value[0], use_color, max_len=30) + "]"
return f"[...{len(value)} items]"
# 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], use_color: bool) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default."""
if not path:
return ""
if not use_color:
return ".".join(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, use_color: bool
) -> list[str]:
"""Format a single change as one or more lines."""
if change_type == "delete":
if not use_color:
return [f" {'.'.join(path)}"]
if len(path) == 1:
return [f" {_DELETE}{path[0]}{_RESET}"]
prefix = ".".join(path[:-1])
final = path[-1]
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 not use_color:
lines.append(f" {'.'.join(path)} =")
elif len(path) == 1:
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
else:
prefix = ".".join(path[:-1])
final = path[-1]
lines.append(
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
)
# Child lines: indented key: value, with aligned values
max_key_len = max(len(k) for k in value.keys())
field_width = max(max_key_len, 12) # minimum 12 chars
for k, v in value.items():
v_str = _format_value(v, use_color)
padding = " " * (field_width - len(k))
if use_color:
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
else:
lines.append(f" {k}:{padding} {v_str}")
return lines
else:
value_str = _format_value(value, use_color)
if not use_color:
return [f" {'.'.join(path)} = {value_str}"]
if len(path) == 1:
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
prefix = ".".join(path[:-1])
final = path[-1]
return [
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
]
# update: Existing item being updated - normal path colors
value_str = _format_value(value, use_color)
path_str = _format_path(path, use_color)
if use_color:
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
return [f" {path_str} = {value_str}"]
def format_diff(diff: dict, previous: dict | 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)
Returns a list of formatted lines (without newlines).
Single changes return one line, multiple changes return multiple lines.
"""
use_color = _use_color()
changes: list[tuple[str, list[str], Any]] = []
_collect_changes(diff, [], changes, previous)
if not changes:
return []
# Format each change
lines = []
for change_type, path, value in changes:
lines.extend(_format_change_lines(change_type, path, value, use_color))
return lines
def format_action_header(action: str, user_display: str | None = None) -> str:
"""Format the action header line."""
use_color = _use_color()
if use_color:
action_str = f"{_ACTION}{action}{_RESET}"
if user_display:
user_str = f"{_USER}{user_display}{_RESET}"
return f"{action_str} by {user_str}"
return action_str
else:
if user_display:
return f"{action} by {user_display}"
return action
def log_change(
action: str,
diff: dict,
user_display: str | None = None,
previous: dict | None = None,
) -> None:
"""
Log a database change with pretty-printed diff.
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)
"""
header = format_action_header(action, user_display)
diff_lines = format_diff(diff, previous)
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
+31 -16
View File
@@ -5,7 +5,11 @@ Migrations are applied during database load based on the version field.
Each migration should be idempotent and only run when needed.
"""
from collections.abc import Awaitable, Callable
import base64
from kanta import Kanta
from paskia.util.crypto import secret_key
def migrate_v1(d: dict) -> None:
@@ -14,20 +18,31 @@ def migrate_v1(d: dict) -> None:
org_data.pop("created_at", None)
migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
)
DBVER = len(migrations) # Used by bootstrap and migrate:sql to set initial version
def migrate_v2(d: dict, kanta: Kanta) -> None:
"""Add config field if missing."""
if "config" not in d:
d["config"] = {"rp_id": kanta.ctx.rp_id}
async def apply_all_migrations(
data_dict: dict,
current_version: int,
persist: Callable[[str, int, dict], Awaitable[None]],
) -> None:
while current_version < DBVER:
migrations[current_version](data_dict)
current_version += 1
await persist(f"migrate:v{current_version}", current_version, data_dict)
def migrate_v3(d: dict) -> None:
"""Ensure all users have visits field."""
for user_data in d["users"].values():
user_data.setdefault("visits", 0)
def migrate_v4(d: dict) -> None:
"""OpenID Connect support and hardened session keys."""
# Session keys changed to hashes, drop old sessions
d["sessions"] = {}
# Create OIDC structure with a generated new key
d["oidc"] = {
"clients": {},
"key": base64.standard_b64encode(secret_key()).decode(),
}
def migrate_v5(d: dict) -> None:
"""Convert config.listen from str to list[str] if needed."""
listen = d["config"].get("listen")
if listen and isinstance(listen, str):
d["config"]["listen"] = [listen]
+316 -364
View File
@@ -6,22 +6,19 @@ Context lookup: _db.session_ctx() returns full SessionContext with effective per
Write operations: Functions that validate and commit, or raise ValueError.
"""
import hashlib
import logging
import os
import secrets
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from uuid import UUID
import uuid7
from paskia import oidc_notify
from paskia.config import SESSION_LIFETIME
from paskia.db.jsonl import (
DB_PATH_DEFAULT,
JsonlStore,
)
from paskia.db.structs import (
DB,
Client,
Config,
Credential,
Org,
Permission,
@@ -31,94 +28,47 @@ from paskia.db.structs import (
SessionContext,
User,
)
from paskia.util.passphrase import generate as generate_passphrase
from paskia.util.passphrase import is_well_formed as _is_passphrase
from paskia.util.crypto import hash_secret
from paskia.util.nameutil import slugify_name
_logger = logging.getLogger(__name__)
# Sentinel for distinguishing "not provided" from None
_UNSET = object()
# Global database instance (empty until init() loads data)
_db = DB()
_store = JsonlStore(_db)
_db._store = _store
_initialized = False
_db = DB(config=Config(rp_id="uninitialized.invalid"))
async def init(*args, **kwargs):
"""Load database from JSONL file."""
global _db, _initialized
if _initialized:
_logger.debug("Database already initialized, skipping reload")
return
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
if db_path.startswith("json:"):
db_path = db_path[5:]
await _store.load(db_path)
_db = _store.db
_initialized = True
def _store():
"""Return active Kanta instance for the current DB object."""
store = _db._store
if store is None:
raise RuntimeError("Kanta store is not initialized")
return store
# -------------------------------------------------------------------------
# Read/lookup functions
# -------------------------------------------------------------------------
def _transaction(
action: str,
ctx: SessionContext | None = None,
*,
user: str | None = None,
mtime: bool | datetime = True,
):
"""Create a Kanta transaction with minimal metadata mapping."""
user_id = str(ctx.user.uuid) if ctx else user
return _store().transaction(action, user=user_id, mtime=mtime)
def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
"""Get the organization a user belongs to and their role name.
Raises ValueError if user not found.
Call sites:
- update_user_role_in_organization: org only
- admin_create_user_registration_link: org only
- admin_get_user_detail: org and role
- admin_update_user_display_name: org only
- admin_delete_user_credential: org only
- admin_delete_user_session: org only
"""
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
user = _db.users[user_uuid]
role = user.role
return role.org, role.display_name
def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
"""Get all users in an organization with their role names.
Returns list of (User, role_display_name) tuples.
"""
org = _db.orgs[org_uuid]
return [(u, u.role.display_name) for role in org.roles for u in role.users]
def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
"""Get credential IDs for a user (for WebAuthn exclude lists).
Returns empty list if user has no credentials.
"""
assert user_uuid
return [c.credential_id for c in _db.users[user_uuid].credentials]
def _reset_key(passphrase: str) -> bytes:
"""Hash a passphrase to bytes for reset token storage."""
if not _is_passphrase(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return hashlib.sha512(passphrase.encode()).digest()[:9]
def get_reset_token(passphrase: str) -> ResetToken | None:
"""Get reset token by passphrase.
Call sites:
- Get reset token to validate it (authsession.py:34)
"""
key = _reset_key(passphrase)
return _db.reset_tokens.get(key)
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
"""Check if a preferred_username is already taken by another user."""
if not username:
return False
for user in _db.users.values():
if user.preferred_username == username:
if exclude_uuid is None or user.uuid != exclude_uuid:
return True
return False
# -------------------------------------------------------------------------
@@ -126,12 +76,18 @@ def get_reset_token(passphrase: str) -> ResetToken | None:
# -------------------------------------------------------------------------
def update_config(config: Config) -> None:
"""Update the stored configuration."""
with _transaction("update_config"):
_db.config = config
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
"""Create a new permission."""
if perm.uuid in _db.permissions:
raise ValueError(f"Permission {perm.uuid} already exists")
with _db.transaction("admin:create_permission", ctx):
_db.permissions[perm.uuid] = perm
with _transaction("admin:create_permission", ctx):
perm.store()
def update_permission(
@@ -148,7 +104,7 @@ def update_permission(
"""
if uuid not in _db.permissions:
raise ValueError(f"Permission {uuid} not found")
with _db.transaction("admin:update_permission", ctx):
with _transaction("admin:update_permission", ctx):
_db.permissions[uuid].scope = scope
_db.permissions[uuid].display_name = display_name
_db.permissions[uuid].domain = domain
@@ -158,11 +114,8 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete a permission and remove it from all roles."""
if uuid not in _db.permissions:
raise ValueError(f"Permission {uuid} not found")
with _db.transaction("admin:delete_permission", ctx):
# Remove this permission from all roles
for role in _db.roles.values():
role.permissions.pop(uuid, None)
del _db.permissions[uuid]
with _transaction("admin:delete_permission", ctx):
_db.permissions[uuid].delete()
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
@@ -172,13 +125,14 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
"""
if org.uuid in _db.orgs:
raise ValueError(f"Organization {org.uuid} already exists")
with _db.transaction("admin:create_org", ctx):
new_org = Org.create(display_name=org.display_name)
now = datetime.now(UTC)
with _transaction("admin:create_org", ctx):
new_org = Org.create(display_name=org.display_name, created_at=now)
new_org.uuid = org.uuid
_db.orgs[org.uuid] = new_org
new_org.store()
# Create Administration role with org admin permission
admin_role_uuid = uuid7.create()
admin_role_uuid = uuid7.create(now)
# Find the auth:org:admin permission UUID
org_admin_perm_uuid = None
for pid, p in _db.permissions.items():
@@ -192,7 +146,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
permissions=role_permissions,
)
admin_role.uuid = admin_role_uuid
_db.roles[admin_role_uuid] = admin_role
admin_role.store()
def update_org_name(
@@ -204,7 +158,7 @@ def update_org_name(
"""Update organization display name."""
if uuid not in _db.orgs:
raise ValueError(f"Organization {uuid} not found")
with _db.transaction("admin:update_org_name", ctx):
with _transaction("admin:update_org_name", ctx):
_db.orgs[uuid].display_name = display_name
@@ -212,17 +166,8 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete organization and all its roles/users."""
if uuid not in _db.orgs:
raise ValueError(f"Organization {uuid} not found")
with _db.transaction("admin:delete_org", ctx):
org = _db.orgs[uuid]
# Remove org from all permissions
for p in _db.permissions.values():
p.orgs.pop(uuid, None)
# Delete roles in this org and their users
for role in org.roles:
for user in role.users:
del _db.users[user.uuid]
del _db.roles[role.uuid]
del _db.orgs[uuid]
with _transaction("admin:delete_org", ctx):
_db.orgs[uuid].delete()
def add_permission_to_org(
@@ -238,7 +183,7 @@ def add_permission_to_org(
if permission_uuid not in _db.permissions:
raise ValueError(f"Permission {permission_uuid} not found")
with _db.transaction("admin:add_permission_to_org", ctx):
with _transaction("admin:add_permission_to_org", ctx):
_db.permissions[permission_uuid].orgs[org_uuid] = True
@@ -255,7 +200,7 @@ def remove_permission_from_org(
if permission_uuid not in _db.permissions:
return # Permission not found, silently return
with _db.transaction("admin:remove_permission_from_org", ctx):
with _transaction("admin:remove_permission_from_org", ctx):
_db.permissions[permission_uuid].orgs.pop(org_uuid, None)
@@ -265,8 +210,8 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
raise ValueError(f"Role {role.uuid} already exists")
if role.org_uuid not in _db.orgs:
raise ValueError(f"Organization {role.org_uuid} not found")
with _db.transaction("admin:create_role", ctx):
_db.roles[role.uuid] = role
with _transaction("admin:create_role", ctx):
role.store()
def update_role_name(
@@ -278,7 +223,7 @@ def update_role_name(
"""Update role display name."""
if uuid not in _db.roles:
raise ValueError(f"Role {uuid} not found")
with _db.transaction("admin:update_role_name", ctx):
with _transaction("admin:update_role_name", ctx):
_db.roles[uuid].display_name = display_name
@@ -293,7 +238,7 @@ def add_permission_to_role(
raise ValueError(f"Role {role_uuid} not found")
if permission_uuid not in _db.permissions:
raise ValueError(f"Permission {permission_uuid} not found")
with _db.transaction("admin:add_permission_to_role", ctx):
with _transaction("admin:add_permission_to_role", ctx):
_db.roles[role_uuid].permissions[permission_uuid] = True
@@ -306,7 +251,7 @@ def remove_permission_from_role(
"""Remove permission from role by UUID."""
if role_uuid not in _db.roles:
raise ValueError(f"Role {role_uuid} not found")
with _db.transaction("admin:remove_permission_from_role", ctx):
with _transaction("admin:remove_permission_from_role", ctx):
_db.roles[role_uuid].permissions.pop(permission_uuid, None)
@@ -318,8 +263,8 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
role = _db.roles[uuid]
if role.users:
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
with _db.transaction("admin:delete_role", ctx):
del _db.roles[uuid]
with _transaction("admin:delete_role", ctx):
_db.roles[uuid].delete()
def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
@@ -328,8 +273,8 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
raise ValueError(f"User {new_user.uuid} already exists")
if new_user.role_uuid not in _db.roles:
raise ValueError(f"Role {new_user.role_uuid} not found")
with _db.transaction("admin:create_user", ctx):
_db.users[new_user.uuid] = new_user
with _transaction("admin:create_user", ctx):
new_user.store()
def update_user_display_name(
@@ -343,30 +288,103 @@ def update_user_display_name(
The acting user should be logged via ctx.
For self-service (user updating own name), pass user's ctx.
For admin operations, pass admin's ctx.
If the user's preferred_username is currently None, this will auto-fill it
with a slugified version of the display name (if unique and non-empty).
"""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db.users:
raise ValueError(f"User {uuid} not found")
with _db.transaction("update_user_display_name", ctx):
_db.users[uuid].display_name = display_name
display_name = (display_name or "").strip()
if not display_name:
raise ValueError("Display name cannot be empty")
user = _db.users[uuid]
with _transaction("update_user_display_name", ctx):
user.display_name = display_name
# Auto-fill preferred_username if not already set
if user.preferred_username is None:
slug = slugify_name(display_name)
if slug and not is_username_taken(slug, exclude_uuid=uuid):
user.preferred_username = slug
def update_user_theme(
def update_user_info(
uuid: UUID,
theme: str,
*,
display_name: str | object = _UNSET,
theme: str | object = _UNSET,
email: str | None | object = _UNSET,
preferred_username: str | None | object = _UNSET,
telephone: str | None | object = _UNSET,
ctx: SessionContext | None = None,
) -> None:
"""Update user theme preference ('' for auto, 'light', 'dark')."""
"""Update user profile information.
Pass only the fields you want to update. Use None to clear optional fields.
This does NOT auto-fill preferred_username - use update_user_display_name
for the registration flow where auto-fill is desired.
Args:
uuid: User UUID
display_name: User display name (cannot be empty)
theme: Theme preference ('' for auto, 'light', 'dark')
email: Email address (None to clear)
preferred_username: Username for OIDC claims (None to clear, must be unique)
telephone: Phone number (None to clear)
ctx: Session context for audit logging
"""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db.users:
raise ValueError(f"User {uuid} not found")
if theme not in ("", "light", "dark"):
raise ValueError(f"Invalid theme: {theme}")
with _db.transaction("update_user_theme", ctx):
_db.users[uuid].theme = theme
user = _db.users[uuid]
# Validate all fields before transaction
if display_name is not _UNSET:
display_name = (display_name or "").strip()
if not display_name:
raise ValueError("Display name cannot be empty")
if theme is not _UNSET:
if theme not in ("", "light", "dark"):
raise ValueError(f"Invalid theme: {theme}")
if email is not _UNSET and email is not None:
email = (email or "").strip()
if not email:
email = None
elif "@" not in email or len(email) > 254:
raise ValueError("Invalid email format")
if preferred_username is not _UNSET and preferred_username is not None:
preferred_username = (preferred_username or "").strip()
if not preferred_username:
raise ValueError("Preferred username cannot be empty (use None to clear)")
if len(preferred_username) > 128:
raise ValueError("preferred_username too long")
if is_username_taken(preferred_username, exclude_uuid=uuid):
raise ValueError("Username already taken")
if telephone is not _UNSET and telephone is not None:
telephone = (telephone or "").strip()
if not telephone:
telephone = None
elif len(telephone) > 32:
raise ValueError("telephone too long")
with _transaction("update_user_info", ctx):
if display_name is not _UNSET:
user.display_name = display_name
if theme is not _UNSET:
user.theme = theme
if email is not _UNSET:
user.email = email
if preferred_username is not _UNSET:
user.preferred_username = preferred_username
if telephone is not _UNSET:
user.telephone = telephone
def update_user_role(
@@ -380,49 +398,16 @@ def update_user_role(
raise ValueError(f"User {uuid} not found")
if role_uuid not in _db.roles:
raise ValueError(f"Role {role_uuid} not found")
with _db.transaction("admin:update_user_role", ctx):
with _transaction("admin:update_user_role", ctx):
_db.users[uuid].role_uuid = role_uuid
def update_user_role_in_organization(
user_uuid: UUID,
role_name: str,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update user's role by role name within their current organization."""
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
user = _db.users[user_uuid]
org = user.org
# Find role by name in the same org
new_role_uuid = None
for r in org.roles:
if r.display_name == role_name:
new_role_uuid = r.uuid
break
if new_role_uuid is None:
raise ValueError(f"Role '{role_name}' not found in organization")
with _db.transaction("admin:update_user_role", ctx):
_db.users[user_uuid].role_uuid = new_role_uuid
def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete user and their credentials/sessions."""
if uuid not in _db.users:
raise ValueError(f"User {uuid} not found")
user = _db.users[uuid]
with _db.transaction("admin:delete_user", ctx):
# Delete credentials
for cred in user.credentials:
del _db.credentials[cred.uuid]
# Delete sessions
for sess in user.sessions:
del _db.sessions[sess.key]
# Delete reset tokens
for token in user.reset_tokens:
del _db.reset_tokens[token.key]
del _db.users[uuid]
with _transaction("admin:delete_user", ctx):
_db.users[uuid].delete()
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
@@ -431,8 +416,8 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
raise ValueError(f"Credential {cred.uuid} already exists")
if cred.user_uuid not in _db.users:
raise ValueError(f"User {cred.user_uuid} not found")
with _db.transaction("create_credential", ctx):
_db.credentials[cred.uuid] = cred
with _transaction("create_credential", ctx):
cred.store()
def update_credential_sign_count(
@@ -445,7 +430,7 @@ def update_credential_sign_count(
"""Update credential sign count and last_used."""
if uuid not in _db.credentials:
raise ValueError(f"Credential {uuid} not found")
with _db.transaction("update_credential_sign_count", ctx):
with _transaction("update_credential_sign_count", ctx):
_db.credentials[uuid].sign_count = sign_count
if last_used:
_db.credentials[uuid].last_used = last_used
@@ -467,42 +452,8 @@ def delete_credential(
if user_uuid is not None:
if cred.user_uuid != user_uuid:
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
with _db.transaction("delete_credential", ctx):
# Delete all sessions using this credential
for sess in cred.sessions:
print(sess, repr(sess.key))
del _db.sessions[sess.key]
del _db.credentials[uuid]
def create_session(
user_uuid: UUID,
credential_uuid: UUID,
host: str,
ip: str,
user_agent: str,
expiry: datetime,
*,
ctx: SessionContext | None = None,
) -> str:
"""Create a new session. Returns the session key."""
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
session = Session.create(
user=user_uuid,
credential=credential_uuid,
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
)
if session.key in _db.sessions:
raise ValueError("Session already exists")
with _db.transaction("create_session", ctx):
_db.sessions[session.key] = session
return session.key
with _transaction("delete_credential", ctx):
cred.delete()
def update_session(
@@ -510,14 +461,14 @@ def update_session(
host: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
expiry: datetime | None = None,
validated: datetime | None = None,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update session metadata."""
if key not in _db.sessions:
raise ValueError("Session not found")
with _db.transaction("update_session", ctx):
with _transaction("update_session", ctx):
s = _db.sessions[key]
if host is not None:
s.host = host
@@ -525,8 +476,8 @@ def update_session(
s.ip = ip
if user_agent is not None:
s.user_agent = user_agent
if expiry is not None:
s.expiry = expiry
if validated is not None:
s.validated = validated
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
@@ -545,8 +496,10 @@ def delete_session(
"""
if key not in _db.sessions:
raise ValueError("Session not found")
with _db.transaction(action, ctx):
del _db.sessions[key]
oidc_notify.schedule_notifications([key])
with _transaction(action, ctx):
_db.sessions[key].delete()
def delete_sessions_for_user(
@@ -561,66 +514,51 @@ def delete_sessions_for_user(
user = _db.users.get(user_uuid)
if not user:
return
with _db.transaction("admin:delete_sessions_for_user", ctx):
keys = [s.key for s in user.sessions]
oidc_notify.schedule_notifications(keys)
with _transaction("admin:delete_sessions_for_user", ctx):
for sess in user.sessions:
del _db.sessions[sess.key]
sess.delete()
def create_reset_token(
passphrase: str,
user_uuid: UUID,
expiry: datetime,
token_type: str,
*,
ctx: SessionContext | None = None,
user: str | None = None,
) -> None:
"""Create a reset token from a passphrase.
) -> str:
"""Create a reset token and return the passphrase.
The acting user should be logged via ctx.
For self-service (user creating own recovery link), pass user's ctx.
For admin operations, pass admin's ctx.
For system operations (bootstrap), pass neither to log no user.
For API operations where ctx is not available but user is known, pass user.
Returns:
The passphrase to give to the user.
"""
key = _reset_key(passphrase)
if key in _db.reset_tokens:
raise ValueError("Reset token already exists")
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
with _db.transaction("create_reset_token", ctx, user=user):
_db.reset_tokens[key] = ResetToken(
user_uuid=user_uuid, expiry=expiry, token_type=token_type
)
token, passphrase = ResetToken.create(
user=user_uuid, expiry=expiry, token_type=token_type
)
if token.key in _db.reset_tokens:
raise ValueError("Reset token already exists")
with _transaction("create_reset_token", ctx, user=user):
token.store()
return passphrase
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
"""Delete a reset token."""
if key not in _db.reset_tokens:
raise ValueError("Reset token not found")
with _db.transaction("delete_reset_token", ctx):
del _db.reset_tokens[key]
# -------------------------------------------------------------------------
# Cleanup (called by background task)
# -------------------------------------------------------------------------
def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
count = 0
with _db.transaction("expiry"):
expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now]
for k in expired_sessions:
del _db.sessions[k]
count += 1
expired_tokens = [k for k, t in _db.reset_tokens.items() if t.expiry < now]
for k in expired_tokens:
del _db.reset_tokens[k]
count += 1
return count
with _transaction("delete_reset_token", ctx):
_db.reset_tokens[key].delete()
# -------------------------------------------------------------------------
@@ -628,11 +566,6 @@ def cleanup_expired() -> int:
# -------------------------------------------------------------------------
def _create_token() -> str:
"""Generate a 16-character URL-safe session token."""
return secrets.token_urlsafe(12)
def login(
user_uuid: UUID,
credential_uuid: UUID,
@@ -640,7 +573,7 @@ def login(
host: str,
ip: str,
user_agent: str,
expiry: datetime,
duration: timedelta = SESSION_LIFETIME,
) -> str:
"""Update user/credential on login and create session in a single transaction.
@@ -660,25 +593,51 @@ def login(
if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
# Generate token and derive key
token = secrets.token_urlsafe(12)
session = Session.create(
user=user_uuid,
credential=credential_uuid,
key=hash_secret("cookie", token),
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
validated=now,
)
user_str = str(user_uuid)
with _db.transaction("login", user=user_str):
# Update user
_db.users[user_uuid].last_seen = now
_db.users[user_uuid].visits += 1
with _transaction("login", user=user_str):
session.store(now)
# Update credential
_db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
return token
def oidc_login(
session: Session,
credential_uuid: UUID,
sign_count: int,
) -> None:
"""Store an OIDC session and update credential in a single transaction.
The caller is responsible for generating the token, deriving the key,
and creating the Session object. This function only handles the
database transaction.
Updates:
- user.last_seen, user.visits
- credential.sign_count, credential.last_used
Stores:
- the provided session
"""
now = datetime.now(UTC)
user_str = str(session.user_uuid)
with _transaction("oidc_login", user=user_str):
session.store(now)
# Update credential
_db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
# Create session
_db.sessions[session.key] = session
return session.key
def create_credential_session(
@@ -702,145 +661,138 @@ def create_credential_session(
"""
now = datetime.now(UTC)
expiry = now + SESSION_LIFETIME
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
# Generate token and derive key
token = secrets.token_urlsafe(12)
key = hash_secret("cookie", token)
session = Session.create(
user=user_uuid,
credential=credential.uuid,
key=key,
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
validated=now,
)
user_str = str(user_uuid)
with _db.transaction("create_credential_session", user=user_str):
with _transaction("create_credential_session", user=user_str):
# Update display name if provided
if display_name:
_db.users[user_uuid].display_name = display_name
# Create credential
_db.credentials[credential.uuid] = credential
# Align credential timestamps with transaction time
credential.created_at = now
credential.last_used = now
credential.last_verified = now
# Create session
_db.sessions[session.key] = session
# Create credential
credential.store()
# Store session and record visit
session.store(now)
# Delete reset token if provided
if reset_key:
if reset_key in _db.reset_tokens:
del _db.reset_tokens[reset_key]
return session.key
reset_token = _db.reset_tokens.get(reset_key)
if reset_token:
reset_token.delete()
return token
# -------------------------------------------------------------------------
# Bootstrap (single transaction for initial system setup)
# OIDC Provider operations
# -------------------------------------------------------------------------
def bootstrap(
org_name: str = "Organization",
admin_name: str = "Admin",
reset_passphrase: str | None = None,
reset_expiry: datetime | None = None,
) -> str:
"""Bootstrap the entire system in a single transaction.
def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> None:
"""Create a new OIDC client."""
if client.uuid in _db.oidc.clients:
raise ValueError(f"OIDC client {client.uuid} already exists")
with _transaction("admin:create_oid_client", ctx):
_db.oidc.clients[client.uuid] = client
Creates:
- auth:admin permission (Master Admin)
- auth:org:admin permission (Org Admin)
- Organization with Administration role
- Admin user with Administration role
- Reset token for admin registration
This is the only way to create a new database file (besides migrate).
All data is created atomically - if any step fails, nothing is written.
def update_oid_client(
client_uuid: UUID,
name: str | None = None,
redirect_uris: list[str] | None = None,
secret_hash: bytes | None = None,
backchannel_logout_uri: str | None = _UNSET,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update an OIDC client's name, redirect URIs, and/or secret."""
if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found")
Args:
org_name: Display name for the organization (default: "Organization")
admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided)
reset_expiry: Expiry datetime for the reset token (default: 14 days)
client = _db.oidc.clients[client_uuid]
changes = {}
Returns:
The reset passphrase for admin registration.
"""
if name is not None and name != client.name:
changes["name"] = name
if redirect_uris is not None and redirect_uris != client.redirect_uris:
changes["redirect_uris"] = redirect_uris
if secret_hash is not None and secret_hash != client.client_secret_hash:
changes["client_secret_hash"] = secret_hash
if (
backchannel_logout_uri is not _UNSET
and backchannel_logout_uri != client.backchannel_logout_uri
):
changes["backchannel_logout_uri"] = backchannel_logout_uri
# Check if system is already bootstrapped
for p in _db.permissions.values():
if p.scope == "auth:admin":
raise ValueError(
"System already bootstrapped (auth:admin permission exists)"
)
if not changes:
return # No changes to make
# Generate UUIDs upfront
perm_admin_uuid = uuid7.create()
perm_org_admin_uuid = uuid7.create()
org_uuid = uuid7.create()
role_uuid = uuid7.create()
user_uuid = uuid7.create()
new_logout_uri = (
backchannel_logout_uri
if backchannel_logout_uri is not _UNSET
else client.backchannel_logout_uri
)
# Generate reset token components
if reset_passphrase is None:
reset_passphrase = generate_passphrase()
if reset_expiry is None:
from paskia.authsession import reset_expires # noqa: PLC0415
reset_expiry = reset_expires()
reset_key = _reset_key(reset_passphrase)
now = datetime.now(UTC)
with _db.transaction("bootstrap"):
# Create auth:admin permission
perm_admin = Permission(
scope="auth:admin",
display_name="Master Admin",
orgs={org_uuid: True}, # Grant to org
with _transaction("admin:update_oid_client", ctx):
# Create updated client with new values
updated_client = Client(
client_secret_hash=secret_hash
if secret_hash is not None
else client.client_secret_hash,
name=name if name is not None else client.name,
redirect_uris=redirect_uris
if redirect_uris is not None
else client.redirect_uris,
backchannel_logout_uri=new_logout_uri,
)
perm_admin.uuid = perm_admin_uuid
_db.permissions[perm_admin_uuid] = perm_admin
updated_client.uuid = client.uuid
_db.oidc.clients[client_uuid] = updated_client
# Create auth:org:admin permission
perm_org_admin = Permission(
scope="auth:org:admin",
display_name="Org Admin",
orgs={org_uuid: True}, # Grant to org
def reset_oid_client_secret(
client_uuid: UUID,
new_secret_hash: bytes,
*,
ctx: SessionContext | None = None,
) -> None:
"""Reset an OIDC client's secret."""
if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found")
client = _db.oidc.clients[client_uuid]
with _transaction("admin:reset_oid_client_secret", ctx):
updated = Client(
client_secret_hash=new_secret_hash,
name=client.name,
redirect_uris=client.redirect_uris,
backchannel_logout_uri=client.backchannel_logout_uri,
)
perm_org_admin.uuid = perm_org_admin_uuid
_db.permissions[perm_org_admin_uuid] = perm_org_admin
updated.uuid = client.uuid
_db.oidc.clients[client_uuid] = updated
# Create organization
new_org = Org.create(display_name=org_name)
new_org.uuid = org_uuid
_db.orgs[org_uuid] = new_org
# Create Administration role with both permissions
admin_role = Role(
org_uuid=org_uuid,
display_name="Administration",
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
)
admin_role.uuid = role_uuid
_db.roles[role_uuid] = admin_role
# Create admin user
admin_user = User(
display_name=admin_name,
role_uuid=role_uuid,
created_at=now,
last_seen=None,
visits=0,
)
admin_user.uuid = user_uuid
_db.users[user_uuid] = admin_user
# Create reset token
_db.reset_tokens[reset_key] = ResetToken(
user_uuid=user_uuid,
expiry=reset_expiry,
token_type="admin bootstrap",
)
return reset_passphrase
def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete an OIDC client."""
if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found")
with _transaction("admin:delete_oid_client", ctx):
del _db.oidc.clients[client_uuid]
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import os
import shutil
from pathlib import Path
def db_root_path(*, rp_id: str = "localhost") -> Path:
"""Return the configured persistence root directory."""
return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb"))
def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
"""Return the JSONL database file path under the persistence root."""
root = db_root_path(rp_id=rp_id)
if root.is_file():
_migrate_legacy_db_file(root)
if create_root:
root.mkdir(parents=True, exist_ok=True)
return root / "main.db"
def users_root_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
"""Return the filesystem root for persisted user files."""
root = db_root_path(rp_id=rp_id)
if root.is_file():
_migrate_legacy_db_file(root)
if create_root:
root.mkdir(parents=True, exist_ok=True)
return root / "users"
def _migrate_legacy_db_file(legacy_path: Path) -> None:
"""Upgrade a legacy single-file database path into a directory root."""
temp_root = legacy_path.parent / f".{legacy_path.name}.migrating"
shutil.rmtree(temp_root, ignore_errors=True)
temp_root.unlink(missing_ok=True)
temp_root.mkdir(parents=True)
legacy_path.replace(temp_root / "main.db")
temp_root.rename(legacy_path)
+290 -37
View File
@@ -1,14 +1,17 @@
from __future__ import annotations
import hashlib
import secrets
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
import msgspec
import uuid7
from paskia import db
from paskia.util.hostutil import normalize_host
from paskia.util import passphrase as passphrase_util
from paskia.util.crypto import hash_secret
# Sentinel for uuid fields before they are set by create() or DB post init
_UUID_UNSET = UUID(int=0)
@@ -45,20 +48,36 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
if org_uuid in db.data().orgs
]
def store(self) -> None:
"""Store this permission in the database. Must be called inside a transaction."""
db.data().permissions[self.uuid] = self
def delete(self) -> None:
"""Delete this permission and remove it from all roles.
Must be called inside a transaction.
"""
_data = db.data()
for role in _data.roles.values():
role.permissions.pop(self.uuid, None)
del _data.permissions[self.uuid]
@classmethod
def create(
cls,
scope: str,
display_name: str,
domain: str | None = None,
created_at: datetime | None = None,
) -> Permission:
"""Create a new Permission with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
perm = cls(
scope=scope,
display_name=display_name,
domain=domain,
)
perm.uuid = uuid7.create()
perm.uuid = uuid7.create(now)
return perm
@@ -81,11 +100,30 @@ class Org(msgspec.Struct, dict=True):
"""Get all permissions that this organization can grant."""
return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
def store(self) -> None:
"""Store this organization in the database. Must be called inside a transaction."""
db.data().orgs[self.uuid] = self
def delete(self) -> None:
"""Delete this org and cascade to roles, users. Remove from permissions.
Must be called inside a transaction.
"""
_data = db.data()
for p in _data.permissions.values():
p.orgs.pop(self.uuid, None)
for role in self.roles:
for user in role.users:
del _data.users[user.uuid]
del _data.roles[role.uuid]
del _data.orgs[self.uuid]
@classmethod
def create(cls, display_name: str) -> Org:
def create(cls, display_name: str, created_at: datetime | None = None) -> Org:
"""Create a new Org with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org = cls(display_name=display_name)
org.uuid = uuid7.create()
org.uuid = uuid7.create(now)
return org
@@ -129,28 +167,38 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all users that have this role."""
return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
def store(self) -> None:
"""Store this role in the database. Must be called inside a transaction."""
db.data().roles[self.uuid] = self
def delete(self) -> None:
"""Delete this role from the database. Must be called inside a transaction."""
del db.data().roles[self.uuid]
@classmethod
def create(
cls,
org: UUID | Org,
display_name: str,
permissions: set[UUID] | None = None,
created_at: datetime | None = None,
) -> Role:
"""Create a new Role with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org_uuid = org if isinstance(org, UUID) else org.uuid
role = cls(
org_uuid=org_uuid,
display_name=display_name,
permissions={p: True for p in (permissions or set())},
)
role.uuid = uuid7.create()
role.uuid = uuid7.create(now)
return role
class User(msgspec.Struct, dict=True, omit_defaults=True):
class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
"""User data structure.
Mutable fields: display_name, role_uuid, last_seen, visits, theme
Mutable fields: display_name, role_uuid, last_seen, visits, theme, email, preferred_username
Immutable fields: created_at (set at creation, never modified)
uuid is derived from created_at using uuid7.
"""
@@ -158,9 +206,12 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
display_name: str
role_uuid: UUID = msgspec.field(name="role")
created_at: datetime
visits: int
last_seen: datetime | None = None
visits: int = 0
theme: str = "" # "" or "auto" = OS default, "light", "dark"
theme: str = ""
email: str | None = None # OIDC email claim
preferred_username: str | None = None # OIDC preferred_username claim
telephone: str | None = None # Telephone number
def __post_init__(self):
if not hasattr(self, "uuid"):
@@ -181,6 +232,11 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all credentials for this user."""
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
@property
def credential_ids(self) -> list[bytes]:
"""Get credential IDs for this user (for WebAuthn exclude lists)."""
return [c.credential_id for c in self.credentials]
@property
def sessions(self) -> list[Session]:
"""Get all sessions for this user."""
@@ -191,6 +247,24 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all reset tokens for this user."""
return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
def store(self) -> None:
"""Store this user in the database. Must be called inside a transaction."""
db.data().users[self.uuid] = self
def delete(self) -> None:
"""Delete this user and cascade to credentials, sessions, reset tokens.
Must be called inside a transaction.
"""
_data = db.data()
for cred in self.credentials:
del _data.credentials[cred.uuid]
for sess in self.sessions:
del _data.sessions[sess.key]
for token in self.reset_tokens:
del _data.reset_tokens[token.key]
del _data.users[self.uuid]
@classmethod
def create(
cls,
@@ -204,6 +278,9 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
display_name=display_name,
role_uuid=role_uuid,
created_at=created_at or datetime.now(UTC),
last_seen=None,
visits=0,
theme="",
)
user.uuid = uuid7.create(user.created_at)
return user
@@ -242,6 +319,20 @@ class Credential(msgspec.Struct, dict=True):
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
]
def store(self) -> None:
"""Store this credential in the database. Must be called inside a transaction."""
db.data().credentials[self.uuid] = self
def delete(self) -> None:
"""Delete this credential and all its sessions.
Must be called inside a transaction.
"""
_data = db.data()
for sess in self.sessions:
del _data.sessions[sess.key]
del _data.credentials[self.uuid]
@classmethod
def create(
cls,
@@ -269,12 +360,17 @@ class Credential(msgspec.Struct, dict=True):
return cred
class Session(msgspec.Struct, dict=True):
class Session(msgspec.Struct, dict=True, omit_defaults=True):
"""Session data structure.
Mutable fields: expiry (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent
key is stored in the dict key, not in the struct.
Mutable fields: validated (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
key is the hashed db_key, stored in the dict key, not in the struct.
If client_uuid is set, this is an OIDC session.
Security: The database stores only derived keys, never the raw secret.
A database leak does not expose working session credentials.
"""
user_uuid: UUID = msgspec.field(name="user")
@@ -282,7 +378,8 @@ class Session(msgspec.Struct, dict=True):
host: str
ip: str
user_agent: str
expiry: datetime
validated: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
def __post_init__(self):
if not hasattr(self, "key"):
@@ -303,33 +400,60 @@ class Session(msgspec.Struct, dict=True):
return {
"ip": self.ip,
"user_agent": self.user_agent,
"expiry": self.expiry.isoformat(),
"validated": self.validated.isoformat(),
}
def store(self, last_seen: datetime) -> None:
"""Store this session in the database and record a visit.
Updates user.last_seen and user.visits. Must be called inside
a database transaction.
"""
_data = db.data()
_data.sessions[self.key] = self
_data.users[self.user_uuid].last_seen = last_seen
_data.users[self.user_uuid].visits += 1
def delete(self) -> None:
"""Delete this session from the database. Must be called inside a transaction."""
del db.data().sessions[self.key]
@classmethod
def create(
cls,
user: UUID | User,
credential: UUID | Credential,
key: str,
host: str,
ip: str,
user_agent: str,
expiry: datetime,
validated: datetime,
client: UUID | None = None,
) -> Session:
"""Create a new Session with auto-generated key."""
"""Create a new Session with the provided key.
Args:
key: The hashed session key (derived from secret via hash_secret)
Returns:
Session object with key set
"""
user_uuid = user if isinstance(user, UUID) else user.uuid
credential_uuid = (
credential if isinstance(credential, UUID) else credential.uuid
)
session = cls(
user_uuid=user_uuid,
credential_uuid=credential_uuid,
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
validated=validated,
client_uuid=client,
)
session.key = secrets.token_urlsafe(12)
session.key = key
return session
@@ -346,13 +470,122 @@ class ResetToken(msgspec.Struct, dict=True):
def __post_init__(self):
if not hasattr(self, "key"):
self.key: bytes = b""
self.key: str = ""
@property
def user(self) -> User:
"""Get the User object for this reset token."""
return db.data().users[self.user_uuid]
def store(self) -> None:
"""Store this reset token in the database. Must be called inside a transaction."""
db.data().reset_tokens[self.key] = self
def delete(self) -> None:
"""Delete this reset token from the database. Must be called inside a transaction."""
del db.data().reset_tokens[self.key]
@staticmethod
def hash(passphrase: str) -> str:
"""Hash a passphrase to string for reset token storage."""
if not passphrase_util.is_well_formed(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return hash_secret("reset", passphrase)
@classmethod
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
"""Get a reset token by passphrase."""
key = cls.hash(passphrase)
return db.data().reset_tokens.get(key)
@classmethod
def create(
cls,
user: UUID | User,
expiry: datetime,
token_type: str,
passphrase: str | None = None,
) -> tuple[ResetToken, str]:
"""Create a new ResetToken with auto-generated or provided passphrase.
Args:
user: User UUID or User object
expiry: Token expiration datetime
token_type: Type of token (e.g., "device addition", "account recovery")
passphrase: Optional passphrase to use (auto-generated if not provided)
Returns:
Tuple of (token, passphrase) where passphrase is the human-readable
code to give to the user.
"""
if passphrase is None:
passphrase = passphrase_util.generate()
key = cls.hash(passphrase)
user_uuid = user if isinstance(user, UUID) else user.uuid
token = cls(
user_uuid=user_uuid,
expiry=expiry,
token_type=token_type,
)
token.key = key
return token, passphrase
# -------------------------------------------------------------------------
# OIDC Provider structures
# -------------------------------------------------------------------------
class Client(msgspec.Struct, dict=True, omit_defaults=True):
"""OIDC client (relying party) registration.
client_id is the dict key (UUID).
"""
client_secret_hash: bytes
name: str
redirect_uris: list[str]
backchannel_logout_uri: str | None = None
def __post_init__(self):
if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@classmethod
def create(
cls,
name: str,
redirect_uris: list[str],
client_secret: str,
created_at: datetime | None = None,
backchannel_logout_uri: str | None = None,
) -> tuple[Client, str]:
"""Create a new OIDClient with hashed secret.
Returns (client, client_secret) tuple.
"""
now = created_at or datetime.now(UTC)
secret_hash = hashlib.sha256(client_secret.encode()).digest()
client = cls(
client_secret_hash=secret_hash,
name=name,
redirect_uris=redirect_uris,
backchannel_logout_uri=backchannel_logout_uri,
)
client.uuid = uuid7.create(now)
return client, client_secret
def verify_secret(self, client_secret: str) -> bool:
"""Verify a client secret against stored hash."""
return secrets.compare_digest(
self.client_secret_hash,
hashlib.sha256(client_secret.encode()).digest(),
)
class SessionContext(msgspec.Struct):
session: Session
@@ -363,6 +596,21 @@ class SessionContext(msgspec.Struct):
permissions: list[Permission] = []
class OIDC(msgspec.Struct, dict=True):
clients: dict[UUID, Client] = {}
key: bytes | None = None
class Config(msgspec.Struct, omit_defaults=True):
"""Stored configuration for the instance."""
rp_id: str
rp_name: str | None = None
auth_host: str | None = None
origins: list[str] | None = None
listen: list[str] | None = None
# -------------------------------------------------------------------------
# Database storage structure
# -------------------------------------------------------------------------
@@ -371,17 +619,20 @@ class SessionContext(msgspec.Struct):
class DB(msgspec.Struct, dict=True, omit_defaults=False):
"""In-memory database. Access fields directly for reads."""
config: Config = msgspec.field(default_factory=lambda: Config(rp_id="localhost"))
permissions: dict[UUID, Permission] = {}
orgs: dict[UUID, Org] = {}
roles: dict[UUID, Role] = {}
users: dict[UUID, User] = {}
credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {}
reset_tokens: dict[bytes, ResetToken] = {}
reset_tokens: dict[str, ResetToken] = {}
# OIDC provider data
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
def __post_init__(self):
# Store reference for persistence (not serialized)
self._store = None
# Optional store reference for non-global DB instances (e.g. tests).
self._store: Any | None = None
# Set the key fields on all stored objects
for uuid, perm in self.permissions.items():
perm.uuid = uuid
@@ -397,30 +648,36 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
session.key = key
for key, token in self.reset_tokens.items():
token.key = key
def transaction(self, action, ctx=None, *, user=None):
"""Wrap writes in transaction. Delegates to JsonlStore."""
return self._store.transaction(action, ctx, user=user)
# OIDC
for uuid, client in self.oidc.clients.items():
client.uuid = uuid
def session_ctx(
self, session_key: str, host: str | None = None
self, session_secret: str, host: str | None = None
) -> SessionContext | None:
"""Get full session context with effective permissions.
Args:
session_key: The session key string
session_secret: The session secret (cookie value) - will be hashed for lookup
host: Optional host for binding/validation and domain-scoped permissions
Returns:
SessionContext if valid, None if session not found, expired, or host mismatch
"""
key = hash_secret("cookie", session_secret)
try:
s = self.sessions[session_key]
s = self.sessions[key]
except KeyError:
return None
# OIDC sessions (client_uuid set) are not valid for cookie-based auth
if s.client_uuid is not None:
return None
# Validate host matches (sessions are always created with a host)
if s.host != host:
normalized_input = host
if s.host != normalized_input:
# Session bound to different host
return None
@@ -435,10 +692,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
# Effective permissions: role's permissions that the org can grant
# Also filter by domain if host is provided
org_perm_uuids = {p.uuid for p in org.permissions}
normalized_host = normalize_host(host)
host_without_port = (
normalized_host.rsplit(":", 1)[0] if normalized_host else None
)
effective_perms = []
for perm_uuid in role.permission_set:
@@ -448,8 +701,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
p = self.permissions[perm_uuid]
except KeyError:
continue
# Check domain restriction
if p.domain is not None and p.domain != host_without_port:
# Check domain restriction (normalized_input already has port stripped)
if p.domain is not None and p.domain != normalized_input:
continue
effective_perms.append(p)
-3
View File
@@ -1,3 +0,0 @@
from paskia.fastapi.mainapp import app
__all__ = ["app"]
-250
View File
@@ -1,250 +0,0 @@
import argparse
import asyncio
import json
import logging
import os
from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server
from uvicorn import run as uvicorn_run
from paskia import globals as _globals
from paskia.bootstrap import bootstrap_if_needed
from paskia.config import PaskiaConfig
from paskia.db.background import flush
from paskia.fastapi import reset as reset_cmd
from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin
DEFAULT_PORT = 4401
EPILOG = """\
Examples:
paskia # localhost:4401
paskia -l :8080 # All interfaces, port 8080
paskia -l /tmp/paskia.sock # Unix socket
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,
)
# Subcommand for reset
parser.add_argument(
"command",
nargs="?",
help="Command: 'reset' for credential reset, or omit to run server",
)
parser.add_argument(
"reset_query",
nargs="?",
help="For 'reset' command: user UUID or substring of display name",
)
parser.add_argument(
"-l",
"--listen",
metavar="LISTEN",
help=(
"Endpoint to listen on (default: localhost:4401). "
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
),
)
add_common_options(parser)
args = parser.parse_args()
# Detect "reset" subcommand
is_reset = args.command == "reset"
if is_reset:
endpoints = []
else:
if args.command is not None:
raise SystemExit(f"Unknown command: {args.command}")
# Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
# Extract host/port/uds from first endpoint for config display and site_url
ep = endpoints[0] if endpoints else {}
host = ep.get("host")
port = ep.get("port")
uds = ep.get("uds")
# Collect and normalize origins, handle auth_host
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
if args.auth_host:
# Normalize auth_host with scheme
if "://" not in args.auth_host:
args.auth_host = f"https://{args.auth_host}"
validate_auth_host(args.auth_host, args.rp_id)
# If origins are configured, ensure auth_host is included at top
if origins:
# Insert auth_host at the beginning
origins.insert(0, args.auth_host)
# Remove duplicates while preserving order
seen = set()
origins = [x for x in origins if not (x in seen or seen.add(x))]
# Compute site_url and site_path for reset links
# Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port
explicit_site_url = os.environ.get("PASKIA_SITE_URL")
if explicit_site_url:
# Explicit site URL from devserver or deployment config
site_url = explicit_site_url.rstrip("/")
site_path = "/" if args.auth_host else "/auth/"
elif args.auth_host:
site_url = args.auth_host.rstrip("/")
site_path = "/"
elif origins:
# Find localhost origin if rp_id is localhost, else use first origin
localhost_origin = (
next((o for o in origins if "://localhost" in o), None)
if args.rp_id == "localhost"
else None
)
site_url = (localhost_origin or origins[0]).rstrip("/")
site_path = "/auth/"
elif args.rp_id == "localhost" and port:
# Dev mode: use http with port
site_url = f"http://localhost:{port}"
site_path = "/auth/"
else:
site_url = f"https://{args.rp_id}"
site_path = "/auth/"
# Build runtime configuration
config = PaskiaConfig(
rp_id=args.rp_id,
rp_name=args.rp_name or None,
origins=origins or None,
auth_host=args.auth_host or None,
site_url=site_url,
site_path=site_path,
host=host,
port=port,
uds=uds,
)
# Export configuration via single JSON env variable for worker processes
config_json = {
"rp_id": config.rp_id,
"rp_name": config.rp_name,
"origins": config.origins,
"auth_host": config.auth_host,
"site_url": config.site_url,
"site_path": config.site_path,
}
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
startupbox.print_startup_config(config)
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
run_kwargs: dict = {
"log_level": "warning", # Suppress startup messages; we use custom logging
"access_log": False, # We use custom AccessLogMiddleware instead
}
if devmode:
# Security: dev mode must run on localhost:4402 to prevent
# accidental public exposure of the Vite dev server
if host != "localhost" or port != 4402:
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
run_kwargs["reload"] = True
run_kwargs["reload_dirs"] = ["paskia"]
async def async_main():
await _globals.init(
rp_id=config.rp_id,
rp_name=config.rp_name,
origins=config.origins,
bootstrap=False,
)
await bootstrap_if_needed()
await flush()
if is_reset:
exit_code = reset_cmd.run(args.reset_query)
raise SystemExit(exit_code)
if len(endpoints) > 1:
async with asyncio.TaskGroup() as tg:
for ep in endpoints:
tg.create_task(
Server(
Config(app="paskia.fastapi:app", **run_kwargs, **ep)
).serve()
)
elif devmode:
# Use uvicorn.run for proper reload support (it handles subprocess spawning)
ep = endpoints[0]
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
else:
server = Server(
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
)
await server.serve()
try:
asyncio.run(async_main())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
-909
View File
@@ -1,909 +0,0 @@
import logging
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse
from paskia import aaguid as aaguid_mod
from paskia import db
from paskia.authsession import EXPIRES, reset_expires
from paskia.db import Org as OrgDC
from paskia.db import Permission as PermDC
from paskia.db import Role as RoleDC
from paskia.db import User as UserDC
from paskia.fastapi import authz
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey
from paskia.util import (
hostutil,
passphrase,
permutil,
querysafe,
vitedev,
)
from paskia.util.apistructs import ApiPermission, ApiSession, format_datetime
from paskia.util.hostutil import normalize_host
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.exception_handler(ValueError)
async def value_error_handler(_request, exc: ValueError): # pragma: no cover - simple
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI."""
return JSONResponse(
status_code=exc.status_code,
content=await authz.auth_error_content(exc),
)
@app.exception_handler(Exception)
async def general_exception_handler(_request, exc: Exception): # pragma: no cover
logging.exception("Unhandled exception in admin app")
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE):
return Response(*await vitedev.read("/auth/admin/index.html"))
# -------------------- Organizations --------------------
@app.get("/orgs")
async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
orgs = list(db.data().orgs.values())
if not master_admin(ctx):
# Org admins can only see their own organization
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
def org_to_dict(o):
users = db.get_organization_users(o.uuid)
return {
"uuid": o.uuid,
"display_name": o.display_name,
"permissions": {p.uuid for p in o.permissions},
"roles": [
{
"uuid": r.uuid,
"org": r.org_uuid,
"display_name": r.display_name,
"permissions": list(r.permissions.keys()),
}
for r in o.roles
],
"users": [
{
"uuid": u.uuid,
"display_name": u.display_name,
"role": role_name,
"visits": u.visits,
"last_seen": u.last_seen,
}
for (u, role_name) in users
],
}
return MsgspecResponse([org_to_dict(o) for o in orgs])
@app.post("/orgs")
async def admin_create_org(
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
display_name = payload.get("display_name") or "New Organization"
permissions = payload.get("permissions") or []
org = OrgDC.create(display_name=display_name)
db.create_org(org, ctx=ctx)
# Grant requested permissions to the new org
for perm in permissions:
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
return {"uuid": str(org.uuid)}
@app.patch("/orgs/{org_uuid}")
async def admin_update_org_name(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update organization display name only."""
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name")
if not display_name:
raise ValueError("display_name is required")
db.update_org_name(org_uuid, display_name, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}")
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
if ctx.org.uuid == org_uuid:
raise ValueError("Cannot delete the organization you belong to")
# Delete organization-specific permissions
org_perm_pattern = f"org:{str(org_uuid).lower()}"
all_permissions = list(db.data().permissions.values())
for perm in all_permissions:
perm_scope_lower = perm.scope.lower()
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
if (
f":{org_perm_pattern}:" in perm_scope_lower
or perm_scope_lower.startswith(f"{org_perm_pattern}:")
or perm_scope_lower.endswith(f":{org_perm_pattern}")
or perm_scope_lower == org_perm_pattern
):
db.delete_permission(perm.uuid, ctx=ctx)
db.delete_org(org_uuid, ctx=ctx)
return {"status": "ok"}
@app.post("/orgs/{org_uuid}/permission")
async def admin_add_org_permission(
org_uuid: UUID,
request: Request,
permission_uuid: UUID = Query(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/permission")
async def admin_remove_org_permission(
org_uuid: UUID,
request: Request,
permission_uuid: UUID = Query(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
perm = db.data().permissions.get(permission_uuid)
if perm and perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
# Check if any other org grants auth:admin that we're a member of
# (we only know our current org, so this effectively means we can't remove it from our own org)
raise ValueError(
"Cannot remove auth:admin from your own organization. "
"This would lock you out of admin access."
)
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
# -------------------- Roles --------------------
@app.post("/orgs/{org_uuid}/roles")
async def admin_create_role(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name") or "New Role"
perms = payload.get("permissions") or []
if org_uuid not in db.data().orgs:
raise HTTPException(status_code=404, detail="Organization not found")
org = db.data().orgs[org_uuid]
grantable = {p.uuid for p in org.permissions}
# Normalize permission IDs to UUIDs
permission_uuids: set[UUID] = set()
for pid in perms:
perm = db.data().permissions.get(UUID(pid))
if not perm:
raise ValueError(f"Permission {pid} not found")
if perm.uuid not in grantable:
raise ValueError(f"Permission not grantable by org: {pid}")
permission_uuids.add(perm.uuid)
role = RoleDC.create(
org=org_uuid,
display_name=display_name,
permissions=permission_uuids,
)
db.create_role(role, ctx=ctx)
return {"uuid": str(role.uuid)}
@app.patch("/orgs/{org_uuid}/roles/{role_uuid}")
async def admin_update_role_name(
org_uuid: UUID,
role_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update role display name only."""
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
display_name = payload.get("display_name")
if not display_name:
raise ValueError("display_name is required")
db.update_role_name(role_uuid, display_name, ctx=ctx)
return {"status": "ok"}
@app.post("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
async def admin_add_role_permission(
org_uuid: UUID,
role_uuid: UUID,
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Add a permission to a role (intent-based API)."""
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
# Verify permission exists and org can grant it
perm = db.data().permissions.get(permission_uuid)
if not perm:
raise HTTPException(status_code=404, detail="Permission not found")
if org_uuid not in perm.orgs:
raise ValueError("Permission not grantable by organization")
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
async def admin_remove_role_permission(
org_uuid: UUID,
role_uuid: UUID,
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Remove a permission from a role (intent-based API)."""
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
# Sanity check: prevent admin from removing their own access
perm = db.data().permissions.get(permission_uuid)
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
# Check if removing this permission would leave no admin access
remaining_perms = role.permission_set - {permission_uuid}
has_admin = False
for rp_uuid in remaining_perms:
rp = db.data().permissions.get(rp_uuid)
if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
has_admin = True
break
if not has_admin:
raise ValueError("Cannot remove your own admin permissions")
db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
async def admin_delete_role(
org_uuid: UUID,
role_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
# Sanity check: prevent admin from deleting their own role
if ctx.role.uuid == role_uuid:
raise ValueError("Cannot delete your own role")
db.delete_role(role_uuid, ctx=ctx)
return {"status": "ok"}
# -------------------- Users --------------------
@app.post("/orgs/{org_uuid}/users")
async def admin_create_user(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name")
role_name = payload.get("role")
if not display_name or not role_name:
raise ValueError("display_name and role are required")
org = db.data().orgs[org_uuid]
role_obj = next(
(r for r in org.roles if r.display_name == role_name),
None,
)
if not role_obj:
raise ValueError("Role not found in organization")
user = UserDC.create(
display_name=display_name,
role=role_obj.uuid,
)
db.create_user(user, ctx=ctx)
return {"uuid": str(user.uuid)}
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/role")
async def admin_update_user_role(
org_uuid: UUID,
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
new_role = payload.get("role")
if not new_role:
raise ValueError("role is required")
try:
user_org, _current_role = db.get_user_organization(user_uuid)
except ValueError:
raise ValueError("User not found")
if user_org.uuid != org_uuid:
raise ValueError("User does not belong to this organization")
roles = user_org.roles
if not any(r.display_name == new_role for r in roles):
raise ValueError("Role not found in organization")
# Sanity check: prevent admin from removing their own access
if ctx.user.uuid == user_uuid:
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
if new_role_obj: # pragma: no branch - always true, role validated above
# Check if any permission in the new role is an admin permission
has_admin_access = False
for perm_uuid in new_role_obj.permissions:
perm = db.data().permissions.get(perm_uuid)
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
has_admin_access = True
break
if not has_admin_access:
raise ValueError(
"Cannot change your own role to one without admin permissions"
)
db.update_user_role_in_organization(user_uuid, new_role, ctx=ctx)
return {"status": "ok"}
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
async def admin_create_user_registration_link(
org_uuid: UUID,
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Check if user has existing credentials
has_credentials = db.get_user_credential_ids(user_uuid)
token_type = "user registration" if not has_credentials else "account recovery"
token = passphrase.generate()
expiry = reset_expires()
db.create_reset_token(
user_uuid=user_uuid,
passphrase=token,
expiry=expiry,
token_type=token_type,
ctx=ctx,
)
url = hostutil.reset_link_url(token)
return {
"url": url,
"expires": format_datetime(expiry),
}
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
async def admin_get_user_detail(
org_uuid: UUID,
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
user = db.data().users.get(user_uuid)
normalized_host = hostutil.normalize_host(request.headers.get("host"))
return MsgspecResponse(
{
"display_name": user.display_name,
"org": {"display_name": user_org.display_name},
"role": role_name,
"visits": user.visits,
"created_at": user.created_at,
"last_seen": user.last_seen,
"credentials": [
{
"credential": c.uuid,
"aaguid": c.aaguid,
"created_at": c.created_at,
"last_used": c.last_used,
"last_verified": c.last_verified,
"sign_count": c.sign_count,
}
for c in user.credentials
],
"aaguid_info": aaguid_mod.filter(c.aaguid for c in user.credentials),
"sessions": [
ApiSession.from_db(
s,
current_key=auth,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
],
}
)
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name")
async def admin_update_user_display_name(
org_uuid: UUID,
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
new_name = (payload.get("display_name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
if len(new_name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
db.update_user_display_name(user_uuid, new_name, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential(
org_uuid: UUID,
user_uuid: UUID,
credential_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}")
async def admin_delete_user_session(
org_uuid: UUID,
user_uuid: UUID,
session_id: str,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
target_session = db.data().sessions.get(session_id)
if not target_session or target_session.user_uuid != user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_id, ctx=ctx, action="admin:delete_session")
# Check if admin terminated their own session
current_terminated = session_id == auth
return {"status": "ok", "current_session_terminated": current_terminated}
# -------------------- Permissions (global) --------------------
def _validate_permission_domain(domain: str | None) -> None:
"""Validate that domain is rp_id or a subdomain of it."""
if domain is None:
return
rp_id = passkey.instance.rp_id
if domain == rp_id or domain.endswith(f".{rp_id}"):
return
raise ValueError(f"Domain '{domain}' must be '{rp_id}' or its subdomain")
def _check_admin_lockout(
perm_uuid: str, new_domain: str | None, current_host: str | None
) -> None:
"""Check if setting domain on auth:admin would lock out the admin.
Raises ValueError if this change would result in no auth:admin permissions
being accessible from the current host.
"""
normalized_host = normalize_host(current_host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
# Get all auth:admin permissions
all_perms = list(db.data().permissions.values())
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
# Check if at least one auth:admin would remain accessible
for p in admin_perms:
# If this is the permission being modified, use the new domain
domain = new_domain if str(p.uuid) == perm_uuid else p.domain
# No domain restriction = accessible from anywhere
if domain is None:
return
# Domain matches current host
if host_without_port and domain == host_without_port:
return
# No accessible auth:admin permission would remain
raise ValueError(
"Cannot set this domain restriction: it would lock you out of admin access. "
"Ensure at least one auth:admin permission remains accessible from your current host."
)
def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> None:
"""Check if deleting an auth:admin permission would lock out the admin.
Raises ValueError if this deletion would result in no auth:admin permissions
being accessible from the current host.
"""
normalized_host = normalize_host(current_host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
# Get all auth:admin permissions
all_perms = list(db.data().permissions.values())
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
# Check if at least one auth:admin would remain accessible after deletion
for p in admin_perms:
# Skip the permission being deleted
if str(p.uuid) == perm_uuid:
continue
# No domain restriction = accessible from anywhere
if p.domain is None:
return
# Domain matches current host
if host_without_port and p.domain == host_without_port:
return
# No accessible auth:admin permission would remain
raise ValueError(
"Cannot delete this permission: it would lock you out of admin access. "
"Ensure at least one auth:admin permission remains accessible from your current host."
)
@app.get("/permissions")
async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
return MsgspecResponse([ApiPermission.from_db(p) for p in perms])
@app.post("/permissions")
async def admin_create_permission(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
scope = payload.get("scope") or payload.get(
"id"
) # Support both for backwards compat
display_name = payload.get("display_name")
domain = payload.get("domain") or None # Treat empty string as None
if not scope or not display_name:
raise ValueError("scope and display_name are required")
querysafe.assert_safe(scope, field="scope")
_validate_permission_domain(domain)
db.create_permission(
PermDC.create(scope=scope, display_name=display_name, domain=domain),
ctx=ctx,
)
return {"status": "ok"}
@app.patch("/permission")
async def admin_update_permission(
request: Request,
auth=AUTH_COOKIE,
permission_uuid: UUID = Query(...),
display_name: str | None = Query(None),
scope: str | None = Query(None),
domain: str | None = Query(None),
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
# Get existing permission
perm = db.data().permissions.get(permission_uuid)
# Update fields that were provided
new_scope = scope if scope is not None else perm.scope
new_display_name = display_name if display_name is not None else perm.display_name
domain_value = domain if domain else None
# Sanity check: prevent changing the auth:admin permission scope
if perm.scope == "auth:admin" and new_scope != "auth:admin":
raise ValueError("Cannot rename the master admin permission")
if not new_display_name:
raise ValueError("display_name is required")
querysafe.assert_safe(new_scope, field="scope")
_validate_permission_domain(domain_value)
# Safety check: prevent admin lockout when setting domain on auth:admin
if perm.scope == "auth:admin" or new_scope == "auth:admin":
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
db.update_permission(
uuid=perm.uuid,
scope=new_scope,
display_name=new_display_name,
domain=domain_value,
ctx=ctx,
)
return {"status": "ok"}
@app.delete("/permission")
async def admin_delete_permission(
request: Request,
permission_uuid: UUID = Query(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
# Get the permission to check its scope
perm = db.data().permissions.get(permission_uuid)
# Sanity check: prevent deleting critical permissions if it would lock out admin
if perm.scope == "auth:admin":
_check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
db.delete_permission(permission_uuid, ctx=ctx)
return {"status": "ok"}

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