Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd2031eef5 | ||
|
|
3cb24bfee9 | ||
|
|
3f51d06f13 | ||
|
|
528a728eb8 | ||
|
|
727625ef4f | ||
|
|
5f7a5ed9b1 | ||
|
|
d64e63527b | ||
|
|
733439b446 | ||
|
|
4e6f63e9ef | ||
|
|
d431c75297 | ||
|
|
6257071efe | ||
|
|
7958b6f365 | ||
|
|
b3cb540098 | ||
|
|
22ba7231b1 | ||
|
|
9a9979fb62 | ||
|
|
9b7855c0af | ||
|
|
dfc4c76d43 | ||
|
|
e1f0fdf664 | ||
|
|
f26ac8f33b | ||
|
|
880ced3b8c | ||
|
|
af80b5eefc | ||
|
|
fa1e69d58b | ||
|
|
39000ef831 | ||
|
|
49119fac81 | ||
|
|
14aae74b60 | ||
|
|
68dccc1378 | ||
|
|
557ffaa0cd | ||
|
|
c0aba07326 | ||
|
|
f830d7d0ec | ||
|
|
7f52276c23 | ||
|
|
c1f8020f6b | ||
|
|
423abb0d1b | ||
|
|
ef7a6c8011 | ||
|
|
97064f7bc7 | ||
|
|
fc0541762e | ||
|
|
cebaa2a757 | ||
|
|
4ebe5ae968 | ||
|
|
a944224027 | ||
|
|
aef0e0cb44 | ||
|
|
d41cffc03e | ||
|
|
bad709a3ab | ||
|
|
1cfde06de9 | ||
|
|
b0b36e88b1 | ||
|
|
2237e6b5e9 | ||
|
|
c2ea01e6d9 | ||
|
|
8b6bdd0f9c | ||
|
|
3ca784dc3c | ||
|
|
d8876d9202 | ||
|
|
aa22b7709f | ||
|
|
a8222f4bba | ||
|
|
16ab111a89 | ||
|
|
d826146932 | ||
|
|
fda9b2545e | ||
|
|
0cf551cb28 | ||
|
|
632230d05c | ||
|
|
a37198bb4b | ||
|
|
fb71ea1220 | ||
|
|
25283eba9b | ||
|
|
6a217978d6 | ||
|
|
4d48c0720a | ||
|
|
083d20c8c9 | ||
|
|
c662fbd39c | ||
|
|
edf80aef52 | ||
|
|
8d5c53ab72 | ||
|
|
f8a760ddfd | ||
|
|
430394a1e5 | ||
|
|
4c54ad5d1f | ||
|
|
0b2be18b44 | ||
|
|
1c8be68811 | ||
|
|
116bc3c4ef | ||
|
|
c641c721c7 | ||
|
|
8fc03ade04 | ||
|
|
6ad3aa7d8c | ||
|
|
d8444b0db8 | ||
|
|
f515daeecd | ||
|
|
5deb57435b | ||
|
|
250189dbe5 | ||
|
|
3469e6fa3f | ||
|
|
c3df6c318c | ||
|
|
615066a2a2 | ||
|
|
70c682b539 | ||
|
|
8444d0399e | ||
|
|
3c5f8694b3 | ||
|
|
871eb149ab | ||
|
|
291a665e21 | ||
|
|
0537b85085 | ||
|
|
af5a48f565 | ||
|
|
632278d4ce | ||
|
|
3faaeee7be | ||
|
|
0f51f8501d | ||
|
|
a7e6eb7341 | ||
|
|
1800dc12ae | ||
|
|
cc55474e62 | ||
|
|
8ac2c8e5fa |
+1
-1
@@ -5,7 +5,7 @@ dist/
|
|||||||
*.lock
|
*.lock
|
||||||
package-lock.json
|
package-lock.json
|
||||||
paskia.sqlite
|
paskia.sqlite
|
||||||
paskia.jsonl
|
*.paskiadb
|
||||||
/paskia/frontend-build
|
/paskia/frontend-build
|
||||||
/paskia/_version.py
|
/paskia/_version.py
|
||||||
coverage-html/
|
coverage-html/
|
||||||
|
|||||||
@@ -1,60 +1,221 @@
|
|||||||
# Paskia
|
# Paskia
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
||||||
|
|
||||||
## What is Paskia?
|
## What is Paskia?
|
||||||
|
|
||||||
- Easy to use fully featured auth&auth system (login and permissions)
|
- Easy to use fully featured auth&auth system (login and permissions)
|
||||||
- Organization and role-based access control (optional)
|
- Organization and role-based access control
|
||||||
* Org admins control their users and roles
|
* Org admins control their users and roles
|
||||||
* Master admin can create multiple independent orgs
|
* Multiple independent orgs
|
||||||
* Master admin makes permissions available for orgs to assign
|
* Master admin can do everything or delegate to org admins
|
||||||
- User Profile and Administration by API and web interface.
|
- User Profile and Admin by API and web interface
|
||||||
under `/auth/` or `auth.example.com`
|
- Implements login/reauth/forbidden flows for you
|
||||||
- Reset tokens and additional device linking via QR code or codewords.
|
- Single Sign-On (SSO): Users register once and authenticate across your services
|
||||||
- Pure Python, FastAPI, packaged with prebuilt Vue frontend
|
- Remote autentication by entering random keywords from another device (like 2fa)
|
||||||
|
- No CORS, NodeJS or anything extra needed.
|
||||||
|
|
||||||
|
## Authenticate to get to your app, or in your app
|
||||||
|
|
||||||
Two interfaces:
|
|
||||||
- API fetch: auth checks and login without leaving your app
|
- API fetch: auth checks and login without leaving your app
|
||||||
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx)
|
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx)
|
||||||
|
|
||||||
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
|
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
|
||||||
|
|
||||||
Single Sign-On (SSO): Users register once and authenticate across all applications under your domain name (configured rp-id).
|
## Authentication flows already done
|
||||||
|
|
||||||
|

|
||||||
|
**Automatic light/dark mode switching with overrides by user profile and protected app's theme.**
|
||||||
|
|
||||||
|
Paskia includes set of login, reauthentication and forbidden dialogs that it can use to perform the needed flows. We never leave the URL, no redirections, and if you make use of API mode, we won't even interrupt whatever your app was doing but retry the blocked API fetch after login like nothing happened.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||||
|
|
||||||
```fish
|
```sh
|
||||||
uvx paskia serve --rp-id example.com
|
uvx paskia --rp-id example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
On the first run it downloads the software and prints a registration link for the Admin. The server will start up on [localhost:4401](http://localhost:4401) *for authentication required*, serving for `*.example.com`. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
|
On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`.
|
||||||
|
|
||||||
Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
|
For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
|
||||||
|
|
||||||
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
||||||
|
|
||||||
```fish
|
```sh
|
||||||
uv tool install paskia
|
uv tool install paskia
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
There is no config file. Pass only the options on CLI:
|
You will need to specify your main domain to which all passkeys will be tied as rp-id. Use your main domain even if Paskia is not running there. All other options are optional.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
paskia serve [options]
|
paskia [options]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** |
|
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
|
||||||
| --rp-id *domain* | Main/top domain | **localhost** |
|
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
|
||||||
| --rp-name *"text"* | Name of your company or site | Same as rp-id |
|
| --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id |
|
||||||
| --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ |
|
| --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains |
|
||||||
| --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.|
|
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
||||||
|
| --save | Save current options to database | (only --rp-id required on further invocations) |
|
||||||
|
|
||||||
|
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
||||||
|
|
||||||
|
## Tutorial: From Local Testing to Production
|
||||||
|
|
||||||
|
This section walks you through a complete example, from running Paskia locally to protecting a real site in production.
|
||||||
|
|
||||||
|
### Step 1: Production Configuration
|
||||||
|
|
||||||
|
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uvx paskia --rp-id=example.com --rp-name="Example Corp"
|
||||||
|
```
|
||||||
|
|
||||||
|
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). On the first run, you'll see a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name.
|
||||||
|
|
||||||
|
### Step 2: Set Up Caddy
|
||||||
|
|
||||||
|
Install [Caddy](https://caddyserver.com/) and copy the [auth folder](caddy/auth) to `/etc/caddy/auth`. Say your current unprotected Caddyfile looks like this:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
app.example.com {
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add Paskia full site protection:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
app.example.com {
|
||||||
|
import auth/setup
|
||||||
|
handle {
|
||||||
|
import auth/require perm=myapp:login
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `systemctl reload caddy`. Now `app.example.com` requires the `myapp:login` permission. Try accessing it and you'll land on a login dialog.
|
||||||
|
|
||||||
|
### Step 3: Assign Permissions via Admin Panel
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
1. Go to `app.example.com/auth/admin/`
|
||||||
|
2. Create a permission, give it a name and scope `myapp:login`
|
||||||
|
3. Assign it to Organization
|
||||||
|
4. In that organization, assign it to the Administration role
|
||||||
|
|
||||||
|
Now you have granted yourself the new permission.
|
||||||
|
|
||||||
|
Permission scopes are text identifiers with colons as separators that we can use for permission checks. The `myapp:` prefix is a convention to namespace permissions per application—you but you can use other forms as you see fit (urlsafe characters, no spaces allowed).
|
||||||
|
|
||||||
|
### Step 4: Add API Authentication to Your App
|
||||||
|
|
||||||
|
Your backend already receives `Remote-*` headers from Caddy's forward-auth. For frontend API calls, we provide a [JS paskia module](https://www.npmjs.com/package/paskia):
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { apiJson } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
|
||||||
|
|
||||||
|
const data = await apiJson('/api/sensitive', { method: 'POST' })
|
||||||
|
```
|
||||||
|
|
||||||
|
When a 401/403 occurs, the auth dialog appears automatically, and the request retries after authentication.
|
||||||
|
|
||||||
|
To protect the API path with a different permission, update your Caddyfile:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
app.example.com {
|
||||||
|
import auth/setup
|
||||||
|
|
||||||
|
@api path /api/*
|
||||||
|
handle @api {
|
||||||
|
import auth/require perm=myapp:api
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
import auth/require perm=myapp:login
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the `myapp:api` permission in the admin panel, that will be required for all API access. Link to `/auth/` for the built-in profile page.
|
||||||
|
|
||||||
|
You may also remove the `myapp:login` protection from the rest of your site paths, unless you wish to keep all your assets behind a login page. Having this as the last entry in your config allows free access to everything not matched by other sections.
|
||||||
|
|
||||||
|
```Caddyfile
|
||||||
|
handle {
|
||||||
|
reverse_proxy :3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Run Paskia as a Service
|
||||||
|
|
||||||
|
Create a system user paskia, install UV on the system, and create a systemd unit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo useradd --system --home-dir /srv/paskia --create-home paskia
|
||||||
|
```
|
||||||
|
|
||||||
|
Install UV on the system (or arch btw `pacman -S uv`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a systemd unit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl edit --force --full paskia@.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the following and save:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Paskia for %i
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=paskia
|
||||||
|
WorkingDirectory=/srv/paskia
|
||||||
|
ExecStart=uvx paskia@latest --rp-id=%i
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the service and view log:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl enable --now paskia@example.com && sudo journalctl -n30 -ocat -fu paskia@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: Dedicated Authentication Site
|
||||||
|
|
||||||
|
Add a Caddy configuration for the authentication domain:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
auth.example.com {
|
||||||
|
reverse_proxy :4401
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
|
||||||
|
|
||||||
|
Enter your auth site domain on Admin / Server Options panel or use `--auth-host=auth.example.com` when starting the server.
|
||||||
|
|
||||||
|
|
||||||
## Further Documentation
|
## Further Documentation
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -15,7 +15,7 @@ For integrating Paskia with your app frontend, see [integration](Integration.md)
|
|||||||
| Method | Path | Used for | Notes |
|
| Method | Path | Used for | Notes |
|
||||||
|---:|---|---|---|
|
|---:|---|---|---|
|
||||||
| GET | `/auth/api/settings` | Paskia configuration | Returns RP info + base paths + session cookie name |
|
| GET | `/auth/api/settings` | Paskia configuration | Returns RP info + base paths + session cookie name |
|
||||||
| POST | `/auth/api/user-info` | Full user profile | Basic information, credentials, sessions, permissions |
|
| GET | `/auth/api/user-info` | Full user profile | Basic information, credentials, sessions, permissions |
|
||||||
| POST | `/auth/api/logout` | Terminate session and delete session cookie | Signs out of the current site |
|
| POST | `/auth/api/logout` | Terminate session and delete session cookie | Signs out of the current site |
|
||||||
| POST | `/auth/api/validate` | Validate and renew session cookie | Optional query: `perm=` (repeatable), `max_age=` |
|
| POST | `/auth/api/validate` | Validate and renew session cookie | Optional query: `perm=` (repeatable), `max_age=` |
|
||||||
| GET | `/auth/api/forward` | Validate access (Caddy/Nginx) | 204 on success; 401/403 otherwise (HTML if requested) |
|
| GET | `/auth/api/forward` | Validate access (Caddy/Nginx) | 204 on success; 401/403 otherwise (HTML if requested) |
|
||||||
@@ -40,6 +40,38 @@ Normally only used via admin panel, requires auth admin permissions and can modi
|
|||||||
|
|
||||||
E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin `auth:admin` can see everything and create and manage orgs.
|
E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin `auth:admin` can see everything and create and manage orgs.
|
||||||
|
|
||||||
|
| Method | Path | Used for | Notes |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| GET | `/auth/api/admin/info` | Admin overview | Returns orgs, permissions, OIDC clients info |
|
||||||
|
| POST | `/auth/api/admin/permissions/` | Create permission | Body: JSON with scope, display_name, domain |
|
||||||
|
| PATCH | `/auth/api/admin/permissions/{uuid}` | Update permission | Query params: display_name, scope, domain |
|
||||||
|
| DELETE | `/auth/api/admin/permissions/{uuid}` | Delete permission | |
|
||||||
|
| POST | `/auth/api/admin/orgs/` | Create organization | Body: JSON with display_name, permissions |
|
||||||
|
| GET | `/auth/api/admin/orgs/{uuid}` | Get organization details | |
|
||||||
|
| PATCH | `/auth/api/admin/orgs/{uuid}` | Update organization | Body: JSON with display_name |
|
||||||
|
| DELETE | `/auth/api/admin/orgs/{uuid}` | Delete organization | |
|
||||||
|
| POST | `/auth/api/admin/orgs/{uuid}/users` | Create user in org | Body: JSON with display_name, role_uuid |
|
||||||
|
| POST | `/auth/api/admin/orgs/{uuid}/roles` | Create role in org | Body: JSON with display_name, permissions |
|
||||||
|
| POST | `/auth/api/admin/orgs/{uuid}/permission` | Grant permission to org | Query param: permission_uuid |
|
||||||
|
| DELETE | `/auth/api/admin/orgs/{uuid}/permission` | Revoke permission from org | Query param: permission_uuid |
|
||||||
|
| PATCH | `/auth/api/admin/roles/{uuid}` | Update role | Body: JSON with display_name |
|
||||||
|
| POST | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Add permission to role | |
|
||||||
|
| DELETE | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Remove permission from role | |
|
||||||
|
| DELETE | `/auth/api/admin/roles/{uuid}` | Delete role | |
|
||||||
|
| PATCH | `/auth/api/admin/users/{uuid}/role` | Update user role | Body: JSON with role_uuid |
|
||||||
|
| PATCH | `/auth/api/admin/users/{uuid}/info` | Update user info | Body: JSON with display_name |
|
||||||
|
| GET | `/auth/api/admin/users/{uuid}` | Get user details | |
|
||||||
|
| DELETE | `/auth/api/admin/users/{uuid}` | Delete user | |
|
||||||
|
| POST | `/auth/api/admin/users/{uuid}/create-link` | Create device add link | |
|
||||||
|
| DELETE | `/auth/api/admin/users/{uuid}/credentials/{uuid}` | Delete user credential | |
|
||||||
|
| DELETE | `/auth/api/admin/users/{uuid}/sessions/{key}` | Delete user session | |
|
||||||
|
| POST | `/auth/api/admin/oidc-clients/` | Create OIDC client | Body: JSON with client_name, redirect_uris |
|
||||||
|
| PATCH | `/auth/api/admin/oidc-clients/{uuid}` | Update OIDC client | Body: JSON with client_name, redirect_uris |
|
||||||
|
| PATCH | `/auth/api/admin/oidc-clients/{uuid}/reset-secret` | Reset client secret | |
|
||||||
|
| DELETE | `/auth/api/admin/oidc-clients/{uuid}` | Delete OIDC client | |
|
||||||
|
| GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins |
|
||||||
|
| PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins |
|
||||||
|
|
||||||
### WebSockets: `/auth/ws/*`
|
### WebSockets: `/auth/ws/*`
|
||||||
|
|
||||||
| Path | Used for | Notes |
|
| Path | Used for | Notes |
|
||||||
|
|||||||
+188
-33
@@ -1,43 +1,198 @@
|
|||||||
# Integrating Paskia with your App
|
# Integrating Paskia with your App
|
||||||
|
|
||||||
Protect API routes with forward-auth (see [Caddy configuration](Caddy.md)). Optionally protect your app assets and not just the API.
|
This guide covers frontend and backend integration with Paskia. For Caddy forward-auth setup, see [Caddy configuration](Caddy.md).
|
||||||
|
|
||||||
Catch response status 401/403 in fetch calls to protected endpoints and implement authentication flow in this case. The response is JSON and contains `detail` (an error message describing what is needed) and `auth.iframe` (a URL). Render that URL in an iframe and retry the request after authentication (see below).
|
## Frontend Integration
|
||||||
|
|
||||||
While the app is in (active) use, call `/auth/api/validate` occasionally to keep the session alive (session lifetime is 24h), otherwise the user will have to login every day. Max-age limits are unaffected by this and can be used on endpoints needing to reauthenticate with passkey more frequently.
|
### Using the paskia-js Module
|
||||||
|
|
||||||
Fetch `/auth/api/user-info` to display user/session details, or link to `/auth/` if you prefer using the built-in profile UI and not having to do anything more.
|
The [paskia](https://www.npmjs.com/package/paskia) JavaScript module provides utilities for API calls, session validation, and authentication overlays. Works with any framework or vanilla JS.
|
||||||
|
|
||||||
## Authentication Flow (iframe)
|
```html
|
||||||
|
<script type="module">
|
||||||
```js
|
import { apiJson, apiFetch, SessionValidator } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
|
||||||
// Show an authentication dialog
|
</script>
|
||||||
const iframe = document.createElement('iframe')
|
|
||||||
iframe.src = auth.url // from 401/403 response JSON
|
|
||||||
iframe.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
z-index: 9999;
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: blur(0.1rem) brightness(0.7);
|
|
||||||
`
|
|
||||||
document.body.appendChild(iframe)
|
|
||||||
|
|
||||||
// Wait until user is finished with the dialog
|
|
||||||
const handler = ev => {
|
|
||||||
if (ev.origin !== location.origin) return
|
|
||||||
iframe.remove()
|
|
||||||
removeEventListener('message', handler)
|
|
||||||
if (ev.data?.type === 'auth-success') retry_original_fetch()
|
|
||||||
}
|
|
||||||
addEventListener('message', handler)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This describes the frontend flow for handling 401/403 responses from endpoints protected by Paskia forward-auth, without ever exiting your app.
|
Or install to your project:
|
||||||
|
|
||||||
When a protected request fails, the backend returns 401 (needs auth / reauth) or 403 (missing permission). For API requests, the response is JSON that includes an iframe URL. Your app should render that URL in a full-screen iframe overlay, and retry the request after the iframe reports success. If it reports `auth-cancel`, don't try again. The backdrop for the dialog is a stylistic choice, and you can style the background shown with the dialog any way you wish, and consider using CSS file with the iframe rather than inline styles as used in the example.
|
```sh
|
||||||
|
npm install paskia
|
||||||
|
```
|
||||||
|
|
||||||
Following this flow the user gets authenticated properly and after that your app keeps running as if nothing ever happened.
|
### API Fetch with Automatic Auth
|
||||||
|
|
||||||
|
Use `apiJson` or `apiFetch` for API calls. When a 401/403 response includes an auth URL, the authentication dialog appears automatically, then the request retries. The JSON variant is purely for convenience, doing JSON headers and conversions for you.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
|
||||||
|
|
||||||
|
// JSON API call (sets Content-Type, parses response)
|
||||||
|
try {
|
||||||
|
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
// User cancelled auth dialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw fetch with auth handling (returns Response object)
|
||||||
|
const response = await apiFetch('/api/endpoint')
|
||||||
|
```
|
||||||
|
|
||||||
|
For requests that shouldn't trigger auth dialogs, use standard `fetch` or our `fetchJson`.
|
||||||
|
|
||||||
|
### Session Validation Polling
|
||||||
|
|
||||||
|
Keep sessions alive and detect when the user logs out or switches accounts:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { SessionValidator } from 'paskia'
|
||||||
|
|
||||||
|
const validator = new SessionValidator(
|
||||||
|
() => currentUser?.uuid, // getter for current user ID
|
||||||
|
(error) => handleSessionLost(error) // callback when session is lost or user changes
|
||||||
|
)
|
||||||
|
|
||||||
|
validator.start() // start polling (pauses on idle)
|
||||||
|
validator.stop() // stop polling
|
||||||
|
```
|
||||||
|
|
||||||
|
The validator calls `/auth/api/validate` periodically to:
|
||||||
|
- Renew the session cookie (24h lifetime)
|
||||||
|
- Detect if the user logged out or switched accounts
|
||||||
|
- Pause polling when the page is idle, allowing sessions to expire when not used
|
||||||
|
|
||||||
|
### Manual Auth Flow
|
||||||
|
|
||||||
|
If you need custom control, handle 401/403 responses manually:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { showAuthIframe, AuthCancelledError } from 'paskia'
|
||||||
|
|
||||||
|
const response = await fetch('/api/protected')
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.auth?.iframe) {
|
||||||
|
try {
|
||||||
|
await showAuthIframe(data.auth.iframe)
|
||||||
|
// Retry the original request
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
// User clicked Back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Info and Profile
|
||||||
|
|
||||||
|
Get current user details:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const user = await apiJson('/auth/api/user-info', { method: 'GET' })
|
||||||
|
// Returns: { uuid, display_name, credentials, sessions, permissions, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Or link to the built-in profile page: `/auth/`
|
||||||
|
|
||||||
|
## Backend Integration
|
||||||
|
|
||||||
|
### Using Forward-Auth Headers
|
||||||
|
|
||||||
|
When using Caddy forward-auth, your backend receives `Remote-*` headers on authenticated requests. See [Headers](Headers.md) for the full list.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Example: Python/FastAPI
|
||||||
|
@app.get("/api/data")
|
||||||
|
def get_data(request: Request):
|
||||||
|
user_id = request.headers.get("Remote-User")
|
||||||
|
org_id = request.headers.get("Remote-Org")
|
||||||
|
permissions = request.headers.get("Remote-Groups", "").split(",")
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Validation from Backend
|
||||||
|
|
||||||
|
Your backend can validate sessions directly by calling Paskia's validate endpoint:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async def validate_session(request) -> dict:
|
||||||
|
"""Validate a session cookie and check permissions."""
|
||||||
|
authcookie = request.get("__Host-paskia")
|
||||||
|
response = await httpx.post(
|
||||||
|
"http://localhost:4401/auth/api/validate?perm=myapp:login+myapp:api",
|
||||||
|
headers={
|
||||||
|
"Host": request.headers["host"]
|
||||||
|
"X-Forwarded-For": request.client.host,
|
||||||
|
"Cookie": f"__Host-paskia={}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return response.json() # Return to client
|
||||||
|
# User authenticated... We are good to go!
|
||||||
|
ctx = response.json() # User and session information
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful for:
|
||||||
|
- WebSocket connections where headers aren't available after handshake
|
||||||
|
- Background jobs that need to verify a stored session
|
||||||
|
- APIs not behind forward-auth (auth/restrict)
|
||||||
|
|
||||||
|
### Validate Endpoint Parameters
|
||||||
|
|
||||||
|
`POST /auth/api/validate` accepts query parameters:
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `perm=scope:name` | Require this permission (repeatable) |
|
||||||
|
| `max_age=5min` | Require recent passkey use |
|
||||||
|
|
||||||
|
Returns 200 with user info on success, 401/403 on failure.
|
||||||
|
|
||||||
|
## Proxying /auth/ to Paskia
|
||||||
|
|
||||||
|
Your app server needs to proxy `/auth/` paths to Paskia. This can be done by your application but is much easier done by Caddy or Nginx.
|
||||||
|
|
||||||
|
### Caddy
|
||||||
|
|
||||||
|
This handles both HTTP and WebSocket connections. Caddy's `reverse_proxy` handles HTTP and WebSocket transparently. This is essentially what our Caddy [auth/setup](../caddy/auth/setup) snippet does: `reverse_proxy :4401`.
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
app.example.com {
|
||||||
|
import auth/setup
|
||||||
|
# ... your routes in handle blocks
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx
|
||||||
|
|
||||||
|
Certain headers need to be configured for correct host and WS support:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /auth/ {
|
||||||
|
proxy_pass http://localhost:4401;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js / Express
|
||||||
|
|
||||||
|
Using `http-proxy-middleware`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createProxyMiddleware } from 'http-proxy-middleware'
|
||||||
|
|
||||||
|
app.use('/auth', createProxyMiddleware({ target: 'http://localhost:4401', ws: true, changeOrigin: false }))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python / FastAPI
|
||||||
|
|
||||||
|
You will need to process and handle `/auth/` for HTTP requests and `/auth/ws/` for WebSockets manually, which is beyond the scope of this documentation.
|
||||||
|
|
||||||
|
We highly recommend Caddy instead as the simpler and more production-worthy solution that Just Works.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
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 |
Generated
+38
-26
@@ -10,7 +10,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@simplewebauthn/browser": "^13.1.2",
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
"@types/bun": "^1.3.3",
|
"@types/node": "*",
|
||||||
"c8": "^10.1.3"
|
"c8": "^10.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -92,11 +92,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.57.0",
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.57.0"
|
"playwright": "1.58.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -107,17 +109,11 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@simplewebauthn/browser": {
|
"node_modules/@simplewebauthn/browser": {
|
||||||
"version": "13.2.2",
|
"version": "13.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
|
||||||
|
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/bun": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bun-types": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -126,7 +122,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.1",
|
"version": "25.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
|
||||||
|
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -176,14 +174,6 @@
|
|||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bun-types": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/c8": {
|
"node_modules/c8": {
|
||||||
"version": "10.1.3",
|
"version": "10.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
|
||||||
@@ -412,6 +402,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-caller-file": {
|
"node_modules/get-caller-file": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
@@ -426,6 +431,7 @@
|
|||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
|
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -674,11 +680,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.57.0",
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.57.0"
|
"playwright-core": "1.58.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -691,7 +699,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.57.0",
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -712,9 +722,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -894,6 +904,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|||||||
+8
-8
@@ -5,18 +5,18 @@
|
|||||||
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
|
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bunx playwright test",
|
"test": "npx playwright test",
|
||||||
"test:headed": "bunx playwright test --headed",
|
"test:headed": "npx playwright test --headed",
|
||||||
"test:debug": "bunx playwright test --debug",
|
"test:debug": "npx playwright test --debug",
|
||||||
"test:ui": "bunx playwright test --ui",
|
"test:ui": "npx playwright test --ui",
|
||||||
"test:coverage": "COVERAGE=1 bunx playwright test",
|
"test:coverage": "COVERAGE=1 npx playwright test",
|
||||||
"report": "bunx playwright show-report",
|
"report": "npx playwright show-report",
|
||||||
"install:browsers": "bunx playwright install chromium"
|
"install:browsers": "npx playwright install chromium"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@simplewebauthn/browser": "^13.1.2",
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
"@types/bun": "^1.3.3",
|
"@types/node": "*",
|
||||||
"c8": "^10.1.3"
|
"c8": "^10.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test'
|
|||||||
* Playwright configuration for Paskia E2E tests.
|
* Playwright configuration for Paskia E2E tests.
|
||||||
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
||||||
*
|
*
|
||||||
* Run with: bun run test
|
* Run with: npm test
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -148,10 +148,10 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
|
|
||||||
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
|
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
|
||||||
|
|
||||||
expect(userInfo.ctx.user.uuid).toBe(userUuid)
|
expect(userInfo.user.uuid).toBe(userUuid)
|
||||||
expect(userInfo.ctx.user.display_name).toBe('Admin User')
|
expect(userInfo.user.display_name).toBe('Admin User')
|
||||||
expect(userInfo.credentials).toBeDefined()
|
expect(userInfo.credentials).toBeDefined()
|
||||||
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
|
expect(Object.keys(userInfo.credentials).length).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
// Navigate to profile and take screenshot
|
// Navigate to profile and take screenshot
|
||||||
const cookieName = getSessionCookieName()
|
const cookieName = getSessionCookieName()
|
||||||
@@ -169,8 +169,8 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
await page.screenshot({ path: 'test-results/profile-view.png' })
|
await page.screenshot({ path: 'test-results/profile-view.png' })
|
||||||
console.log('✓ Screenshot saved: test-results/profile-view.png')
|
console.log('✓ Screenshot saved: test-results/profile-view.png')
|
||||||
|
|
||||||
console.log(`✓ User info retrieved: ${userInfo.ctx.user.display_name}`)
|
console.log(`✓ User info retrieved: ${userInfo.user.display_name}`)
|
||||||
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
|
console.log(`✓ Credentials count: ${Object.keys(userInfo.credentials).length}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
|
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
logout,
|
logout,
|
||||||
} from './fixtures/passkey-helpers'
|
} from './fixtures/passkey-helpers'
|
||||||
import type { Page, Frame } from '@playwright/test'
|
import type { Page, Frame } from '@playwright/test'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E tests for API mode authentication flows.
|
* E2E tests for API mode authentication flows.
|
||||||
@@ -55,8 +61,18 @@ async function clearSessionCookie(page: Page): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Set up the test page using the examples page directly.
|
* Set up the test page using the examples page directly.
|
||||||
* The examples page already has iframe handling - we just add a Promise wrapper.
|
* The examples page already has iframe handling - we just add a Promise wrapper.
|
||||||
|
* We route the paskia-js module request to serve from the local dist.
|
||||||
*/
|
*/
|
||||||
async function setupTestHarness(page: Page): Promise<void> {
|
async function setupTestHarness(page: Page): Promise<void> {
|
||||||
|
// Serve paskia.js from the local filesystem since the server doesn't serve /paskia-js/
|
||||||
|
const paskiaJsPath = join(__dirname, '..', '..', 'paskia-js', 'dist', 'paskia.js')
|
||||||
|
await page.route('**/paskia-js/dist/paskia.js', async route => {
|
||||||
|
const body = readFileSync(paskiaJsPath, 'utf-8')
|
||||||
|
await route.fulfill({
|
||||||
|
body,
|
||||||
|
contentType: 'application/javascript',
|
||||||
|
})
|
||||||
|
})
|
||||||
// Navigate to the examples page which already has the auth iframe handling
|
// Navigate to the examples page which already has the auth iframe handling
|
||||||
await page.goto(`${baseUrl}/auth/examples/`)
|
await page.goto(`${baseUrl}/auth/examples/`)
|
||||||
}
|
}
|
||||||
@@ -143,8 +159,8 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s
|
|||||||
* Wait for auth iframe to appear and return a reference to it.
|
* Wait for auth iframe to appear and return a reference to it.
|
||||||
*/
|
*/
|
||||||
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
|
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
|
||||||
await page.waitForSelector('#auth-iframe', { timeout })
|
await page.waitForSelector('#paskia-iframe', { timeout })
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
// Wait for iframe content to load
|
// Wait for iframe content to load
|
||||||
await iframe.locator('.view-root').waitFor({ timeout })
|
await iframe.locator('.view-root').waitFor({ timeout })
|
||||||
return page.frame({ url: /\/auth\/restricted\// })!
|
return page.frame({ url: /\/auth\/restricted\// })!
|
||||||
@@ -154,14 +170,14 @@ async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
|
|||||||
* Wait for auth iframe to disappear.
|
* Wait for auth iframe to disappear.
|
||||||
*/
|
*/
|
||||||
async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> {
|
async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> {
|
||||||
await page.waitForSelector('#auth-iframe', { state: 'detached', timeout })
|
await page.waitForSelector('#paskia-iframe', { state: 'detached', timeout })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click Back button in auth iframe.
|
* Click Back button in auth iframe.
|
||||||
*/
|
*/
|
||||||
async function clickBackInIframe(page: Page): Promise<void> {
|
async function clickBackInIframe(page: Page): Promise<void> {
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await iframe.getByRole('button', { name: 'Back' }).click()
|
await iframe.getByRole('button', { name: 'Back' }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +185,7 @@ async function clickBackInIframe(page: Page): Promise<void> {
|
|||||||
* Click Login button in auth iframe.
|
* Click Login button in auth iframe.
|
||||||
*/
|
*/
|
||||||
async function clickLoginInIframe(page: Page): Promise<void> {
|
async function clickLoginInIframe(page: Page): Promise<void> {
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await iframe.getByRole('button', { name: 'Login' }).click()
|
await iframe.getByRole('button', { name: 'Login' }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +193,7 @@ async function clickLoginInIframe(page: Page): Promise<void> {
|
|||||||
* Click Verify button in auth iframe (for reauth mode).
|
* Click Verify button in auth iframe (for reauth mode).
|
||||||
*/
|
*/
|
||||||
async function clickVerifyInIframe(page: Page): Promise<void> {
|
async function clickVerifyInIframe(page: Page): Promise<void> {
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await iframe.getByRole('button', { name: 'Verify' }).click()
|
await iframe.getByRole('button', { name: 'Verify' }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +201,7 @@ async function clickVerifyInIframe(page: Page): Promise<void> {
|
|||||||
* Click Logout button in auth iframe (for forbidden mode).
|
* Click Logout button in auth iframe (for forbidden mode).
|
||||||
*/
|
*/
|
||||||
async function clickLogoutInIframe(page: Page): Promise<void> {
|
async function clickLogoutInIframe(page: Page): Promise<void> {
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await iframe.getByRole('button', { name: 'Logout' }).click()
|
await iframe.getByRole('button', { name: 'Logout' }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +216,11 @@ test.describe('API Mode - 401 Login Flow', () => {
|
|||||||
await clearSessionCookie(page)
|
await clearSessionCookie(page)
|
||||||
|
|
||||||
// Make API call that triggers 401 (don't await - it blocks until iframe resolves)
|
// Make API call that triggers 401 (don't await - it blocks until iframe resolves)
|
||||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST').catch(e => e)
|
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET').catch(e => e)
|
||||||
console.log('✓ Auth iframe appeared on 401')
|
console.log('✓ Auth iframe appeared on 401')
|
||||||
|
|
||||||
// Verify it's in login mode (not reauth)
|
// Verify it's in login mode (not reauth)
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await expect(iframe.locator('h1')).toContainText('🔐')
|
await expect(iframe.locator('h1')).toContainText('🔐')
|
||||||
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible()
|
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible()
|
||||||
|
|
||||||
@@ -252,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
|||||||
await setupTestHarness(page)
|
await setupTestHarness(page)
|
||||||
|
|
||||||
// Make API call that triggers 401
|
// Make API call that triggers 401
|
||||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST')
|
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET')
|
||||||
|
|
||||||
// Wait for auth iframe to appear
|
// Wait for auth iframe to appear
|
||||||
await waitForAuthIframe(page)
|
await waitForAuthIframe(page)
|
||||||
@@ -268,7 +284,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
|||||||
// Wait for API call to complete and verify result
|
// Wait for API call to complete and verify result
|
||||||
const result = await apiCallPromise
|
const result = await apiCallPromise
|
||||||
expect(result.status).toBe(200)
|
expect(result.status).toBe(200)
|
||||||
expect(result.data.ctx).toBeDefined()
|
expect(result.data.user).toBeDefined()
|
||||||
console.log('✓ API call succeeded after authentication')
|
console.log('✓ API call succeeded after authentication')
|
||||||
|
|
||||||
// Save the session for other tests
|
// Save the session for other tests
|
||||||
@@ -314,7 +330,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
|
|||||||
console.log('✓ Reauth iframe appeared (session older than max_age)')
|
console.log('✓ Reauth iframe appeared (session older than max_age)')
|
||||||
|
|
||||||
// Verify it's in reauth mode
|
// Verify it's in reauth mode
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
|
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
|
||||||
await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible()
|
await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible()
|
||||||
|
|
||||||
@@ -362,7 +378,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
|
|||||||
await waitForAuthIframe(page)
|
await waitForAuthIframe(page)
|
||||||
console.log('✓ Reauth iframe appeared')
|
console.log('✓ Reauth iframe appeared')
|
||||||
|
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
|
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
|
||||||
|
|
||||||
// Click Verify - virtual authenticator handles passkey
|
// Click Verify - virtual authenticator handles passkey
|
||||||
@@ -394,7 +410,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
|
|||||||
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
|
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
|
||||||
|
|
||||||
// Check if auth iframe appeared
|
// Check if auth iframe appeared
|
||||||
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
|
const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
|
||||||
|
|
||||||
if (!iframeAppeared) {
|
if (!iframeAppeared) {
|
||||||
// User might already have admin permission
|
// User might already have admin permission
|
||||||
@@ -410,7 +426,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
|
|||||||
|
|
||||||
// Wait for view to stabilize and check mode
|
// Wait for view to stabilize and check mode
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
const headingText = await iframe.locator('h1').textContent()
|
const headingText = await iframe.locator('h1').textContent()
|
||||||
console.log(` Heading: ${headingText}`)
|
console.log(` Heading: ${headingText}`)
|
||||||
|
|
||||||
@@ -459,7 +475,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
|
|||||||
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
|
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
|
||||||
|
|
||||||
// Check if auth iframe appeared
|
// Check if auth iframe appeared
|
||||||
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
|
const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
|
||||||
|
|
||||||
if (!iframeAppeared) {
|
if (!iframeAppeared) {
|
||||||
const result = await apiCallPromise
|
const result = await apiCallPromise
|
||||||
@@ -470,7 +486,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await waitForAuthIframe(page)
|
await waitForAuthIframe(page)
|
||||||
const iframe = page.frameLocator('#auth-iframe')
|
const iframe = page.frameLocator('#paskia-iframe')
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
const headingText = await iframe.locator('h1').textContent()
|
const headingText = await iframe.locator('h1').textContent()
|
||||||
@@ -534,7 +550,7 @@ test.describe('API Mode - Direct API Response Format', () => {
|
|||||||
expect(data.auth).toBeDefined()
|
expect(data.auth).toBeDefined()
|
||||||
expect(data.auth.iframe).toBeDefined()
|
expect(data.auth.iframe).toBeDefined()
|
||||||
expect(data.auth.mode).toBe('login')
|
expect(data.auth.mode).toBe('login')
|
||||||
expect(data.auth.iframe).toContain('/auth/restricted/')
|
expect(data.auth.iframe).toContain('/auth/restricted/iframe')
|
||||||
|
|
||||||
console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`)
|
console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`)
|
||||||
})
|
})
|
||||||
|
|||||||
+62
-12
@@ -44,7 +44,7 @@ export interface UserInfo {
|
|||||||
sign_count: number
|
sign_count: number
|
||||||
is_current_session: boolean
|
is_current_session: boolean
|
||||||
}>
|
}>
|
||||||
aaguid_info: Record<string, { name: string; icon_light?: string; icon_dark?: string }>
|
aaguid_info: Record<string, { name: string; icon?: string; icon_dark?: string }>
|
||||||
sessions: Array<{
|
sessions: Array<{
|
||||||
id: string
|
id: string
|
||||||
credential: string
|
credential: string
|
||||||
@@ -193,7 +193,8 @@ export async function registerPasskey(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
options: { resetToken?: string; displayName?: string } = {}
|
options: { resetToken?: string; displayName?: string } = {}
|
||||||
): Promise<RegistrationResult> {
|
): Promise<RegistrationResult> {
|
||||||
return await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
|
// Step 1: Do WebSocket registration + exchange code in browser context
|
||||||
|
const wsResult = await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
|
||||||
// Build WebSocket URL with query parameters
|
// Build WebSocket URL with query parameters
|
||||||
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
|
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
|
||||||
const params: string[] = []
|
const params: string[] = []
|
||||||
@@ -203,6 +204,7 @@ export async function registerPasskey(
|
|||||||
|
|
||||||
return new Promise<any>((resolve, reject) => {
|
return new Promise<any>((resolve, reject) => {
|
||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl)
|
||||||
|
let done = false
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('WebSocket connected for registration')
|
console.log('WebSocket connected for registration')
|
||||||
@@ -213,15 +215,31 @@ export async function registerPasskey(
|
|||||||
|
|
||||||
// Check for error response
|
// Check for error response
|
||||||
if (data.detail) {
|
if (data.detail) {
|
||||||
|
done = true
|
||||||
ws.close()
|
ws.close()
|
||||||
reject(new Error(data.detail))
|
reject(new Error(data.detail))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is the final success response
|
// Check if this is the final success response (exchange_code flow)
|
||||||
if (data.session_token) {
|
if (data.exchange_code) {
|
||||||
|
done = true
|
||||||
ws.close()
|
ws.close()
|
||||||
resolve(data)
|
// Exchange the code for a session cookie
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
|
||||||
|
resolve({
|
||||||
|
user: data.user,
|
||||||
|
credential: data.credential,
|
||||||
|
message: data.message || 'Registration successful',
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
reject(new Error(`Code exchange failed: ${err.message}`))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,12 +311,21 @@ export async function registerPasskey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
if (!event.wasClean && event.code !== 1000) {
|
if (!done && !event.wasClean && event.code !== 1000) {
|
||||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
|
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
|
||||||
|
|
||||||
|
// Step 2: Extract the session token from the cookie set by the exchange
|
||||||
|
const cookies = await page.context().cookies()
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
|
const sessionCookie = cookies.find(c => c.name === cookieName)
|
||||||
|
return {
|
||||||
|
...wsResult,
|
||||||
|
session_token: sessionCookie?.value || '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -309,11 +336,13 @@ export async function authenticatePasskey(
|
|||||||
page: Page,
|
page: Page,
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
return await page.evaluate(async ({ baseUrl }) => {
|
// Step 1: Do WebSocket authentication + exchange code in browser context
|
||||||
|
const wsResult = await page.evaluate(async ({ baseUrl }) => {
|
||||||
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate`
|
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate`
|
||||||
|
|
||||||
return new Promise<any>((resolve, reject) => {
|
return new Promise<any>((resolve, reject) => {
|
||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl)
|
||||||
|
let done = false
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('WebSocket connected for authentication')
|
console.log('WebSocket connected for authentication')
|
||||||
@@ -324,15 +353,27 @@ export async function authenticatePasskey(
|
|||||||
|
|
||||||
// Check for error response
|
// Check for error response
|
||||||
if (data.detail) {
|
if (data.detail) {
|
||||||
|
done = true
|
||||||
ws.close()
|
ws.close()
|
||||||
reject(new Error(data.detail))
|
reject(new Error(data.detail))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is the final success response
|
// Check if this is the final success response (exchange_code flow)
|
||||||
if (data.session_token) {
|
if (data.exchange_code) {
|
||||||
|
done = true
|
||||||
ws.close()
|
ws.close()
|
||||||
resolve(data)
|
// Exchange the code for a session cookie
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
|
||||||
|
resolve({ user: data.user })
|
||||||
|
} catch (err: any) {
|
||||||
|
reject(new Error(`Code exchange failed: ${err.message}`))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,12 +436,21 @@ export async function authenticatePasskey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
if (!event.wasClean && event.code !== 1000) {
|
if (!done && !event.wasClean && event.code !== 1000) {
|
||||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, { baseUrl })
|
}, { baseUrl })
|
||||||
|
|
||||||
|
// Step 2: Extract the session token from the cookie set by the exchange
|
||||||
|
const cookies = await page.context().cookies()
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
|
const sessionCookie = cookies.find(c => c.name === cookieName)
|
||||||
|
return {
|
||||||
|
...wsResult,
|
||||||
|
session_token: sessionCookie?.value || '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -429,7 +479,7 @@ export async function getUserInfo(
|
|||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<UserInfo> {
|
): Promise<UserInfo> {
|
||||||
const cookieName = getSessionCookieName()
|
const cookieName = getSessionCookieName()
|
||||||
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
const response = await page.request.get(`${baseUrl}/auth/api/user-info`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': `${cookieName}=${sessionToken}`,
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawn } from 'child_process'
|
import { execSync, spawn } from 'child_process'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -31,6 +31,11 @@ export default async function globalSetup() {
|
|||||||
mkdirSync(testDataDir, { recursive: true })
|
mkdirSync(testDataDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the package first
|
||||||
|
console.log(' Building package with uv build...')
|
||||||
|
execSync('uv build', { cwd: projectRoot, stdio: 'inherit' })
|
||||||
|
console.log(' ✅ Build complete\n')
|
||||||
|
|
||||||
console.log(' Starting server with in-memory database...')
|
console.log(' Starting server with in-memory database...')
|
||||||
if (COLLECT_COVERAGE) {
|
if (COLLECT_COVERAGE) {
|
||||||
console.log(' 📊 Coverage collection enabled for Python backend')
|
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||||
@@ -42,16 +47,16 @@ export default async function globalSetup() {
|
|||||||
const serverArgs = COLLECT_COVERAGE
|
const serverArgs = COLLECT_COVERAGE
|
||||||
? [
|
? [
|
||||||
'run', 'coverage', 'run', '--parallel-mode',
|
'run', 'coverage', 'run', '--parallel-mode',
|
||||||
'-m', 'paskia.fastapi', 'localhost:4404',
|
'-m', 'paskia', '-l', 'localhost:4404',
|
||||||
'--rp-id', 'localhost'
|
'--rp-id', 'localhost'
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'run', 'paskia', 'localhost:4404',
|
'run', 'paskia', '-l', 'localhost:4404',
|
||||||
'--rp-id', 'localhost'
|
'--rp-id', 'localhost'
|
||||||
]
|
]
|
||||||
|
|
||||||
// Use a temporary jsonl file for test database
|
// Use a fresh database file for tests
|
||||||
const testDbFile = join(testDataDir, 'test-db.jsonl')
|
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||||
|
|
||||||
// Start the server using Node's spawn
|
// Start the server using Node's spawn
|
||||||
const serverProcess = spawn('uv', serverArgs, {
|
const serverProcess = spawn('uv', serverArgs, {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default async function globalTeardown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean up test database
|
// Clean up test database
|
||||||
const testDbFile = join(testDataDir, 'test-db.jsonl')
|
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||||
if (existsSync(testDbFile)) {
|
if (existsSync(testDbFile)) {
|
||||||
console.log(' Removing test database...')
|
console.log(' Removing test database...')
|
||||||
rmSync(testDbFile)
|
rmSync(testDbFile)
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["bun-types"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["tests/**/*.ts", "playwright.config.ts"],
|
"include": ["tests/**/*.ts", "playwright.config.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
+38
-107
@@ -8,26 +8,6 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light dark; /* Automatic themes by browser */
|
color-scheme: light dark; /* Automatic themes by browser */
|
||||||
}
|
}
|
||||||
/* Login/reauth/forbidden dialog will appear in this iframe */
|
|
||||||
#auth-iframe {
|
|
||||||
/* Full viewport overlay */
|
|
||||||
border: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 9999;
|
|
||||||
/* Optional transparent background with optional blur backdrop */
|
|
||||||
color-scheme: auto;
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
}
|
|
||||||
/* Prevent background scroll when auth-iframe is shown */
|
|
||||||
body:has(#auth-iframe) {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -47,7 +27,7 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>API Mode (not leaving the page)</h2>
|
<h2>API Mode (not leaving the page)</h2>
|
||||||
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
|
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
|
||||||
<button onclick="apiCall('/auth/api/user-info', 'POST')">📋 Get User Info</button>
|
<button onclick="apiCall('/auth/api/user-info', 'GET')">📋 Get User Info</button>
|
||||||
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
||||||
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
||||||
<button onclick="logout()">🚪 Logout</button>
|
<button onclick="logout()">🚪 Logout</button>
|
||||||
@@ -65,99 +45,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script type="module">
|
||||||
|
import { apiFetch, apiJson, AuthCancelledError } from '/paskia-js/dist/paskia.js'
|
||||||
|
|
||||||
const output = document.getElementById('output');
|
const output = document.getElementById('output');
|
||||||
let pendingCall = null; // Stores the API call to retry after auth
|
|
||||||
|
|
||||||
// The auth iframe posts messages when authentication completes or is cancelled.
|
|
||||||
// Message types: 'auth-success' (proceed), 'auth-back' (user cancelled)
|
|
||||||
// Errors during auth stay in the dialog allowing retry, no message is sent.
|
|
||||||
window.addEventListener('message', (event) => {
|
|
||||||
const { type, message } = event.data || {};
|
|
||||||
|
|
||||||
if (type === 'auth-success') {
|
|
||||||
log('✓ Authentication successful, retrying...');
|
|
||||||
hideAuthIframe();
|
|
||||||
// Retry the original API call that triggered authentication
|
|
||||||
if (pendingCall) {
|
|
||||||
const { url, method } = pendingCall;
|
|
||||||
pendingCall = null;
|
|
||||||
apiCall(url, method);
|
|
||||||
}
|
|
||||||
} else if (type === 'auth-back') {
|
|
||||||
log(message || 'Authentication cancelled');
|
|
||||||
hideAuthIframe();
|
|
||||||
pendingCall = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make an API call, handling 401/403 by showing the auth iframe.
|
|
||||||
// The server returns JSON with auth.iframe URL when authentication is needed.
|
|
||||||
async function apiCall(url, method = 'GET') {
|
|
||||||
log(`${method} ${url}...`);
|
|
||||||
|
|
||||||
const response = await fetch(url, { method });
|
|
||||||
|
|
||||||
// Server returns 401 (login/reauth) or 403 (missing permissions)
|
|
||||||
// with a JSON body containing the iframe URL for authentication
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.auth?.iframe) {
|
|
||||||
const mode = data.auth.mode; // 'login' or 'reauth'
|
|
||||||
log(`${mode === 'reauth' ? 'Re-authentication' : 'Authentication'} required...`);
|
|
||||||
pendingCall = { url, method };
|
|
||||||
showAuthIframe(data.auth.iframe);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log(`Error: ${response.status} - ${data.detail}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward endpoint returns 204 on success (Caddy then adds Remote-* headers)
|
|
||||||
if (response.status === 204) {
|
|
||||||
log('✓ Success (204 No Content)\nHeaders:\n' +
|
|
||||||
[...response.headers].filter(([k]) => k.startsWith('remote-'))
|
|
||||||
.map(([k, v]) => ` ${k}: ${v}`).join('\n'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
log(`Error: ${response.status} ${response.statusText}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
log('✓ Response:\n' + JSON.stringify(data, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await fetch('/auth/api/logout', { method: 'POST' });
|
|
||||||
log('Logged out');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create fullscreen iframe for authentication.
|
|
||||||
// The 'allow' attribute enables WebAuthn (passkey) API inside the iframe.
|
|
||||||
function showAuthIframe(url) {
|
|
||||||
hideAuthIframe();
|
|
||||||
const iframe = document.createElement('iframe');
|
|
||||||
iframe.id = 'auth-iframe';
|
|
||||||
iframe.src = url;
|
|
||||||
document.body.appendChild(iframe);
|
|
||||||
log("Authentication dialog open...")
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideAuthIframe() {
|
|
||||||
document.getElementById('auth-iframe')?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function log(msg) {
|
function log(msg) {
|
||||||
output.textContent = msg;
|
output.textContent = msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make an API call using paskia module (handles 401/403 automatically)
|
||||||
|
window.apiCall = async function(url, method = 'GET') {
|
||||||
|
log(`${method} ${url}...`);
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(url, { method });
|
||||||
|
|
||||||
|
// Forward endpoint returns 204 on success
|
||||||
|
if (response.status === 204) {
|
||||||
|
log('✓ Success (204 No Content)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
log(`Error: ${response.status} ${response.statusText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
log('✓ Response:\n' + JSON.stringify(data, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthCancelledError) {
|
||||||
|
log('Authentication cancelled');
|
||||||
|
} else {
|
||||||
|
log(`Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.logout = async function() {
|
||||||
|
await fetch('/auth/api/logout', { method: 'POST' });
|
||||||
|
log('Logged out');
|
||||||
|
}
|
||||||
|
|
||||||
// Browser mode: open the forward endpoint directly in a new window.
|
// Browser mode: open the forward endpoint directly in a new window.
|
||||||
// When Accept: text/html, the server redirects to the login page if needed,
|
window.browserNav = function(url) {
|
||||||
// then back to the original URL after authentication.
|
|
||||||
function browserNav(url) {
|
|
||||||
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).');
|
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).');
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-76
@@ -13,8 +13,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { apiJson, SessionValidator, createAuthIframe, removeAuthIframe } from 'paskia'
|
import { apiJson, SessionValidator } from 'paskia'
|
||||||
import { getAuthIframeUrl } from '@/utils/api'
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
import StatusMessage from '@/components/StatusMessage.vue'
|
import StatusMessage from '@/components/StatusMessage.vue'
|
||||||
import ProfileView from '@/components/ProfileView.vue'
|
import ProfileView from '@/components/ProfileView.vue'
|
||||||
import HostProfileView from '@/components/HostProfileView.vue'
|
import HostProfileView from '@/components/HostProfileView.vue'
|
||||||
@@ -47,80 +47,50 @@ const isHostMode = computed(() => {
|
|||||||
const configuredHost = normalizeHost(authHost)
|
const configuredHost = normalizeHost(authHost)
|
||||||
return currentHost !== configuredHost
|
return currentHost !== configuredHost
|
||||||
})
|
})
|
||||||
const userUuid = computed(() => store.userInfo?.ctx.user.uuid)
|
|
||||||
|
|
||||||
function terminateSession() {
|
function onSessionLost(e) {
|
||||||
store.userInfo = null
|
store.userInfo = null
|
||||||
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 userUuidGetter = () => store.ctx?.user.uuid
|
||||||
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
|
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
|
||||||
|
|
||||||
onMounted(() => sessionValidator.start())
|
onMounted(() => sessionValidator.start())
|
||||||
onUnmounted(() => sessionValidator.stop())
|
onUnmounted(() => sessionValidator.stop())
|
||||||
|
|
||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
|
viewState.value = 'loading'
|
||||||
|
loadingMessage.value = 'Loading...'
|
||||||
try {
|
try {
|
||||||
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
// apiJson handles 401/403 with auth.iframe automatically:
|
||||||
|
// shows overlay iframe, waits for auth, retries the request.
|
||||||
|
const [validateData, userInfoData] = await Promise.all([
|
||||||
|
apiJson('/auth/api/validate', { method: 'POST' }),
|
||||||
|
apiJson('/auth/api/user-info', { method: 'GET' })
|
||||||
|
])
|
||||||
|
store.userInfo = userInfoData
|
||||||
|
store.ctx = validateData.ctx
|
||||||
|
updateThemeFromSession(store.userInfo)
|
||||||
|
// Verify that the user UUIDs match between user-info and validate responses
|
||||||
|
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
|
||||||
|
console.error('User UUID mismatch between user-info and validate responses')
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
viewState.value = 'profile'
|
viewState.value = 'profile'
|
||||||
return true
|
} catch (e) {
|
||||||
} catch {
|
onSessionLost(e)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Listen for postMessage from auth iframe
|
|
||||||
window.addEventListener('message', handleAuthMessage)
|
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
await store.loadSettings()
|
await store.loadSettings()
|
||||||
|
|
||||||
@@ -134,20 +104,7 @@ onMounted(async () => {
|
|||||||
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to load user info
|
// Load user info (apiJson handles auth iframe if needed)
|
||||||
const success = await loadUserInfo()
|
await loadUserInfo()
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
// Need authentication - show login iframe
|
|
||||||
showAuthIframe()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('message', handleAuthMessage)
|
|
||||||
removeAuthIframe()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
</style>
|
|
||||||
|
|||||||
+353
-100
@@ -9,10 +9,13 @@ import AccessDenied from '@/components/AccessDenied.vue'
|
|||||||
import AdminOverview from '@/admin/AdminOverview.vue'
|
import AdminOverview from '@/admin/AdminOverview.vue'
|
||||||
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
||||||
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
||||||
|
import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
|
||||||
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||||
import { apiJson, SessionValidator } from 'paskia'
|
import { apiJson, SessionValidator } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
import { uuidv7 } from 'uuidv7'
|
||||||
import { getDirection } from '@/utils/keynav'
|
import { getDirection } from '@/utils/keynav'
|
||||||
import { goBack } from '@/utils/helpers'
|
import { goBack } from '@/utils/helpers'
|
||||||
|
|
||||||
@@ -24,9 +27,12 @@ const showBackMessage = ref(false)
|
|||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const orgs = ref([])
|
const orgs = ref([])
|
||||||
const permissions = ref([])
|
const permissions = ref([])
|
||||||
|
const oidcClients = ref([])
|
||||||
const currentOrgId = ref(null) // UUID of selected org for detail view
|
const currentOrgId = ref(null) // UUID of selected org for detail view
|
||||||
const currentUserId = ref(null) // UUID for user detail view
|
const currentUserId = ref(null) // UUID for user detail view
|
||||||
|
const currentOidcId = ref(null) // UUID for OIDC client detail view
|
||||||
const userDetail = ref(null) // cached user detail object
|
const userDetail = ref(null) // cached user detail object
|
||||||
|
const editingOidcClient = ref(null) // OIDC client being edited (with local changes)
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const addingOrgForPermission = ref(null)
|
const addingOrgForPermission = ref(null)
|
||||||
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
|
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
|
||||||
@@ -43,6 +49,7 @@ const breadcrumbsRef = ref(null)
|
|||||||
const adminOverviewRef = ref(null)
|
const adminOverviewRef = ref(null)
|
||||||
const adminOrgDetailRef = ref(null)
|
const adminOrgDetailRef = ref(null)
|
||||||
const adminUserDetailRef = ref(null)
|
const adminUserDetailRef = ref(null)
|
||||||
|
const adminOidcDetailRef = ref(null)
|
||||||
|
|
||||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||||
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
|
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
|
||||||
@@ -79,11 +86,12 @@ onUnmounted(() => {
|
|||||||
const permissionSummary = computed(() => {
|
const permissionSummary = computed(() => {
|
||||||
const summary = {}
|
const summary = {}
|
||||||
for (const o of orgs.value) {
|
for (const o of orgs.value) {
|
||||||
const orgBase = { uuid: o.uuid, display_name: o.display_name }
|
const orgBase = { uuid: o.uuid, display_name: o.org.display_name }
|
||||||
const orgPerms = new Set(o.permissions || [])
|
// o.permissions is a dict[UUID, Permission]
|
||||||
|
const orgPermUuids = new Set(Object.keys(o.permissions || {}))
|
||||||
|
|
||||||
// Org-level permissions (direct) - only count if org can grant them
|
// Org-level permissions (direct) - only count if org can grant them
|
||||||
for (const pid of o.permissions || []) {
|
for (const pid of Object.keys(o.permissions || {})) {
|
||||||
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||||
if (!summary[pid].orgSet.has(o.uuid)) {
|
if (!summary[pid].orgSet.has(o.uuid)) {
|
||||||
summary[pid].orgs.push(orgBase)
|
summary[pid].orgs.push(orgBase)
|
||||||
@@ -92,17 +100,18 @@ const permissionSummary = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Role-based permissions (inheritance) - only count if org can grant them
|
// Role-based permissions (inheritance) - only count if org can grant them
|
||||||
for (const r of o.roles) {
|
for (const [roleUuid, r] of Object.entries(o.roles || {})) {
|
||||||
for (const pid of r.permissions) {
|
// r.permissions is dict[UUID, bool]
|
||||||
|
for (const pid of Object.keys(r.permissions || {})) {
|
||||||
// Only count if the org can grant this permission
|
// Only count if the org can grant this permission
|
||||||
if (!orgPerms.has(pid)) continue
|
if (!orgPermUuids.has(pid)) continue
|
||||||
|
|
||||||
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||||
if (!summary[pid].orgSet.has(o.uuid)) {
|
if (!summary[pid].orgSet.has(o.uuid)) {
|
||||||
summary[pid].orgs.push(orgBase)
|
summary[pid].orgs.push(orgBase)
|
||||||
summary[pid].orgSet.add(o.uuid)
|
summary[pid].orgSet.add(o.uuid)
|
||||||
}
|
}
|
||||||
summary[pid].userCount += r.users.length
|
summary[pid].userCount += roleUserCount(o, roleUuid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,32 +129,76 @@ function parseHash() {
|
|||||||
const h = window.location.hash || ''
|
const h = window.location.hash || ''
|
||||||
currentOrgId.value = null
|
currentOrgId.value = null
|
||||||
currentUserId.value = null
|
currentUserId.value = null
|
||||||
|
currentOidcId.value = null
|
||||||
|
editingOidcClient.value = null
|
||||||
if (h.startsWith('#org/')) {
|
if (h.startsWith('#org/')) {
|
||||||
currentOrgId.value = h.slice(5)
|
currentOrgId.value = h.slice(5)
|
||||||
} else if (h.startsWith('#user/')) {
|
} else if (h.startsWith('#user/')) {
|
||||||
currentUserId.value = h.slice(6)
|
currentUserId.value = h.slice(6)
|
||||||
|
} else if (h.startsWith('#oidc:')) {
|
||||||
|
const oidcUuid = h.slice(6)
|
||||||
|
currentOidcId.value = oidcUuid
|
||||||
|
// Initialize editing client data
|
||||||
|
if (oidcUuid === 'new') {
|
||||||
|
// Generate client_id and secret for new client
|
||||||
|
const bytes = new Uint8Array(32)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
const client_secret = btoa(String.fromCharCode(...bytes))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
editingOidcClient.value = {
|
||||||
|
client_id: uuidv7(),
|
||||||
|
client_secret,
|
||||||
|
isNew: true,
|
||||||
|
name: '',
|
||||||
|
redirect_uris: []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const client = oidcClients.value.find(c => c.uuid === oidcUuid)
|
||||||
|
if (client) {
|
||||||
|
editingOidcClient.value = {
|
||||||
|
...client,
|
||||||
|
client_id: client.uuid,
|
||||||
|
client_secret: null,
|
||||||
|
isNew: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrgs() {
|
async function loadAdminData() {
|
||||||
const data = await apiJson('/auth/api/admin/orgs')
|
const data = await apiJson('/auth/api/admin/info')
|
||||||
orgs.value = data.map(o => {
|
// Convert dicts to arrays with uuid added
|
||||||
const roles = o.roles.map(r => ({ ...r, org: o.uuid, users: [] }))
|
orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o }))
|
||||||
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
|
permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p }))
|
||||||
for (const u of o.users || []) {
|
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
|
||||||
if (roleMap[u.role]) roleMap[u.role].users.push(u)
|
|
||||||
}
|
|
||||||
return { ...o, roles }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPermissions() {
|
// Helper to get users for a role as sorted array of [uuid, user]
|
||||||
permissions.value = await apiJson('/auth/api/admin/permissions')
|
function roleUsers(org, roleUuid) {
|
||||||
|
return Object.entries(org.users)
|
||||||
|
.filter(([_, u]) => u.role === roleUuid)
|
||||||
|
.sort(([, a], [, b]) => {
|
||||||
|
const nameA = a.display_name.toLowerCase()
|
||||||
|
const nameB = b.display_name.toLowerCase()
|
||||||
|
return nameA.localeCompare(nameB)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to count users in a role
|
||||||
|
function roleUserCount(org, roleUuid) {
|
||||||
|
return Object.values(org.users).filter(u => u.role === roleUuid).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to count total users in an org
|
||||||
|
function orgUserCount(org) {
|
||||||
|
return Object.keys(org.users).length
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
||||||
info.value = data
|
info.value = data
|
||||||
|
updateThemeFromSession(data.ctx)
|
||||||
authenticated.value = true
|
authenticated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +206,9 @@ function clearSensitiveState() {
|
|||||||
info.value = null
|
info.value = null
|
||||||
orgs.value = []
|
orgs.value = []
|
||||||
permissions.value = []
|
permissions.value = []
|
||||||
|
oidcClients.value = []
|
||||||
userDetail.value = null
|
userDetail.value = null
|
||||||
|
editingOidcClient.value = null
|
||||||
authenticated.value = false
|
authenticated.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +233,7 @@ async function load() {
|
|||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
// Load admin data first - apiJson will handle 401/403 with iframe authentication
|
// Load admin data first - apiJson will handle 401/403 with iframe authentication
|
||||||
await Promise.all([loadOrgs(), loadPermissions()])
|
await loadAdminData()
|
||||||
// If we get here, user has admin access - now fetch user info for display
|
// If we get here, user has admin access - now fetch user info for display
|
||||||
await loadUserInfo()
|
await loadUserInfo()
|
||||||
|
|
||||||
@@ -186,7 +241,6 @@ async function load() {
|
|||||||
if (!window.location.hash || window.location.hash === '#overview') {
|
if (!window.location.hash || window.location.hash === '#overview') {
|
||||||
currentOrgId.value = orgs.value[0].uuid
|
currentOrgId.value = orgs.value[0].uuid
|
||||||
window.location.hash = `#org/${currentOrgId.value}`
|
window.location.hash = `#org/${currentOrgId.value}`
|
||||||
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
|
|
||||||
} else {
|
} else {
|
||||||
parseHash()
|
parseHash()
|
||||||
}
|
}
|
||||||
@@ -207,17 +261,17 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user.
|
|||||||
|
|
||||||
async function performOrgDeletion(orgUuid) {
|
async function performOrgDeletion(orgUuid) {
|
||||||
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
|
||||||
await Promise.all([loadOrgs(), loadPermissions()])
|
await Promise.all([loadAdminData()])
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteOrg(org) {
|
function deleteOrg(org) {
|
||||||
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
|
const userCount = orgUserCount(org)
|
||||||
|
|
||||||
if (userCount === 0) {
|
if (userCount === 0) {
|
||||||
// No users in the organization, safe to delete directly
|
// No users in the organization, safe to delete directly
|
||||||
performOrgDeletion(org.uuid)
|
performOrgDeletion(org.uuid)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Organization "${org.display_name}" deleted.`, 'success', 2500)
|
authStore.showMessage(`Organization "${org.org.display_name}" deleted.`, 'success', 2500)
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to delete organization', 'error')
|
authStore.showMessage(e.message || 'Failed to delete organization', 'error')
|
||||||
@@ -226,50 +280,68 @@ function deleteOrg(org) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build detailed breakdown of users by role
|
// Build detailed breakdown of users by role
|
||||||
const roleParts = org.roles
|
const roleParts = Object.entries(org.roles)
|
||||||
.filter(r => r.users.length > 0)
|
.map(([uuid, r]) => ({ role: r, count: roleUserCount(org, uuid) }))
|
||||||
.map(r => `${r.users.length} ${r.display_name}`)
|
.filter(x => x.count > 0)
|
||||||
|
.map(x => `${x.count} ${x.role.display_name}`)
|
||||||
|
|
||||||
const affects = roleParts.join(', ')
|
const affects = roleParts.join(', ')
|
||||||
|
|
||||||
openDialog('confirm', { message: `Delete organization "${org.display_name}", including accounts of ${affects})?`, action: async () => {
|
openDialog('confirm', { message: `Delete organization "${org.org.display_name}", including accounts of ${affects})?`, action: async () => {
|
||||||
await performOrgDeletion(org.uuid)
|
await performOrgDeletion(org.uuid)
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
|
|
||||||
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
||||||
|
|
||||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
function deleteUser(user, userDetail) {
|
||||||
if (user.role === targetRoleDisplayName) return
|
const credentialCount = userDetail?.credentials ? Object.keys(userDetail.credentials).length : 0
|
||||||
|
const userUuid = user.uuid
|
||||||
|
const userName = user.display_name
|
||||||
|
const orgUuid = user.org // org UUID is stored in selectedUser
|
||||||
|
|
||||||
|
if (credentialCount === 0) {
|
||||||
|
// No credentials, safe to delete directly
|
||||||
|
performUserDeletion(userUuid, userName, orgUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const passkeys = credentialCount === 1 ? '1 passkey' : `${credentialCount} passkeys`
|
||||||
|
openDialog('confirm', {
|
||||||
|
message: `Delete user "${userName}" with ${passkeys}? This action cannot be undone.`,
|
||||||
|
action: async () => {
|
||||||
|
await performUserDeletion(userUuid, userName, orgUuid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performUserDeletion(userUuid, userName, orgUuid) {
|
||||||
try {
|
try {
|
||||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' })
|
||||||
|
authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500)
|
||||||
|
await loadAdminData()
|
||||||
|
window.location.hash = `#org/${orgUuid}`
|
||||||
|
} catch (e) {
|
||||||
|
authStore.showMessage(e.message || 'Failed to delete user', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveUserToRole(userUuid, user, targetRoleUuid) {
|
||||||
|
if (user.role === targetRoleUuid) return
|
||||||
|
try {
|
||||||
|
await apiJson(`/auth/api/admin/users/${userUuid}/role`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { role: targetRoleDisplayName }
|
body: { role_uuid: targetRoleUuid }
|
||||||
})
|
})
|
||||||
await loadOrgs()
|
await loadAdminData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to update user role')
|
authStore.showMessage(e.message || 'Failed to update user role')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUserDragStart(e, user, org) {
|
function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
const user = selectedOrg.value?.users?.[userUuid]
|
||||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org }))
|
if (user) moveUserToRole(userUuid, user, newRoleUuid)
|
||||||
}
|
|
||||||
|
|
||||||
function onRoleDragOver(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.dataTransfer.dropEffect = 'move'
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRoleDrop(e, org, role) {
|
|
||||||
e.preventDefault()
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
|
|
||||||
if (data.org !== org.uuid) return // only within same org
|
|
||||||
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
|
|
||||||
if (user) moveUserToRole(org, user, role.display_name)
|
|
||||||
} catch (_) { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role actions
|
// Role actions
|
||||||
@@ -279,10 +351,10 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
|
|||||||
|
|
||||||
function deleteRole(role) {
|
function deleteRole(role) {
|
||||||
// UI only allows deleting empty roles, so no confirmation needed
|
// UI only allows deleting empty roles, so no confirmation needed
|
||||||
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'DELETE' })
|
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
|
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
|
||||||
loadOrgs()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to delete role', 'error')
|
authStore.showMessage(e.message || 'Failed to delete role', 'error')
|
||||||
@@ -290,19 +362,22 @@ function deleteRole(role) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleRolePermission(role, pid, checked) {
|
async function toggleRolePermission(role, pid, checked) {
|
||||||
// Optimistic update
|
// Optimistic update - role.permissions is dict[UUID, bool]
|
||||||
const prevPermissions = [...role.permissions]
|
const prevPermissions = { ...role.permissions }
|
||||||
const newPermissions = checked
|
const newPermissions = { ...role.permissions }
|
||||||
? [...role.permissions, pid]
|
if (checked) {
|
||||||
: role.permissions.filter(p => p !== pid)
|
newPermissions[pid] = true
|
||||||
|
} else {
|
||||||
|
delete newPermissions[pid]
|
||||||
|
}
|
||||||
role.permissions = newPermissions
|
role.permissions = newPermissions
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const method = checked ? 'POST' : 'DELETE'
|
const method = checked ? 'POST' : 'DELETE'
|
||||||
await apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}/permissions/${pid}`, {
|
await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
|
||||||
method
|
method
|
||||||
})
|
})
|
||||||
await loadOrgs()
|
await loadAdminData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to update role permission')
|
authStore.showMessage(e.message || 'Failed to update role permission')
|
||||||
role.permissions = prevPermissions // revert
|
role.permissions = prevPermissions // revert
|
||||||
@@ -313,7 +388,7 @@ async function toggleRolePermission(role, pid, checked) {
|
|||||||
async function performPermissionDeletion(permissionUuid) {
|
async function performPermissionDeletion(permissionUuid) {
|
||||||
const params = new URLSearchParams({ permission_uuid: permissionUuid })
|
const params = new URLSearchParams({ permission_uuid: permissionUuid })
|
||||||
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||||
await loadPermissions()
|
await loadAdminData()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deletePermission(p) {
|
function deletePermission(p) {
|
||||||
@@ -322,8 +397,8 @@ function deletePermission(p) {
|
|||||||
// Count roles that have this permission
|
// Count roles that have this permission
|
||||||
let roleCount = 0
|
let roleCount = 0
|
||||||
for (const org of orgs.value) {
|
for (const org of orgs.value) {
|
||||||
for (const role of org.roles) {
|
for (const role of Object.values(org.roles)) {
|
||||||
if (role.permissions.includes(p.uuid)) {
|
if (p.uuid in (role.permissions || {})) {
|
||||||
roleCount++
|
roleCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
||||||
|
|
||||||
function openOrg(o) {
|
function openOrg(o) {
|
||||||
@@ -368,34 +541,41 @@ function openUser(u) {
|
|||||||
const selectedUser = computed(() => {
|
const selectedUser = computed(() => {
|
||||||
if (!currentUserId.value) return null
|
if (!currentUserId.value) return null
|
||||||
for (const o of orgs.value) {
|
for (const o of orgs.value) {
|
||||||
for (const r of o.roles) {
|
const u = o.users[currentUserId.value]
|
||||||
const u = r.users.find(x => x.uuid === currentUserId.value)
|
if (u) {
|
||||||
if (u) return { ...u, org: o.uuid, role_display_name: r.display_name }
|
const role = o.roles[u.role]
|
||||||
|
return { ...u, uuid: currentUserId.value, org: o.uuid, role_display_name: role?.display_name }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageHeading = computed(() => {
|
|
||||||
if (selectedUser.value) return 'Admin: User'
|
|
||||||
if (selectedOrg.value) return 'Admin: Org'
|
|
||||||
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Breadcrumb entries for admin app.
|
// Breadcrumb entries for admin app.
|
||||||
const breadcrumbEntries = computed(() => {
|
const breadcrumbEntries = computed(() => {
|
||||||
const entries = [
|
const entries = [
|
||||||
{ label: 'Auth', href: makeUiHref() },
|
{ label: 'My Profile', href: makeUiHref() }
|
||||||
{ label: 'Admin', href: adminUiPath() }
|
|
||||||
]
|
]
|
||||||
|
// For org admins, combine Admin and their org
|
||||||
|
if (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) {
|
||||||
|
const org = orgs.value[0]
|
||||||
|
entries.push({ label: `Admin: ${org.org.display_name}`, href: `#org/${org.uuid}` })
|
||||||
|
} else {
|
||||||
|
entries.push({ label: 'Admin', href: adminUiPath() })
|
||||||
|
}
|
||||||
// Determine organization for user view if selectedOrg not explicitly chosen.
|
// Determine organization for user view if selectedOrg not explicitly chosen.
|
||||||
let orgForUser = null
|
let orgForUser = null
|
||||||
if (selectedUser.value) {
|
if (selectedUser.value) {
|
||||||
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
|
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
|
||||||
}
|
}
|
||||||
const orgToShow = selectedOrg.value || orgForUser
|
const orgToShow = selectedOrg.value || orgForUser
|
||||||
if (orgToShow) {
|
// Add org breadcrumb only if it's not already included in the Admin entry
|
||||||
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
|
const adminOrg = (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) ? orgs.value[0] : null
|
||||||
|
if (orgToShow && (!adminOrg || orgToShow.uuid !== adminOrg.uuid)) {
|
||||||
|
entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` })
|
||||||
|
}
|
||||||
|
if (currentOidcId.value) {
|
||||||
|
const label = editingOidcClient.value?.isNew ? 'New Client' : (editingOidcClient.value?.name || 'OIDC Client')
|
||||||
|
entries.push({ label, href: `#oidc:${currentOidcId.value}` })
|
||||||
}
|
}
|
||||||
if (selectedUser.value) {
|
if (selectedUser.value) {
|
||||||
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
|
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
|
||||||
@@ -406,7 +586,7 @@ const breadcrumbEntries = computed(() => {
|
|||||||
watch(selectedUser, async (u) => {
|
watch(selectedUser, async (u) => {
|
||||||
if (!u) { userDetail.value = null; return }
|
if (!u) { userDetail.value = null; return }
|
||||||
try {
|
try {
|
||||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org}/users/${u.uuid}`)
|
userDetail.value = await apiJson(`/auth/api/admin/users/${u.uuid}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
userDetail.value = { error: e.message }
|
userDetail.value = { error: e.message }
|
||||||
}
|
}
|
||||||
@@ -418,18 +598,27 @@ function generateUserRegistrationLink(u) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleOrgPermission(org, permId, checked) {
|
async function toggleOrgPermission(org, permId, checked) {
|
||||||
// Build next permission list
|
// org.permissions is dict[UUID, Permission]
|
||||||
const has = org.permissions.includes(permId)
|
const has = permId in org.permissions
|
||||||
if (checked && has) return
|
if (checked && has) return
|
||||||
if (!checked && !has) return
|
if (!checked && !has) return
|
||||||
const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId)
|
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
const prev = [...org.permissions]
|
const prev = { ...org.permissions }
|
||||||
org.permissions = next
|
if (checked) {
|
||||||
|
// Need to fetch the permission object to add it
|
||||||
|
const perm = permissions.value.find(p => p.uuid === permId)
|
||||||
|
if (perm) {
|
||||||
|
org.permissions = { ...org.permissions, [permId]: perm }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const next = { ...org.permissions }
|
||||||
|
delete next[permId]
|
||||||
|
org.permissions = next
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ permission_uuid: permId })
|
const params = new URLSearchParams({ permission_uuid: permId })
|
||||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
||||||
await loadOrgs()
|
await loadAdminData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
|
authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
|
||||||
org.permissions = prev // revert
|
org.permissions = prev // revert
|
||||||
@@ -539,10 +728,10 @@ function handlePanelNavigateOut(direction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshUserDetail() {
|
async function refreshUserDetail() {
|
||||||
await loadOrgs()
|
await loadAdminData()
|
||||||
if (selectedUser.value) {
|
if (selectedUser.value) {
|
||||||
try {
|
try {
|
||||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org}/users/${selectedUser.value.uuid}`)
|
userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`)
|
||||||
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,7 +754,7 @@ async function submitDialog() {
|
|||||||
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
|
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
|
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
|
||||||
Promise.all([loadOrgs(), loadPermissions()])
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to create organization', 'error')
|
authStore.showMessage(e.message || 'Failed to create organization', 'error')
|
||||||
@@ -579,7 +768,7 @@ async function submitDialog() {
|
|||||||
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
|
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
|
||||||
loadOrgs()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to update organization', 'error')
|
authStore.showMessage(e.message || 'Failed to update organization', 'error')
|
||||||
@@ -593,7 +782,7 @@ async function submitDialog() {
|
|||||||
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
|
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
|
authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
|
||||||
loadOrgs()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to create role', 'error')
|
authStore.showMessage(e.message || 'Failed to create role', 'error')
|
||||||
@@ -604,7 +793,7 @@ async function submitDialog() {
|
|||||||
|
|
||||||
// Close dialog immediately, then perform async operation
|
// Close dialog immediately, then perform async operation
|
||||||
closeDialog()
|
closeDialog()
|
||||||
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||||
loadOrgs()
|
loadOrgs()
|
||||||
@@ -621,7 +810,7 @@ async function submitDialog() {
|
|||||||
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
|
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
|
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
|
||||||
loadOrgs()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to add user', 'error')
|
authStore.showMessage(e.message || 'Failed to add user', 'error')
|
||||||
@@ -632,7 +821,7 @@ async function submitDialog() {
|
|||||||
|
|
||||||
// Close dialog immediately, then perform async operation
|
// Close dialog immediately, then perform async operation
|
||||||
closeDialog()
|
closeDialog()
|
||||||
apiJson(`/auth/api/admin/orgs/${user.org}/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
|
apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
||||||
onUserNameSaved()
|
onUserNameSaved()
|
||||||
@@ -667,7 +856,7 @@ async function submitDialog() {
|
|||||||
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
|
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
|
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
|
||||||
loadPermissions()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to update permission', 'error')
|
authStore.showMessage(e.message || 'Failed to update permission', 'error')
|
||||||
@@ -683,12 +872,58 @@ async function submitDialog() {
|
|||||||
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } })
|
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
|
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
|
||||||
loadPermissions()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
||||||
})
|
})
|
||||||
return // Don't call closeDialog() again
|
return // Don't call closeDialog() again
|
||||||
|
} else if (t === 'oidc-edit') {
|
||||||
|
const { client_id, client_secret, isNew } = dialog.value.data
|
||||||
|
const name = dialog.value.data.name?.trim()
|
||||||
|
const uris = dialog.value.data.redirect_uris?.trim()
|
||||||
|
if (!name) throw new Error('Client name required')
|
||||||
|
|
||||||
|
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
|
||||||
|
|
||||||
|
// Close dialog immediately, then perform async operation
|
||||||
|
closeDialog()
|
||||||
|
|
||||||
|
const req = client_secret
|
||||||
|
? sha256Hex(client_secret).then(secret_hash => isNew
|
||||||
|
? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
|
||||||
|
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } }))
|
||||||
|
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
|
||||||
|
req
|
||||||
|
.then(() => {
|
||||||
|
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
||||||
|
loadAdminData()
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
||||||
|
})
|
||||||
|
return // Don't call closeDialog() again
|
||||||
|
} else if (t === 'server-config') {
|
||||||
|
const rp_name = dialog.value.data.rp_name?.trim() || ''
|
||||||
|
const auth_host = dialog.value.data.auth_host?.trim() || ''
|
||||||
|
// Origins are stored as-is (hostnames); backend normalizes with https://
|
||||||
|
const origins = dialog.value.data.origins
|
||||||
|
.map(o => o.trim())
|
||||||
|
.filter(o => o)
|
||||||
|
|
||||||
|
closeDialog()
|
||||||
|
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
|
||||||
|
.then(() => {
|
||||||
|
authStore.showMessage('Server configuration updated.', 'success', 2500)
|
||||||
|
// Reload settings to reflect rp_name changes
|
||||||
|
authStore.loadSettings().then(() => {
|
||||||
|
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
|
||||||
|
})
|
||||||
|
return // Don't call closeDialog() again
|
||||||
} else if (t === 'confirm') {
|
} else if (t === 'confirm') {
|
||||||
const action = dialog.value.data.action
|
const action = dialog.value.data.action
|
||||||
// Close dialog first, then perform action (errors shown via showMessage)
|
// Close dialog first, then perform action (errors shown via showMessage)
|
||||||
@@ -728,7 +963,6 @@ async function submitDialog() {
|
|||||||
/>
|
/>
|
||||||
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
|
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<h1>{{ pageHeading }}</h1>
|
|
||||||
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -736,11 +970,12 @@ async function submitDialog() {
|
|||||||
<div class="section-body admin-section-body">
|
<div class="section-body admin-section-body">
|
||||||
<div class="admin-panels">
|
<div class="admin-panels">
|
||||||
<AdminOverview
|
<AdminOverview
|
||||||
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)"
|
v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
|
||||||
ref="adminOverviewRef"
|
ref="adminOverviewRef"
|
||||||
:info="info"
|
:info="info"
|
||||||
:orgs="orgs"
|
:orgs="orgs"
|
||||||
:permissions="permissions"
|
:permissions="permissions"
|
||||||
|
:oidc-clients="oidcClients"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
:permission-summary="permissionSummary"
|
:permission-summary="permissionSummary"
|
||||||
@create-org="createOrg"
|
@create-org="createOrg"
|
||||||
@@ -751,6 +986,10 @@ async function submitDialog() {
|
|||||||
@open-dialog="openDialog"
|
@open-dialog="openDialog"
|
||||||
@delete-permission="deletePermission"
|
@delete-permission="deletePermission"
|
||||||
@rename-permission-display="renamePermissionDisplay"
|
@rename-permission-display="renamePermissionDisplay"
|
||||||
|
@create-oidc-client="createOidcClient"
|
||||||
|
@open-oidc-client="openOidcClient"
|
||||||
|
@delete-oidc-client="deleteOidcClient"
|
||||||
|
@open-server-config="openServerConfig"
|
||||||
@navigate-out="handlePanelNavigateOut"
|
@navigate-out="handlePanelNavigateOut"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -771,6 +1010,7 @@ async function submitDialog() {
|
|||||||
@edit-user-name="editUserName"
|
@edit-user-name="editUserName"
|
||||||
@close-reg-modal="showRegModal = false"
|
@close-reg-modal="showRegModal = false"
|
||||||
@navigate-out="handlePanelNavigateOut"
|
@navigate-out="handlePanelNavigateOut"
|
||||||
|
@delete-user="deleteUser(selectedUser, userDetail)"
|
||||||
/>
|
/>
|
||||||
<AdminOrgDetail
|
<AdminOrgDetail
|
||||||
v-else-if="selectedOrg"
|
v-else-if="selectedOrg"
|
||||||
@@ -785,10 +1025,23 @@ async function submitDialog() {
|
|||||||
@create-user-in-role="createUserInRole"
|
@create-user-in-role="createUserInRole"
|
||||||
@open-user="openUser"
|
@open-user="openUser"
|
||||||
@toggle-role-permission="toggleRolePermission"
|
@toggle-role-permission="toggleRolePermission"
|
||||||
@on-role-drag-over="onRoleDragOver"
|
@move-user-to-role="moveUserToRoleFromDrag"
|
||||||
|
@navigate-out="handlePanelNavigateOut"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminOidcDetail
|
||||||
|
v-else-if="currentOidcId && editingOidcClient"
|
||||||
|
ref="adminOidcDetailRef"
|
||||||
|
:client="editingOidcClient"
|
||||||
|
:permissions="permissions"
|
||||||
|
:is-new="editingOidcClient.isNew"
|
||||||
|
:navigation-disabled="hasActiveModal"
|
||||||
|
@save="handleOidcSave"
|
||||||
|
@cancel="handleOidcCancel"
|
||||||
|
@delete="deleteOidcClient"
|
||||||
|
@reset-secret="resetOidcSecret"
|
||||||
|
@create-permission="createPermissionForClient"
|
||||||
@navigate-out="handlePanelNavigateOut"
|
@navigate-out="handlePanelNavigateOut"
|
||||||
@on-role-drop="onRoleDrop"
|
|
||||||
@on-user-drag-start="onUserDragStart"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -802,14 +1055,14 @@ async function submitDialog() {
|
|||||||
:settings="authStore.settings"
|
:settings="authStore.settings"
|
||||||
@submit-dialog="submitDialog"
|
@submit-dialog="submitDialog"
|
||||||
@close-dialog="closeDialog"
|
@close-dialog="closeDialog"
|
||||||
|
@reset-oidc-secret="resetOidcSecret"
|
||||||
|
@create-permission-for-client="createPermissionForClient"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-admin { padding-bottom: var(--space-3xl); }
|
.view-admin { padding-bottom: var(--space-3xl); }
|
||||||
.view-header { display: flex; flex-direction: column; gap: var(--space-sm); }
|
|
||||||
.admin-section { margin-top: var(--space-xl); }
|
|
||||||
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||||
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Admin</title>
|
<title>Admin</title>
|
||||||
|
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
|
||||||
|
<link rel="stylesheet" href="/src/assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="admin-app"></div>
|
<div id="admin-app"></div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '@/assets/style.css'
|
import { initThemeFromCache } from '@/utils/theme'
|
||||||
|
initThemeFromCache()
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Auth Profile</title>
|
<title>Auth Profile</title>
|
||||||
|
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
|
||||||
|
<link rel="stylesheet" href="/src/assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '@/assets/style.css'
|
import { initThemeFromCache } from '@/utils/theme'
|
||||||
|
initThemeFromCache()
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|||||||
@@ -2,19 +2,23 @@
|
|||||||
<RestrictedAuth
|
<RestrictedAuth
|
||||||
:mode="authMode"
|
:mode="authMode"
|
||||||
:remote-auth-token="remoteAuthToken"
|
:remote-auth-token="remoteAuthToken"
|
||||||
|
:oidc-query-string="oidcQueryString"
|
||||||
@authenticated="handleAuthenticated"
|
@authenticated="handleAuthenticated"
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||||
|
|
||||||
// Check if this is a remote auth URL: /auth/{token}
|
// Check if this is a remote auth URL: /auth/{token}
|
||||||
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
||||||
const remoteAuthToken = ref(null)
|
const remoteAuthToken = ref(null)
|
||||||
|
|
||||||
|
// For OIDC flow, pass the raw query string to preserve exact param values
|
||||||
|
const oidcQueryString = window.location.search.includes('client_id=') ? window.location.search : null
|
||||||
|
|
||||||
function extractRemoteToken() {
|
function extractRemoteToken() {
|
||||||
const path = window.location.pathname
|
const path = window.location.pathname
|
||||||
// Match /auth/{token} where token is a passphrase with dots
|
// Match /auth/{token} where token is a passphrase with dots
|
||||||
@@ -30,14 +34,20 @@ function extractRemoteToken() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect mode from URL hash fragment
|
// Parse URL hash fragment
|
||||||
const authMode = computed(() => {
|
const hashParams = new URLSearchParams(window.location.hash.slice(1))
|
||||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
|
||||||
const mode = params.get('mode')
|
// Determine auth mode based on URL path
|
||||||
if (mode === 'reauth') return 'reauth'
|
// - /auth/restricted/oidc: OIDC flow, no session dependency
|
||||||
if (mode === 'forbidden') return 'forbidden'
|
// - /auth/restricted/iframe: iframe embedding, mode from hash params
|
||||||
return 'login'
|
// - Other paths: forward auth, mode from hash params
|
||||||
})
|
let authMode
|
||||||
|
if (window.location.pathname === '/auth/restricted/oidc') {
|
||||||
|
authMode = 'oidc'
|
||||||
|
} else {
|
||||||
|
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth)
|
||||||
|
authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
|
||||||
|
}
|
||||||
|
|
||||||
function postToParent(message) {
|
function postToParent(message) {
|
||||||
if (window.parent && window.parent !== window) {
|
if (window.parent && window.parent !== window) {
|
||||||
@@ -46,10 +56,15 @@ function postToParent(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthenticated(result) {
|
function handleAuthenticated(result) {
|
||||||
|
if (result.redirect_url) {
|
||||||
|
// OIDC flow: redirect to client with auth code
|
||||||
|
window.location.href = result.redirect_url
|
||||||
|
return
|
||||||
|
}
|
||||||
postToParent({
|
postToParent({
|
||||||
type: 'auth-success',
|
type: 'auth-success',
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
sessionToken: result.session_token
|
exchangeCode: result.exchange_code
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" style="background: transparent">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<script>{let t=localStorage.getItem('paskia-theme');if(!t){let p=new URLSearchParams(location.hash.slice(1)).get('theme');if(p==='light'||p==='dark')t=p}(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
|
||||||
|
<link rel="stylesheet" href="/src/assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import './theme.js'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import RestrictedApi from './RestrictedApi.vue'
|
import RestrictedApi from './RestrictedApi.vue'
|
||||||
import '@/assets/style.css'
|
|
||||||
import { initKeyboardNavigation } from '@/utils/keynav'
|
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||||
|
|
||||||
createApp(RestrictedApi).mount('#app')
|
createApp(RestrictedApi).mount('#app')
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Early theme for restricted app - user preference (localStorage) wins, then URL param
|
||||||
|
import { applyTheme, getCachedTheme } from '@/utils/theme.js'
|
||||||
|
|
||||||
|
function getTheme() {
|
||||||
|
const params = new URLSearchParams(location.hash.slice(1))
|
||||||
|
return getCachedTheme() || params.get('theme') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme class to document root
|
||||||
|
applyTheme(getTheme())
|
||||||
|
addEventListener('hashchange', () => applyTheme(getTheme()))
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Access Restricted</title>
|
<title>Access Restricted</title>
|
||||||
|
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<div v-if="status.show" class="global-status" style="display: block;">
|
<div v-if="status.show" class="global-status show">
|
||||||
<div :class="['status', status.type]">
|
<div :class="['status', status.type]">
|
||||||
{{ status.message }}
|
{{ status.message }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="view-root">
|
<main class="view-root">
|
||||||
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
<div class="surface surface--tight reset-container">
|
||||||
<header class="view-header" style="text-align: center;">
|
<header class="view-header center">
|
||||||
<h1>🔑 Registration</h1>
|
<h1>🔑 Registration</h1>
|
||||||
<p class="view-lede">
|
<p class="view-lede">
|
||||||
{{ subtitleMessage }}
|
{{ subtitleMessage }}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<section class="section-block" v-else-if="!canRegister">
|
<section class="section-block" v-else-if="!canRegister">
|
||||||
<div class="section-body center">
|
<div class="section-body center">
|
||||||
<div class="button-row center" style="justify-content: center;">
|
<div class="button-row button-row--center">
|
||||||
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,6 +60,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
|||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
|
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
const status = reactive({
|
const status = reactive({
|
||||||
show: false,
|
show: false,
|
||||||
@@ -80,7 +81,7 @@ const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your en
|
|||||||
const subtitleMessage = computed(() => {
|
const subtitleMessage = computed(() => {
|
||||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||||
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
||||||
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
|
return `Finish up ${sessionDescriptor.value}. The name entered will be stored on your passkey and on our system.`
|
||||||
})
|
})
|
||||||
|
|
||||||
const basePath = computed(() => uiBasePath())
|
const basePath = computed(() => uiBasePath())
|
||||||
@@ -117,6 +118,7 @@ async function fetchTokenInfo() {
|
|||||||
headers: { 'Authorization': `Bearer ${token.value}` },
|
headers: { 'Authorization': `Bearer ${token.value}` },
|
||||||
})
|
})
|
||||||
displayName.value = tokenInfo.value.display_name
|
displayName.value = tokenInfo.value.display_name
|
||||||
|
if (tokenInfo.value.theme) updateThemeFromSession({ user: { theme: tokenInfo.value.theme } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load token info', error)
|
console.error('Failed to load token info', error)
|
||||||
const message = error instanceof ApiError
|
const message = error instanceof ApiError
|
||||||
@@ -144,7 +146,7 @@ async function registerPasskey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setSessionCookie(result)
|
await exchangeCode(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
const message = error?.message || 'Failed to establish session'
|
const message = error?.message || 'Failed to establish session'
|
||||||
@@ -156,15 +158,13 @@ async function registerPasskey() {
|
|||||||
setTimeout(() => { loading.value = false; goHome() }, 800)
|
setTimeout(() => { loading.value = false; goHome() }, 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSessionCookie(result) {
|
async function exchangeCode(result) {
|
||||||
if (!result?.session_token) {
|
if (!result?.exchange_code) {
|
||||||
throw new Error('Registration response missing session_token')
|
throw new Error('Registration response missing exchange_code')
|
||||||
}
|
}
|
||||||
return await apiJson('/auth/api/set-session', {
|
return await apiJson('/auth/api/set-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Authorization': `Bearer ${result.exchange_code}` }
|
||||||
Authorization: `Bearer ${result.session_token}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,13 +203,14 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.center {
|
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||||
text-align: center;
|
.reset-container {
|
||||||
}
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
.button-row.center {
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
|
gap: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-body {
|
.section-body {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Complete Passkey Setup</title>
|
<title>Complete Passkey Setup</title>
|
||||||
|
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sirv": "^3.0.2",
|
"sirv": "^3.0.2",
|
||||||
"vue": "^3.5.17"
|
"uuidv7": "^1.1.0",
|
||||||
|
"vue": "^3.5.17",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import NameEditForm from '@/components/NameEditForm.vue'
|
import NameEditForm from '@/components/NameEditForm.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
dialog: Object,
|
dialog: Object,
|
||||||
@@ -9,10 +10,164 @@ const props = defineProps({
|
|||||||
settings: Object
|
settings: Object
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['submitDialog', 'closeDialog'])
|
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
|
||||||
|
|
||||||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||||||
|
const NO_SUBMIT_TYPES = new Set([])
|
||||||
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||||
|
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
|
||||||
|
|
||||||
|
// Initialize validation properties
|
||||||
|
if (props.dialog?.data && props.dialog.type === 'server-config') {
|
||||||
|
if (!('authHostValidation' in props.dialog.data)) {
|
||||||
|
props.dialog.data.authHostValidation = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidationInvalid = computed(() => {
|
||||||
|
if (props.dialog?.type !== 'server-config') return false
|
||||||
|
const d = props.dialog.data
|
||||||
|
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
|
||||||
|
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Copy-to-clipboard helper
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
function copyText(value, label) {
|
||||||
|
navigator.clipboard.writeText(value).then(() => {
|
||||||
|
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrigin() {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (d) {
|
||||||
|
d.origins.push(rpId.value)
|
||||||
|
d.originValidation.push(null)
|
||||||
|
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function removeOrigin(i) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (d) {
|
||||||
|
d.origins.splice(i, 1)
|
||||||
|
d.originValidation.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function stripScheme(val, i) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
function stripSchemeAuthHost() {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
function focusOriginStart(e) {
|
||||||
|
e.target.setSelectionRange(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOriginDomain(origin, rpId) {
|
||||||
|
if (!origin.trim()) return false
|
||||||
|
try {
|
||||||
|
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
||||||
|
const hostname = url.hostname
|
||||||
|
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateOriginConnectivity(origin, i) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (!d) return
|
||||||
|
|
||||||
|
d.originValidation[i] = 'validating'
|
||||||
|
try {
|
||||||
|
const cleanOrigin = origin.replace(/\/+$/, '')
|
||||||
|
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
|
||||||
|
const response = await fetch(testUrl + '/auth/api/settings', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||||
|
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||||
|
// Only update if the origin hasn't changed
|
||||||
|
if (d.origins[i] === origin) {
|
||||||
|
d.originValidation[i] = result
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (d.origins[i] === origin) {
|
||||||
|
d.originValidation[i] = 'invalid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (d.origins[i] === origin) {
|
||||||
|
d.originValidation[i] = 'invalid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOrigin(origin, i) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (!d) return
|
||||||
|
|
||||||
|
const id = rpId.value
|
||||||
|
if (validateOriginDomain(origin, id)) {
|
||||||
|
validateOriginConnectivity(origin, i)
|
||||||
|
} else {
|
||||||
|
d.originValidation[i] = 'invalid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateAuthHostConnectivity(authHost) {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (!d) return
|
||||||
|
|
||||||
|
d.authHostValidation = 'validating'
|
||||||
|
try {
|
||||||
|
const cleanAuthHost = authHost.replace(/\/+$/, '')
|
||||||
|
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
|
||||||
|
const response = await fetch(testUrl + '/auth/api/settings', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||||
|
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||||
|
// Only update if the auth_host hasn't changed
|
||||||
|
if (d.auth_host === authHost) {
|
||||||
|
d.authHostValidation = result
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (d.auth_host === authHost) {
|
||||||
|
d.authHostValidation = 'invalid-connectivity'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (d.auth_host === authHost) {
|
||||||
|
d.authHostValidation = 'invalid-connectivity'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAuthHost() {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (!d || !d.auth_host?.trim()) {
|
||||||
|
d.authHostValidation = null // Allow empty
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = rpId.value
|
||||||
|
if (validateOriginDomain(d.auth_host, id)) {
|
||||||
|
validateAuthHostConnectivity(d.auth_host)
|
||||||
|
} else {
|
||||||
|
d.authHostValidation = 'invalid-domain'
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -25,6 +180,8 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
|||||||
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
||||||
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
||||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
||||||
|
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
|
||||||
|
<template v-else-if="dialog.type==='server-config'">Server Options</template>
|
||||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||||
</h3>
|
</h3>
|
||||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||||
@@ -76,19 +233,51 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
|||||||
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
|
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
|
||||||
</label>
|
</label>
|
||||||
<label>Permission Scope
|
<label>Permission Scope
|
||||||
<input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
|
<input v-model="dialog.data.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
|
||||||
</label>
|
</label>
|
||||||
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
|
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
|
||||||
<label>Domain Scope
|
<label>Domain Scope
|
||||||
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" />
|
<input v-model="dialog.data.domain" data-form-type="other" />
|
||||||
</label>
|
</label>
|
||||||
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
|
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="dialog.type==='server-config'">
|
||||||
|
<label>Site Branding (rp-name)
|
||||||
|
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
|
||||||
|
</label>
|
||||||
|
<label>Dedicated Authentication Site (auth-host)
|
||||||
|
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
|
||||||
|
</label>
|
||||||
|
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
|
||||||
|
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
|
||||||
|
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
|
||||||
|
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
|
||||||
|
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
|
||||||
|
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
|
||||||
|
<div class="origin-label">
|
||||||
|
Allowed Origins
|
||||||
|
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin">➕</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="dialog.data.origins.length" class="origin-list">
|
||||||
|
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
|
||||||
|
<input
|
||||||
|
:value="dialog.data.origins[i]"
|
||||||
|
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
|
||||||
|
@focus="focusOriginStart"
|
||||||
|
class="origin-input"
|
||||||
|
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
||||||
|
/>
|
||||||
|
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
|
||||||
|
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="dialog.type==='confirm'">
|
<template v-else-if="dialog.type==='confirm'">
|
||||||
<p>{{ dialog.data.message }}</p>
|
<p>{{ dialog.data.message }}</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
||||||
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
|
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
@@ -100,18 +289,46 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
:disabled="dialog.busy"
|
:disabled="dialog.busy || isValidationInvalid"
|
||||||
>
|
>
|
||||||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
@click="$emit('closeDialog')"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.error { color: var(--color-danger-text); }
|
|
||||||
.small { font-size: 0.9rem; }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
|
||||||
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
||||||
|
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
|
||||||
|
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
|
||||||
|
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
|
||||||
|
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
|
||||||
|
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||||
|
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
|
||||||
|
.oidc-groups { cursor: default; }
|
||||||
|
.oidc-group { cursor: pointer; }
|
||||||
|
.oidc-group output { white-space: normal; word-break: break-all; }
|
||||||
|
|
||||||
|
/* Server config origins */
|
||||||
|
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
|
||||||
|
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
|
||||||
|
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
|
||||||
|
.origin-row .delete-icon { flex-shrink: 0; }
|
||||||
|
.origin-add-btn { font-size: 1.2rem; }
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -8,7 +9,7 @@ const props = defineProps({
|
|||||||
navigationDisabled: { type: Boolean, default: false }
|
navigationDisabled: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart', 'navigateOut'])
|
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'moveUserToRole', 'navigateOut'])
|
||||||
|
|
||||||
// Template refs for navigation
|
// Template refs for navigation
|
||||||
const orgTitleRef = ref(null)
|
const orgTitleRef = ref(null)
|
||||||
@@ -16,7 +17,8 @@ const permMatrixRef = ref(null)
|
|||||||
const rolesGridRef = ref(null)
|
const rolesGridRef = ref(null)
|
||||||
|
|
||||||
const sortedRoles = computed(() => {
|
const sortedRoles = computed(() => {
|
||||||
return [...props.selectedOrg.roles].sort((a, b) => {
|
// o.roles is dict[UUID, Role], convert to array for sorting with uuid added
|
||||||
|
return Object.entries(props.selectedOrg.roles).map(([uuid, r]) => ({ uuid, ...r })).sort((a, b) => {
|
||||||
const nameA = a.display_name.toLowerCase()
|
const nameA = a.display_name.toLowerCase()
|
||||||
const nameB = b.display_name.toLowerCase()
|
const nameB = b.display_name.toLowerCase()
|
||||||
if (nameA !== nameB) {
|
if (nameA !== nameB) {
|
||||||
@@ -28,10 +30,35 @@ const sortedRoles = computed(() => {
|
|||||||
|
|
||||||
// Get org's grantable permissions as full permission objects (with UUIDs)
|
// Get org's grantable permissions as full permission objects (with UUIDs)
|
||||||
const orgPermissions = computed(() => {
|
const orgPermissions = computed(() => {
|
||||||
const uuidSet = new Set(props.selectedOrg.permissions || [])
|
// props.selectedOrg.permissions is dict[UUID, Permission]
|
||||||
|
const uuidSet = new Set(Object.keys(props.selectedOrg.permissions || {}))
|
||||||
return props.permissions.filter(p => uuidSet.has(p.uuid))
|
return props.permissions.filter(p => uuidSet.has(p.uuid))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get users for a role as sorted array of { uuid, ...user }
|
||||||
|
function roleUsers(roleUuid) {
|
||||||
|
return Object.entries(props.selectedOrg.users)
|
||||||
|
.filter(([_, u]) => u.role === roleUuid)
|
||||||
|
.map(([uuid, u]) => ({ uuid, ...u }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const nameA = a.display_name.toLowerCase()
|
||||||
|
const nameB = b.display_name.toLowerCase()
|
||||||
|
return nameA.localeCompare(nameB)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleUserCount(roleUuid) {
|
||||||
|
return Object.values(props.selectedOrg.users).filter(u => u.role === roleUuid).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUserChange(evt, targetRoleUuid) {
|
||||||
|
// Only handle 'added' events (when a user is dropped into this role)
|
||||||
|
if (evt.added) {
|
||||||
|
const userUuid = evt.added.element.uuid
|
||||||
|
emit('moveUserToRole', userUuid, targetRoleUuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function permissionDisplayName(scope) {
|
function permissionDisplayName(scope) {
|
||||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||||
}
|
}
|
||||||
@@ -83,7 +110,7 @@ function handleMatrixKeydown(event) {
|
|||||||
|
|
||||||
// Calculate grid dimensions
|
// Calculate grid dimensions
|
||||||
const cols = sortedRoles.value.length
|
const cols = sortedRoles.value.length
|
||||||
const rows = props.selectedOrg.permissions.length
|
const rows = Object.keys(props.selectedOrg.permissions).length
|
||||||
|
|
||||||
const currentRow = Math.floor(currentIndex / cols)
|
const currentRow = Math.floor(currentIndex / cols)
|
||||||
const currentCol = currentIndex % cols
|
const currentCol = currentIndex % cols
|
||||||
@@ -219,7 +246,7 @@ function handleRoleHeaderKeydown(event, roleIndex) {
|
|||||||
if (checkboxes?.length) {
|
if (checkboxes?.length) {
|
||||||
// Focus the checkbox in the corresponding column
|
// Focus the checkbox in the corresponding column
|
||||||
const cols = sortedRoles.value.length
|
const cols = sortedRoles.value.length
|
||||||
const rows = props.selectedOrg.permissions.length
|
const rows = Object.keys(props.selectedOrg.permissions).length
|
||||||
const targetIndex = (rows - 1) * cols + roleIndex
|
const targetIndex = (rows - 1) * cols + roleIndex
|
||||||
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
|
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
|
||||||
else checkboxes[checkboxes.length - 1].focus()
|
else checkboxes[checkboxes.length - 1].focus()
|
||||||
@@ -287,7 +314,7 @@ defineExpose({ focusFirstElement })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid">
|
<h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid">
|
||||||
<span class="org-name">{{ selectedOrg.display_name }}</span>
|
<span class="org-name">{{ selectedOrg.org.display_name }}</span>
|
||||||
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -317,7 +344,7 @@ defineExpose({ focusFirstElement })
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="r.permissions.includes(p.uuid)"
|
:checked="p.uuid in (r.permissions || {})"
|
||||||
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
|
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,84 +359,73 @@ defineExpose({ focusFirstElement })
|
|||||||
v-for="(r, roleIndex) in sortedRoles"
|
v-for="(r, roleIndex) in sortedRoles"
|
||||||
:key="r.uuid"
|
:key="r.uuid"
|
||||||
class="role-column"
|
class="role-column"
|
||||||
@dragover="$emit('onRoleDragOver', $event)"
|
|
||||||
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
|
|
||||||
>
|
>
|
||||||
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
|
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
|
||||||
<strong class="role-name" :title="r.uuid">
|
<strong class="role-name" :title="r.uuid">
|
||||||
<span>{{ r.display_name }}</span>
|
<span>{{ r.display_name }}</span>
|
||||||
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button>
|
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button>
|
||||||
|
<button v-if="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role">❌</button>
|
||||||
</strong>
|
</strong>
|
||||||
<div class="role-actions">
|
<div class="role-actions">
|
||||||
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="r.users.length > 0">
|
<div class="user-list-wrapper">
|
||||||
<ul class="user-list" @keydown="handleUserListKeydown">
|
<draggable
|
||||||
<li
|
:list="roleUsers(r.uuid)"
|
||||||
v-for="u in r.users.slice().sort((a, b) => {
|
group="users"
|
||||||
const nameA = a.display_name.toLowerCase()
|
item-key="uuid"
|
||||||
const nameB = b.display_name.toLowerCase()
|
tag="ul"
|
||||||
if (nameA !== nameB) {
|
class="user-list"
|
||||||
return nameA.localeCompare(nameB)
|
@change="evt => onUserChange(evt, r.uuid)"
|
||||||
}
|
@keydown="handleUserListKeydown"
|
||||||
return a.uuid.localeCompare(b.uuid)
|
>
|
||||||
})"
|
<template #item="{ element: u }">
|
||||||
:key="u.uuid"
|
<li
|
||||||
class="user-chip"
|
class="user-chip"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
draggable="true"
|
@click="$emit('openUser', u)"
|
||||||
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
|
@keydown.enter="$emit('openUser', u)"
|
||||||
@click="$emit('openUser', u)"
|
:title="u.uuid"
|
||||||
@keydown.enter="$emit('openUser', u)"
|
>
|
||||||
:title="u.uuid"
|
<span class="name">{{ u.display_name }}</span>
|
||||||
>
|
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
|
||||||
<span class="name">{{ u.display_name }}</span>
|
</li>
|
||||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
|
</template>
|
||||||
</li>
|
</draggable>
|
||||||
</ul>
|
<div v-if="roleUserCount(r.uuid) === 0" class="empty-role">
|
||||||
</template>
|
<p class="empty-text muted">No members</p>
|
||||||
<div v-else class="empty-role" @keydown="e => handleEmptyRoleKeydown(e, roleIndex)">
|
</div>
|
||||||
<p class="empty-text muted">No members</p>
|
|
||||||
<button @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role">❌</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="sortedRoles.length >= 2" class="roles-hint muted">Members can be drag&dropped to different roles.</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.card.surface { padding: var(--space-lg); }
|
.card.surface { padding: var(--space-lg); }
|
||||||
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
|
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); font-size: 1.65rem; }
|
||||||
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
|
.org-name { font-weight: 600; color: var(--color-heading); }
|
||||||
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
|
|
||||||
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
|
|
||||||
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
|
|
||||||
.matrix-scroll { overflow-x: auto; }
|
|
||||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
|
||||||
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
|
|
||||||
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
|
|
||||||
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
|
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
|
||||||
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
||||||
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
||||||
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
.roles-grid { display: flex; flex-wrap: wrap; gap: var(--space-lg); margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
||||||
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
|
.role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
||||||
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
|
|
||||||
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
||||||
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
|
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
|
||||||
.role-actions { display: flex; gap: var(--space-xs); }
|
.role-actions { display: flex; gap: var(--space-xs); }
|
||||||
.plus-btn { background: var(--color-accent-soft); color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
|
.plus-btn { background: none; color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
|
||||||
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
||||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
|
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
||||||
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
|
||||||
|
.user-chip { background: var(--color-accent-strong); color: white; border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
||||||
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||||
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
|
.user-chip .meta { font-size: 0.7rem; color: rgba(255, 255, 255, 0.8); }
|
||||||
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
|
.user-chip.sortable-ghost { opacity: 0.5; }
|
||||||
|
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||||
|
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||||
|
.user-list:has(.sortable-ghost) + .empty-role { display: none; }
|
||||||
.empty-text { margin: 0; }
|
.empty-text { margin: 0; }
|
||||||
.delete-icon { color: var(--color-danger); }
|
|
||||||
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.roles-grid { flex-direction: column; }
|
.roles-grid { flex-direction: column; }
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||||
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
info: Object,
|
info: Object,
|
||||||
orgs: Array,
|
orgs: Array,
|
||||||
permissions: Array,
|
permissions: Array,
|
||||||
|
oidcClients: Array,
|
||||||
permissionSummary: Object,
|
permissionSummary: Object,
|
||||||
navigationDisabled: { type: Boolean, default: false }
|
navigationDisabled: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut'])
|
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
|
||||||
|
|
||||||
// Template refs for navigation
|
// Template refs for navigation
|
||||||
const orgSection = ref(null)
|
const orgSection = ref(null)
|
||||||
@@ -19,11 +21,41 @@ const orgTableRef = ref(null)
|
|||||||
const permMatrixRef = ref(null)
|
const permMatrixRef = ref(null)
|
||||||
const permActionsRef = ref(null)
|
const permActionsRef = ref(null)
|
||||||
const permTableRef = ref(null)
|
const permTableRef = ref(null)
|
||||||
|
const oidcActionsRef = ref(null)
|
||||||
|
const oidcTableRef = ref(null)
|
||||||
|
|
||||||
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
||||||
const nameCompare = a.display_name.localeCompare(b.display_name)
|
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
|
||||||
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
|
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Map OIDC client UUIDs to display names for permission domain column
|
||||||
|
const oidcClientNames = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const c of props.oidcClients || []) map[c.uuid] = c.name
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
function domainDisplay(domain) {
|
||||||
|
if (!domain) return '—'
|
||||||
|
return oidcClientNames.value[domain] || domain
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map OIDC client UUIDs to their group permissions (sorted by scope)
|
||||||
|
const clientGroups = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const p of props.permissions || []) {
|
||||||
|
if (p.domain) {
|
||||||
|
if (!map[p.domain]) map[p.domain] = []
|
||||||
|
map[p.domain].push(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort each group array by scope
|
||||||
|
for (const key in map) {
|
||||||
|
map[key].sort((a, b) => a.scope.localeCompare(b.scope))
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
|
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
|
||||||
|
|
||||||
// Derive admin status from permissions (info contains ctx from validate response)
|
// Derive admin status from permissions (info contains ctx from validate response)
|
||||||
@@ -35,13 +67,18 @@ function permissionDisplayName(scope) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRoleNames(org) {
|
function getRoleNames(org) {
|
||||||
return org.roles
|
// org.roles is dict[UUID, Role]
|
||||||
|
return Object.values(org.roles)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
||||||
.map(r => r.display_name)
|
.map(r => r.display_name)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orgUserCount(org) {
|
||||||
|
return Object.keys(org.users).length
|
||||||
|
}
|
||||||
|
|
||||||
// Table navigation for both org and permissions tables
|
// Table navigation for both org and permissions tables
|
||||||
function handleTableKeydown(event, tableType) {
|
function handleTableKeydown(event, tableType) {
|
||||||
if (props.navigationDisabled) return
|
if (props.navigationDisabled) return
|
||||||
@@ -265,11 +302,11 @@ defineExpose({ focusFirstElement })
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="o in sortedOrgs" :key="o.uuid">
|
<tr v-for="o in sortedOrgs" :key="o.uuid">
|
||||||
<td>
|
<td>
|
||||||
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
|
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.org.display_name }}</a>
|
||||||
<button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
<button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="role-names">{{ getRoleNames(o) }}</td>
|
<td class="role-names">{{ getRoleNames(o) }}</td>
|
||||||
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
<td class="center">{{ orgUserCount(o) }}</td>
|
||||||
<td v-if="isMasterAdmin" class="center">
|
<td v-if="isMasterAdmin" class="center">
|
||||||
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -291,9 +328,9 @@ defineExpose({ focusFirstElement })
|
|||||||
v-for="o in sortedOrgs"
|
v-for="o in sortedOrgs"
|
||||||
:key="'head-' + o.uuid"
|
:key="'head-' + o.uuid"
|
||||||
class="grid-head org-head"
|
class="grid-head org-head"
|
||||||
:title="o.display_name"
|
:title="o.org.display_name"
|
||||||
>
|
>
|
||||||
<span>{{ o.display_name }}</span>
|
<span>{{ o.org.display_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-for="p in sortedPermissions" :key="p.uuid">
|
<template v-for="p in sortedPermissions" :key="p.uuid">
|
||||||
@@ -307,7 +344,7 @@ defineExpose({ focusFirstElement })
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="o.permissions.includes(p.uuid)"
|
:checked="p.uuid in o.permissions"
|
||||||
@change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)"
|
@change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,7 +376,7 @@ defineExpose({ focusFirstElement })
|
|||||||
<span class="id-text">{{ p.scope }}</span>
|
<span class="id-text">{{ p.scope }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="perm-domain">{{ p.domain || '—' }}</td>
|
<td class="perm-domain">{{ domainDisplay(p.domain) }}</td>
|
||||||
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
|
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
|
||||||
<td class="perm-actions center">
|
<td class="perm-actions center">
|
||||||
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
||||||
@@ -348,13 +385,67 @@ defineExpose({ focusFirstElement })
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isMasterAdmin" class="oidc-clients-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>OAuth2 / OpenID Connect</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Allow external websites and applications to securely authenticate users through this system.
|
||||||
|
The clients are remote sites or applications that we allow to use Paskia for Single Sign-On.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div ref="oidcActionsRef">
|
||||||
|
<button @click="$emit('createOidcClient')">+ Add Site</button>
|
||||||
|
</div>
|
||||||
|
<table class="org-table" ref="oidcTableRef">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Client</th>
|
||||||
|
<th scope="col">Groups</th>
|
||||||
|
<th scope="col" class="center">Sessions</th>
|
||||||
|
<th scope="col" class="center">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="!oidcClients || oidcClients.length === 0">
|
||||||
|
<td colspan="4" class="center muted">No OIDC clients configured</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="client in oidcClients" :key="client.uuid">
|
||||||
|
<td class="perm-name-cell">
|
||||||
|
<div class="perm-title">
|
||||||
|
<a :href="'#oidc:' + client.uuid" @click.prevent="$emit('openOidcClient', client)">{{ client.name }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="perm-id-info">
|
||||||
|
<span class="id-text">{{ client.uuid }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="client-groups">
|
||||||
|
<span v-if="clientGroups[client.uuid]?.length">{{ clientGroups[client.uuid].map(g => g.scope).join(' ') }}</span>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="center">{{ client.active_sessions || 0 }}</td>
|
||||||
|
<td class="center">
|
||||||
|
<button @click="$emit('deleteOidcClient', client)" class="icon-btn delete-icon" aria-label="Delete OIDC client" title="Delete OIDC client">❌</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isMasterAdmin" class="server-options-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Server</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Configure core server settings such as the display name, authentication host, and allowed origins.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button @click="$emit('openServerConfig')">⚙ Server Options</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.permissions-section { margin-bottom: var(--space-xl); }
|
.permissions-section { margin-bottom: var(--space-xl); }
|
||||||
.permissions-section h2 { margin-bottom: var(--space-md); }
|
.permissions-section h2 { margin-bottom: var(--space-md); }
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
|
||||||
.actions button { width: auto; }
|
|
||||||
.org-table a { text-decoration: none; color: var(--color-link); }
|
.org-table a { text-decoration: none; color: var(--color-link); }
|
||||||
.org-table a:hover { text-decoration: underline; }
|
.org-table a:hover { text-decoration: underline; }
|
||||||
.org-table .center { width: 6rem; min-width: 6rem; }
|
.org-table .center { width: 6rem; min-width: 6rem; }
|
||||||
@@ -363,24 +454,19 @@ defineExpose({ focusFirstElement })
|
|||||||
.perm-title { font-weight: 600; color: var(--color-heading); }
|
.perm-title { font-weight: 600; color: var(--color-heading); }
|
||||||
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
.perm-domain { color: var(--color-text-muted); font-size: 0.9rem; }
|
.perm-domain { color: var(--color-text-muted); font-size: 0.9rem; }
|
||||||
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
|
|
||||||
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
|
|
||||||
.delete-icon { color: var(--color-danger); }
|
|
||||||
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
|
|
||||||
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
|
|
||||||
.matrix-scroll { overflow-x: auto; }
|
|
||||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
|
||||||
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
|
|
||||||
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
|
|
||||||
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
|
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
|
||||||
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
||||||
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.display-text { margin-right: var(--space-xs); }
|
.display-text { margin-right: var(--space-xs); }
|
||||||
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
|
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
|
||||||
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
|
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
|
||||||
.perm-actions { text-align: center; }
|
.perm-actions { text-align: center; }
|
||||||
.center { text-align: center; }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
/* OIDC Clients Section */
|
||||||
|
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
|
||||||
|
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||||
|
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
|
||||||
|
|
||||||
|
/* Server Options Section */
|
||||||
|
.server-options-section { margin-top: var(--space-2xl); }
|
||||||
|
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,13 +17,18 @@ const props = defineProps({
|
|||||||
navigationDisabled: { type: Boolean, default: false }
|
navigationDisabled: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut'])
|
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const terminatingSessions = ref({})
|
const terminatingSessions = ref({})
|
||||||
const hoveredCredentialUuid = ref(null)
|
const hoveredCredentialUuid = ref(null)
|
||||||
const hoveredSession = ref(null)
|
const hoveredSession = ref(null)
|
||||||
|
|
||||||
|
// Convert credentials dict to array with uuid attached as 'credential'
|
||||||
|
const credentials = computed(() =>
|
||||||
|
Object.entries(props.userDetail?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||||
|
)
|
||||||
|
|
||||||
// Template refs for navigation
|
// Template refs for navigation
|
||||||
const userInfoRef = ref(null)
|
const userInfoRef = ref(null)
|
||||||
const regActionsRef = ref(null)
|
const regActionsRef = ref(null)
|
||||||
@@ -45,7 +50,7 @@ function handleEditName() {
|
|||||||
|
|
||||||
async function handleDelete(credential) {
|
async function handleDelete(credential) {
|
||||||
try {
|
try {
|
||||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||||
if (data.status === 'ok') {
|
if (data.status === 'ok') {
|
||||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||||
} else {
|
} else {
|
||||||
@@ -57,11 +62,11 @@ async function handleDelete(credential) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTerminateSession(session) {
|
async function handleTerminateSession(session) {
|
||||||
const sessionId = session?.id
|
const sessionKey = session?.key
|
||||||
if (!sessionId) return
|
if (!sessionKey) return
|
||||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
|
||||||
try {
|
try {
|
||||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionKey}`, { method: 'DELETE' })
|
||||||
if (data.status === 'ok') {
|
if (data.status === 'ok') {
|
||||||
if (data.current_session_terminated) {
|
if (data.current_session_terminated) {
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
@@ -78,11 +83,15 @@ async function handleTerminateSession(session) {
|
|||||||
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
|
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
const next = { ...terminatingSessions.value }
|
const next = { ...terminatingSessions.value }
|
||||||
delete next[sessionId]
|
delete next[sessionKey]
|
||||||
terminatingSessions.value = next
|
terminatingSessions.value = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUser() {
|
||||||
|
emit('deleteUser')
|
||||||
|
}
|
||||||
|
|
||||||
// Handle user info section keynav
|
// Handle user info section keynav
|
||||||
function handleUserInfoKeydown(event) {
|
function handleUserInfoKeydown(event) {
|
||||||
if (hasActiveModal.value || props.navigationDisabled) return
|
if (hasActiveModal.value || props.navigationDisabled) return
|
||||||
@@ -176,31 +185,37 @@ defineExpose({ focusFirstElement })
|
|||||||
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
|
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="userDetail && !userDetail.error"
|
v-if="userDetail && !userDetail.error"
|
||||||
:name="userDetail.display_name || selectedUser.display_name"
|
:name="userDetail.user.display_name || selectedUser.display_name"
|
||||||
:visits="userDetail.visits"
|
:visits="userDetail.user.visits"
|
||||||
:created-at="userDetail.created_at"
|
:created-at="userDetail.user.created_at"
|
||||||
:last-seen="userDetail.last_seen"
|
:last-seen="userDetail.user.last_seen"
|
||||||
|
:email="userDetail.user.email"
|
||||||
|
:telephone="userDetail.user.telephone"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:org-display-name="userDetail.org.display_name"
|
:org-display-name="userDetail.org.display_name"
|
||||||
:role-name="userDetail.role"
|
:role-name="userDetail.role.display_name"
|
||||||
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/display-name`"
|
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
||||||
@saved="$emit('onUserNameSaved')"
|
@saved="$emit('onUserNameSaved')"
|
||||||
@edit-name="handleEditName"
|
@edit="handleEditName"
|
||||||
/>
|
>
|
||||||
|
<div class="admin-actions">
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
@click="$emit('generateUserRegistrationLink', selectedUser)"
|
||||||
|
:disabled="loading"
|
||||||
|
title="Generate a one-time link for this user"
|
||||||
|
>{{ userDetail?.credentials && Object.keys(userDetail.credentials).length > 0 ? 'Recovery Link' : 'Registration Link' }}</button>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
@click="handleDeleteUser"
|
||||||
|
:disabled="loading"
|
||||||
|
title="Delete this user"
|
||||||
|
>Delete User</button>
|
||||||
|
</div>
|
||||||
|
</UserBasicInfo>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||||
<template v-if="userDetail && !userDetail.error">
|
<template v-if="userDetail && !userDetail.error">
|
||||||
<div class="registration-actions" ref="regActionsRef" @keydown="handleRegActionsKeydown">
|
|
||||||
<button
|
|
||||||
class="btn-secondary reg-token-btn"
|
|
||||||
@click="$emit('generateUserRegistrationLink', selectedUser)"
|
|
||||||
:disabled="loading"
|
|
||||||
>Generate Registration Token</button>
|
|
||||||
<p class="matrix-hint muted">
|
|
||||||
Generate a one-time registration link so this user can register or add another passkey.
|
|
||||||
Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<section class="section-block" data-section="registered-passkeys">
|
<section class="section-block" data-section="registered-passkeys">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Registered Passkeys</h2>
|
<h2>Registered Passkeys</h2>
|
||||||
@@ -208,7 +223,7 @@ defineExpose({ focusFirstElement })
|
|||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<CredentialList
|
<CredentialList
|
||||||
ref="credentialListRef"
|
ref="credentialListRef"
|
||||||
:credentials="userDetail.credentials"
|
:credentials="credentials"
|
||||||
:aaguid-info="userDetail.aaguid_info"
|
:aaguid-info="userDetail.aaguid_info"
|
||||||
:allow-delete="true"
|
:allow-delete="true"
|
||||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
@@ -222,7 +237,7 @@ defineExpose({ focusFirstElement })
|
|||||||
</section>
|
</section>
|
||||||
<SessionList
|
<SessionList
|
||||||
ref="sessionListRef"
|
ref="sessionListRef"
|
||||||
:sessions="userDetail.sessions || []"
|
:sessions="userDetail.sessions || {}"
|
||||||
:terminating-sessions="terminatingSessions"
|
:terminating-sessions="terminatingSessions"
|
||||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
@@ -238,7 +253,7 @@ defineExpose({ focusFirstElement })
|
|||||||
</div>
|
</div>
|
||||||
<RegistrationLinkModal
|
<RegistrationLinkModal
|
||||||
v-if="showRegModal"
|
v-if="showRegModal"
|
||||||
:endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/create-link`"
|
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
|
||||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||||
@close="$emit('closeRegModal')"
|
@close="$emit('closeRegModal')"
|
||||||
@copied="onLinkCopied"
|
@copied="onLinkCopied"
|
||||||
@@ -248,14 +263,6 @@ defineExpose({ focusFirstElement })
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
|
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
.admin-actions { display: flex; gap: 0.5rem; }
|
||||||
.ancillary-actions { margin-top: -0.5rem; }
|
.ancillary-actions { margin-top: -0.5rem; }
|
||||||
.reg-token-btn { align-self: flex-start; }
|
|
||||||
.registration-actions { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
||||||
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
|
|
||||||
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
|
|
||||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
|
||||||
.error { color: var(--color-danger-text); }
|
|
||||||
.small { font-size: 0.9rem; }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+288
-87
@@ -1,68 +1,90 @@
|
|||||||
|
|
||||||
|
@property --hue {
|
||||||
|
syntax: '<angle>';
|
||||||
|
inherits: true;
|
||||||
|
initial-value: 0.72turn;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||||
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
--color-canvas: #ffffff;
|
--color-canvas: white;
|
||||||
--color-surface: #eff6ff;
|
--color-surface: white;
|
||||||
--color-surface-subtle: #dbeafe;
|
--color-surface-subtle: white;
|
||||||
--color-border: #2563eb;
|
--color-surface-hover: oklab(0.97 -0.01 -0.02);
|
||||||
--color-border-strong: #1e40af;
|
--color-dialog: oklab(0.96 -0.01 -0.03);
|
||||||
--color-heading: #1e3a8a;
|
--color-border: oklab(0.82 -0.02 -0.06);
|
||||||
--color-text: #1e293b;
|
--color-border-strong: oklab(0.55 -0.05 -0.14);
|
||||||
--color-text-muted: #475569;
|
--color-heading: oklab(0.22 -0.03 -0.09);
|
||||||
--color-link: #1d4ed8;
|
--color-text: oklab(0.2 -0.02 -0.05);
|
||||||
--color-link-hover: #1e40af;
|
--color-text-muted: oklab(0.42 -0.02 -0.06);
|
||||||
--color-accent: #2563eb;
|
--color-link: oklab(0.5 -0.06 -0.17);
|
||||||
--color-accent-strong: #1e40af;
|
--color-link-hover: oklab(0.45 -0.06 -0.19);
|
||||||
--color-accent-contrast: #ffffff;
|
--color-accent: oklab(0.55 -0.06 -0.19);
|
||||||
--color-success-text: #166534;
|
--color-accent-strong: oklab(0.45 -0.06 -0.19);
|
||||||
--color-success-bg: #dcfce7;
|
--color-accent-contrast: white;
|
||||||
--color-error-text: #b91c1c;
|
--color-secondary: oklab(0.55 -0.02 -0.05);
|
||||||
--color-error-bg: #fee2e2;
|
--color-secondary-strong: oklab(0.45 -0.02 -0.05);
|
||||||
--color-info-text: #1e40af;
|
--color-success-text: oklab(0.4 -0.12 0.09);
|
||||||
--color-info-bg: #dbeafe;
|
--color-success-bg: oklab(0.95 -0.02 0.02);
|
||||||
--color-danger: #dc2626;
|
--color-error-text: oklab(0.45 0.18 0.09);
|
||||||
--shadow-soft: 0 10px 30px rgba(30, 64, 175, 0.15);
|
--color-error-bg: oklab(0.95 0.03 0.01);
|
||||||
--radius-none: 0;
|
--color-info-text: oklab(0.45 -0.05 -0.14);
|
||||||
|
--color-info-bg: oklab(0.95 -0.01 -0.02);
|
||||||
|
--color-danger: oklab(0.55 0.20 0.09);
|
||||||
|
--color-primary: oklab(0.55 -0.06 -0.19);
|
||||||
|
--color-error: oklab(0.45 0.18 0.09);
|
||||||
|
--color-success: oklab(0.4 -0.12 0.09);
|
||||||
|
--color-bg: oklab(0.94 -0.01 -0.03);
|
||||||
|
--color-accent-soft: oklab(0.92 -0.03 -0.08);
|
||||||
|
--shadow-soft: 0 0 .2rem black;
|
||||||
|
--shadow-xl: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius-md: 6px;
|
--radius-md: 6px;
|
||||||
--radius-lg: 10px;
|
--radius-lg: 10px;
|
||||||
--space-xxs: 0.25rem;
|
|
||||||
--space-xs: 0.5rem;
|
--space-xs: 0.5rem;
|
||||||
--space-sm: 0.75rem;
|
--space-sm: 0.75rem;
|
||||||
--space-md: 1rem;
|
--space-md: 1rem;
|
||||||
--space-lg: 1.5rem;
|
--space-lg: 1.5rem;
|
||||||
--space-xl: 2.25rem;
|
--space-xl: 2.25rem;
|
||||||
--space-xxl: 3.5rem;
|
--space-3xl: 5rem;
|
||||||
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
||||||
--transition-base: 160ms ease;
|
--transition-base: 160ms ease;
|
||||||
--focus-ring: 0 0 0 2px var(--color-accent);
|
--focus-ring: 0 0 0 2px var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root.dark {
|
||||||
:root {
|
--color-canvas: oklab(0.17 -0.02 -0.05);
|
||||||
--color-canvas: #0f172a;
|
--color-surface: oklab(0.22 -0.02 -0.05);
|
||||||
--color-surface: #141b2f;
|
--color-surface-subtle: oklab(0.25 -0.02 -0.05);
|
||||||
--color-surface-subtle: #1b243b;
|
--color-surface-hover: oklab(0.28 -0.02 -0.05);
|
||||||
--color-border: #25304a;
|
--color-dialog: oklab(0.22 -0.02 -0.05);
|
||||||
--color-border-strong: #3d4d6b;
|
--color-border: oklab(0.3 -0.02 -0.05);
|
||||||
--color-heading: #fff;
|
--color-border-strong: oklab(0.4 -0.02 -0.05);
|
||||||
--color-text: #e2e8f0;
|
--color-heading: white;
|
||||||
--color-text-muted: #94a3b8;
|
--color-text: oklab(0.9 0.00 -0.01);
|
||||||
--color-link: #60a5fa;
|
--color-text-muted: oklab(0.7 -0.01 -0.02);
|
||||||
--color-link-hover: #93c5fd;
|
--color-link: oklab(0.7 -0.05 -0.14);
|
||||||
--color-accent: #60a5fa;
|
--color-link-hover: oklab(0.8 -0.04 -0.11);
|
||||||
--color-accent-strong: #3b82f6;
|
--color-accent: oklab(0.7 -0.05 -0.14);
|
||||||
--color-accent-contrast: #0b1120;
|
--color-accent-strong: oklab(0.6 -0.06 -0.17);
|
||||||
--color-success-text: #34d399;
|
--color-accent-contrast: oklab(0.12 -0.01 -0.03);
|
||||||
--color-success-bg: #1a4d2e;
|
--color-secondary: oklab(0.6 -0.02 -0.05);
|
||||||
--color-error-text: #fca5a5;
|
--color-secondary-strong: oklab(0.5 -0.02 -0.05);
|
||||||
--color-error-bg: #4a1f1f;
|
--color-success-text: oklab(0.75 -0.12 0.09);
|
||||||
--color-info-text: #bae6fd;
|
--color-success-bg: oklab(0.3 -0.07 0.05);
|
||||||
--color-info-bg: #1e3a5f;
|
--color-error-text: oklab(0.8 0.11 0.05);
|
||||||
--color-danger: #f87171;
|
--color-error-bg: oklab(0.3 0.07 0.03);
|
||||||
--shadow-soft: 0 0 0 #000000;
|
--color-info-text: oklab(0.8 -0.03 -0.10);
|
||||||
}
|
--color-info-bg: oklab(0.3 -0.02 -0.05);
|
||||||
|
--color-danger: oklab(0.7 0.16 0.08);
|
||||||
|
--color-primary: oklab(0.7 -0.05 -0.14);
|
||||||
|
--color-error: oklab(0.8 0.11 0.05);
|
||||||
|
--color-success: oklab(0.75 -0.12 0.09);
|
||||||
|
--color-bg: oklab(0.18 -0.01 -0.03);
|
||||||
|
--color-accent-soft: oklab(0.25 -0.03 -0.08);
|
||||||
|
--shadow-soft: 0 0 0 black;
|
||||||
|
--shadow-xl: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
@@ -78,7 +100,6 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color-scheme: light dark;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -132,6 +153,7 @@ a:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.view-root {
|
.view-root {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -150,6 +172,25 @@ a:focus-visible {
|
|||||||
max-width: 540px;
|
max-width: 540px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-root--profile .view-header-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc(1200px - 2 * var(--layout-padding));
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-root--profile .theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-block--constrained {
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc(1200px - 2 * var(--layout-padding));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.view-header {
|
.view-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -197,6 +238,7 @@ a:focus-visible {
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-row button {
|
.button-row button {
|
||||||
@@ -229,6 +271,7 @@ button {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
transition: box-shadow var(--transition-base), background var(--transition-base), border-color var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
@@ -236,49 +279,53 @@ button:disabled {
|
|||||||
filter: opacity(0.6);
|
filter: opacity(0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output[title="Click to copy"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-accent);
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent);
|
||||||
color: var(--color-accent-contrast);
|
color: var(--color-accent-contrast);
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled),
|
||||||
|
.btn-primary:focus-visible {
|
||||||
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent-strong);
|
||||||
|
border-color: var(--color-accent-strong);
|
||||||
box-shadow: var(--shadow-soft);
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--color-accent-strong);
|
|
||||||
border-color: var(--color-accent-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary);
|
||||||
color: var(--color-text);
|
|
||||||
border-color: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
border-color: var(--color-border-strong);
|
|
||||||
background: var(--color-surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--color-danger);
|
|
||||||
color: var(--color-accent-contrast);
|
color: var(--color-accent-contrast);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled),
|
||||||
filter: brightness(0.92);
|
.btn-secondary:focus-visible {
|
||||||
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary-strong);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus-visible outlines for buttons */
|
.btn-danger {
|
||||||
.btn-primary:focus-visible,
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
|
||||||
.btn-secondary:focus-visible,
|
color: var(--color-accent-contrast);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled),
|
||||||
.btn-danger:focus-visible {
|
.btn-danger:focus-visible {
|
||||||
outline: 1px solid var(-webkit-focus-ring-color);
|
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
|
||||||
|
filter: brightness(0.92);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input:not([type]),
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="search"],
|
input[type="search"],
|
||||||
input[type="email"],
|
input[type="email"],
|
||||||
|
input[type="tel"],
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -291,6 +338,18 @@ select {
|
|||||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input:not([type]):focus,
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="search"]:focus,
|
||||||
|
input[type="email"]:focus,
|
||||||
|
input[type="tel"]:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -331,10 +390,118 @@ th {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Permission matrix styles */
|
||||||
|
.matrix-wrapper {
|
||||||
|
margin: var(--space-md) 0;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid {
|
||||||
|
display: inline-grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid > * {
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .grid-head {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .perm-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .rotated-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .rotated-head span {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heading);
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.center {
|
.center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 0.2rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--color-heading);
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon:hover {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row--center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -353,13 +520,17 @@ th {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
z-index: 1200;
|
z-index: 2000;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: min(520px, calc(100% - 2rem));
|
min-width: min(520px, calc(100% - 2rem));
|
||||||
max-width: calc(100% - 2rem);
|
max-width: calc(100% - 2rem);
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-status.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.global-status .status {
|
.global-status .status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -407,7 +578,7 @@ th {
|
|||||||
|
|
||||||
.device-dialog,
|
.device-dialog,
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--color-surface);
|
background: var(--color-dialog);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
width: min(520px, 100%);
|
width: min(520px, 100%);
|
||||||
@@ -429,8 +600,7 @@ th {
|
|||||||
|
|
||||||
.qr-code {
|
.qr-code {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #fff;
|
background: white;
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-container,
|
.link-container,
|
||||||
@@ -487,9 +657,18 @@ th {
|
|||||||
|
|
||||||
.record-item.is-current,
|
.record-item.is-current,
|
||||||
.credential-item.current-session,
|
.credential-item.current-session,
|
||||||
|
.session-item.is-current {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background-color: var(--color-surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
.credential-item.is-hovered,
|
.credential-item.is-hovered,
|
||||||
.session-item.is-current,
|
.session-item.is-hovered {
|
||||||
.session-item.is-hovered { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
|
border-color: var(--color-accent);
|
||||||
|
background-color: var(--color-surface-subtle);
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.credential-item.is-linked-session,
|
.credential-item.is-linked-session,
|
||||||
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
|
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
|
||||||
@@ -567,7 +746,7 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
.btn-card-delete:hover:not(:disabled) { background: #fee; }
|
.btn-card-delete:hover:not(:disabled) { filter: brightness(0.85); }
|
||||||
.btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
|
.btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
|
||||||
|
|
||||||
|
|
||||||
@@ -644,9 +823,6 @@ th {
|
|||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
display: grid;
|
display: grid;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
padding: 1.1rem 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-details {
|
.user-details {
|
||||||
@@ -692,8 +868,8 @@ th {
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
backdrop-filter: blur(.2rem) brightness(0.7);
|
||||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -711,7 +887,7 @@ th {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
background: var(--color-surface);
|
background: var(--color-dialog);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 20px 60px #1e293b;
|
box-shadow: 0 20px 60px #1e293b;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -733,7 +909,7 @@ th {
|
|||||||
|
|
||||||
.slot-machine {
|
.slot-machine {
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
background: var(--color-surface-hover);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
@@ -754,3 +930,28 @@ th {
|
|||||||
height: 1.8em;
|
height: 1.8em;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Spinner utilities */
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner--sm {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner--md {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ const getCredentialAuthIcon = (credential) => {
|
|||||||
const info = props.aaguidInfo?.[credential.aaguid]
|
const info = props.aaguidInfo?.[credential.aaguid]
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
// Fall back to icon if icon_dark is not available
|
||||||
return info[iconKey] || null
|
return (isDarkMode && info.icon_dark) || info.icon || null
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root host-view" data-view="host-profile">
|
<section class="view-root view-root--wide host-view" data-view="host-profile">
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<h1>{{ headingTitle }}</h1>
|
<h1>{{ headingTitle }}</h1>
|
||||||
<p class="view-lede">{{ subheading }}</p>
|
<p class="view-lede">{{ subheading }}</p>
|
||||||
@@ -10,9 +10,11 @@
|
|||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="ctx"
|
v-if="ctx"
|
||||||
:name="ctx.user.display_name"
|
:name="ctx.user.display_name"
|
||||||
:visits="authStore.userInfo?.visits || 0"
|
:visits="authStore.userInfo.user.visits"
|
||||||
:created-at="authStore.userInfo?.created_at"
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
:last-seen="authStore.userInfo?.last_seen"
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
|
:email="ctx.user.email"
|
||||||
|
:telephone="ctx.user.telephone"
|
||||||
:org-display-name="orgDisplayName"
|
:org-display-name="orgDisplayName"
|
||||||
:role-name="roleDisplayName"
|
:role-name="roleDisplayName"
|
||||||
:can-edit="false"
|
:can-edit="false"
|
||||||
@@ -78,9 +80,9 @@ const currentHost = window.location.host
|
|||||||
const userInfoSection = ref(null)
|
const userInfoSection = ref(null)
|
||||||
const buttonRow = ref(null)
|
const buttonRow = ref(null)
|
||||||
|
|
||||||
const ctx = computed(() => authStore.userInfo?.ctx || null)
|
const ctx = computed(() => authStore.userInfo || null)
|
||||||
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '')
|
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
|
||||||
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '')
|
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
|
||||||
|
|
||||||
const headingTitle = computed(() => {
|
const headingTitle = computed(() => {
|
||||||
const service = authStore.settings?.rp_name
|
const service = authStore.settings?.rp_name
|
||||||
@@ -125,12 +127,3 @@ const handleButtonRowKeydown = (event) => {
|
|||||||
// Down does nothing (no elements below to navigate to)
|
// Down does nothing (no elements below to navigate to)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.host-view { padding: 3rem 1.5rem 4rem; }
|
|
||||||
.host-actions { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
||||||
.host-actions .button-row { gap: 0.75rem; flex-wrap: wrap; }
|
|
||||||
.host-actions .button-row button { flex: 1 1 0; }
|
|
||||||
.note { margin: 0; color: var(--color-text-muted); }
|
|
||||||
.empty-state { margin: 0; color: var(--color-text-muted); }
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -27,17 +27,12 @@ defineProps({
|
|||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 4px solid var(--color-border);
|
border: 3px solid var(--color-border);
|
||||||
border-top: 4px solid var(--color-primary);
|
border-top-color: var(--color-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container p {
|
.loading-container p {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
|
<div class="dialog-overlay" @click="$emit('close')">
|
||||||
<slot />
|
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
|
||||||
</dialog>
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
|
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
|
||||||
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// Optional: provide a fallback element to focus if original element is gone
|
// Optional: provide a fallback element to focus if original element is gone
|
||||||
@@ -17,7 +20,7 @@ const props = defineProps({
|
|||||||
focusSiblingSelector: { type: String, default: '' }
|
focusSiblingSelector: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
// Dialog element reference
|
// Dialog element reference
|
||||||
const dialog = ref(null)
|
const dialog = ref(null)
|
||||||
@@ -76,6 +79,13 @@ const restoreFocus = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDialogKeydown = (event) => {
|
const handleDialogKeydown = (event) => {
|
||||||
|
// ESC to close (previously handled by <dialog> natively)
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const direction = getDirection(event)
|
const direction = getDirection(event)
|
||||||
if (!direction) return
|
if (!direction) return
|
||||||
|
|
||||||
@@ -111,11 +121,11 @@ onMounted(() => {
|
|||||||
// Save currently focused element before modal takes focus
|
// Save currently focused element before modal takes focus
|
||||||
previouslyFocusedElement.value = document.activeElement
|
previouslyFocusedElement.value = document.activeElement
|
||||||
|
|
||||||
// Show the dialog as a modal
|
holdGlobalBackdrop()
|
||||||
|
|
||||||
|
// Focus the most appropriate element
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (dialog.value) {
|
if (dialog.value) {
|
||||||
dialog.value.showModal()
|
|
||||||
|
|
||||||
// Autofocus the most appropriate element:
|
// Autofocus the most appropriate element:
|
||||||
// - For form dialogs (rename, edit): focus first input and select text
|
// - For form dialogs (rename, edit): focus first input and select text
|
||||||
// - For other dialogs: focus primary button (or fallback)
|
// - For other dialogs: focus primary button (or fallback)
|
||||||
@@ -131,14 +141,16 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
// Restore focus when modal closes
|
// Restore focus when modal closes
|
||||||
restoreFocus()
|
restoreFocus()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
dialog {
|
.modal-panel {
|
||||||
background: var(--color-surface);
|
background: var(--color-dialog);
|
||||||
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: var(--shadow-xl);
|
||||||
@@ -147,65 +159,36 @@ dialog {
|
|||||||
width: min(500px, 90vw);
|
width: min(500px, 90vw);
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
margin: auto;
|
|
||||||
height: fit-content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog::backdrop {
|
.modal-panel :deep(.modal-title),
|
||||||
background: transparent;
|
.modal-panel :deep(h3) {
|
||||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog :deep(.modal-title),
|
|
||||||
dialog :deep(h3) {
|
|
||||||
margin: 0 0 var(--space-md);
|
margin: 0 0 var(--space-md);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog :deep(form) {
|
.modal-panel :deep(form) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog :deep(.modal-form) {
|
.modal-panel :deep(.modal-form) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog :deep(.modal-form label) {
|
.modal-panel :deep(.modal-form label) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog :deep(.modal-form input),
|
.modal-panel :deep(.modal-actions) {
|
||||||
dialog :deep(.modal-form textarea) {
|
|
||||||
padding: var(--space-md);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog :deep(.modal-form input:focus),
|
|
||||||
dialog :deep(.modal-form textarea:focus) {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
box-shadow: 0 0 0 2px #c7d2fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog :deep(.modal-actions) {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
|||||||
@@ -84,12 +84,4 @@ function handleCancel() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
color: var(--color-danger-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.small {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root" data-view="profile">
|
<section class="view-root view-root--profile" data-view="profile">
|
||||||
<header class="view-header">
|
<div class="view-header-wrapper">
|
||||||
<h1>User Profile</h1>
|
<div class="theme-toggle">
|
||||||
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
<ThemeSelector />
|
||||||
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
|
</div>
|
||||||
</header>
|
<header class="view-header">
|
||||||
|
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||||
|
<p class="view-lede">Account dashboard to manage your profile and authentications.</p>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="section-block" ref="userInfoSection">
|
<section class="section-block section-block--constrained" ref="userInfoSection">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="authStore.userInfo?.ctx"
|
v-if="authStore.userInfo?.user"
|
||||||
ref="userBasicInfo"
|
ref="userBasicInfo"
|
||||||
:name="authStore.userInfo.ctx.user.display_name"
|
:name="authStore.userInfo.user.display_name"
|
||||||
:visits="authStore.userInfo.visits"
|
:email="authStore.userInfo.user.email"
|
||||||
:created-at="authStore.userInfo.created_at"
|
:preferred_username="authStore.userInfo.user.preferred_username"
|
||||||
:last-seen="authStore.userInfo.last_seen"
|
:telephone="authStore.userInfo.user.telephone"
|
||||||
|
:visits="authStore.userInfo.user.visits"
|
||||||
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
:loading="authStore.isLoading"
|
:loading="authStore.isLoading"
|
||||||
update-endpoint="/auth/api/user/display-name"
|
:org-display-name="authStore.userInfo.org.display_name"
|
||||||
|
:role-name="authStore.userInfo.role.display_name"
|
||||||
|
update-endpoint="/auth/api/user/info"
|
||||||
@saved="authStore.loadUserInfo()"
|
@saved="authStore.loadUserInfo()"
|
||||||
@edit-name="openNameDialog"
|
@edit="openEditDialog"
|
||||||
@keydown="handleUserInfoKeydown"
|
@keydown="handleUserInfoKeydown"
|
||||||
>
|
>
|
||||||
<div class="remote-auth-inline">
|
<div class="remote-auth-inline">
|
||||||
@@ -31,11 +40,11 @@
|
|||||||
@device-info-visible="showDeviceInfo = $event"
|
@device-info-visible="showDeviceInfo = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="remote-auth-description">Provided by another device requesting remote auth.</p>
|
<p class="remote-auth-description">Login from another device</p>
|
||||||
</UserBasicInfo>
|
</UserBasicInfo>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-block">
|
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Your Passkeys</h2>
|
<h2>Your Passkeys</h2>
|
||||||
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
||||||
@@ -43,8 +52,8 @@
|
|||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<CredentialList
|
<CredentialList
|
||||||
ref="credentialList"
|
ref="credentialList"
|
||||||
:credentials="authStore.userInfo?.credentials || []"
|
:credentials="credentials"
|
||||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
:aaguid-info="authStore.userInfo.aaguid_info"
|
||||||
:loading="authStore.isLoading"
|
:loading="authStore.isLoading"
|
||||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
:hovered-session-credential-uuid="hoveredSession?.credential"
|
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||||
@@ -67,25 +76,14 @@
|
|||||||
:terminating-sessions="terminatingSessions"
|
:terminating-sessions="terminatingSessions"
|
||||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
|
:section-class="useWideLayout ? '' : 'section-block--constrained'"
|
||||||
@terminate="terminateSession"
|
@terminate="terminateSession"
|
||||||
@session-hover="hoveredSession = $event"
|
@session-hover="hoveredSession = $event"
|
||||||
@navigate-out="handleSessionNavigateOut"
|
@navigate-out="handleSessionNavigateOut"
|
||||||
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
|
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
||||||
<h3>Edit Display Name</h3>
|
|
||||||
<form @submit.prevent="saveName" class="modal-form">
|
|
||||||
<NameEditForm
|
|
||||||
label="Display Name"
|
|
||||||
v-model="newName"
|
|
||||||
:busy="saving"
|
|
||||||
@cancel="showNameDialog = false"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<section class="section-block">
|
|
||||||
<div class="button-row" ref="logoutButtons">
|
<div class="button-row" ref="logoutButtons">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -101,9 +99,38 @@
|
|||||||
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
|
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
<div class="logout-footer">
|
||||||
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p>
|
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
||||||
|
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}.</p>
|
||||||
|
<a class="paskia-version" href="https://git.zi.fi/leovasanko/paskia" target="_blank" rel="noopener noreferrer">Paskia {{ paskiaVersion }}</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Modal v-if="showEditDialog" @close="showEditDialog = false">
|
||||||
|
<h3>Edit Profile</h3>
|
||||||
|
<form @submit.prevent="saveProfile" class="modal-form">
|
||||||
|
<div class="profile-edit-form">
|
||||||
|
<label for="edit-display-name">Display Name
|
||||||
|
<input id="edit-display-name" type="text" v-model="editName" :disabled="saving" required />
|
||||||
|
</label>
|
||||||
|
<label for="edit-email">Email
|
||||||
|
<input id="edit-email" type="email" v-model="editEmail" :disabled="saving" />
|
||||||
|
</label>
|
||||||
|
<label for="edit-username">Preferred Username
|
||||||
|
<input id="edit-username" type="text" v-model="editUsername" :disabled="saving" placeholder="username" />
|
||||||
|
</label>
|
||||||
|
<label for="edit-telephone">Telephone
|
||||||
|
<input id="edit-telephone" type="tel" v-model="editTelephone" :disabled="saving" />
|
||||||
|
</label>
|
||||||
|
<div v-if="editError" class="error small">{{ editError }}</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" @click="showEditDialog = false" :disabled="saving">Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="saving" data-nav-primary>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<RegistrationLinkModal
|
<RegistrationLinkModal
|
||||||
v-if="showRegLink"
|
v-if="showRegLink"
|
||||||
endpoint="/auth/api/user/create-link"
|
endpoint="/auth/api/user/create-link"
|
||||||
@@ -117,9 +144,9 @@
|
|||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
|
import ThemeSelector from '@/components/ThemeSelector.vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import NameEditForm from '@/components/NameEditForm.vue'
|
|
||||||
import SessionList from '@/components/SessionList.vue'
|
import SessionList from '@/components/SessionList.vue'
|
||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
|
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
|
||||||
@@ -132,10 +159,14 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
|
|||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const updateInterval = ref(null)
|
const updateInterval = ref(null)
|
||||||
const showNameDialog = ref(false)
|
const showEditDialog = ref(false)
|
||||||
const showRegLink = ref(false)
|
const showRegLink = ref(false)
|
||||||
const newName = ref('')
|
const editName = ref('')
|
||||||
|
const editEmail = ref('')
|
||||||
|
const editUsername = ref('')
|
||||||
|
const editTelephone = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const editError = ref('')
|
||||||
const hoveredCredentialUuid = ref(null)
|
const hoveredCredentialUuid = ref(null)
|
||||||
const hoveredSession = ref(null)
|
const hoveredSession = ref(null)
|
||||||
const showDeviceInfo = ref(false)
|
const showDeviceInfo = ref(false)
|
||||||
@@ -149,9 +180,17 @@ const userBasicInfo = ref(null)
|
|||||||
const userInfoSection = ref(null)
|
const userInfoSection = ref(null)
|
||||||
|
|
||||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||||
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
|
const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
|
||||||
|
|
||||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' })
|
watch(showEditDialog, (open) => {
|
||||||
|
if (!open) return
|
||||||
|
const user = authStore.userInfo.user
|
||||||
|
editName.value = user.display_name ?? ''
|
||||||
|
editEmail.value = user.email ?? ''
|
||||||
|
editUsername.value = user.preferred_username ?? ''
|
||||||
|
editTelephone.value = user.telephone ?? ''
|
||||||
|
editError.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||||
@@ -301,59 +340,92 @@ const handleDelete = async (credential) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
||||||
const sessions = computed(() => authStore.userInfo?.sessions || [])
|
const paskiaVersion = computed(() => authStore.settings?.version || '')
|
||||||
|
const sessions = computed(() => authStore.userInfo.sessions)
|
||||||
const currentSessionHost = computed(() => {
|
const currentSessionHost = computed(() => {
|
||||||
const currentSession = sessions.value.find(session => session.is_current)
|
const currentSession = Object.values(sessions.value).find(session => session.is_current)
|
||||||
return currentSession?.host || 'this host'
|
return currentSession?.host || 'this host'
|
||||||
})
|
})
|
||||||
const terminatingSessions = ref({})
|
const terminatingSessions = ref({})
|
||||||
|
|
||||||
const terminateSession = async (session) => {
|
const terminateSession = async (session) => {
|
||||||
const sessionId = session?.id
|
const sessionKey = session?.key
|
||||||
if (!sessionId) return
|
if (!sessionKey) return
|
||||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
|
||||||
try { await authStore.terminateSession(sessionId) }
|
try { await authStore.terminateSession(sessionKey) }
|
||||||
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
|
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
|
||||||
finally {
|
finally {
|
||||||
const next = { ...terminatingSessions.value }
|
const next = { ...terminatingSessions.value }
|
||||||
delete next[sessionId]
|
delete next[sessionKey]
|
||||||
terminatingSessions.value = next
|
terminatingSessions.value = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||||
const logout = async () => { await authStore.logout() }
|
const logout = async () => { await authStore.logout() }
|
||||||
const openNameDialog = () => { newName.value = authStore.userInfo?.ctx.user.display_name ?? ''; showNameDialog.value = true }
|
const openEditDialog = () => { showEditDialog.value = true }
|
||||||
const isAdmin = computed(() => {
|
const isAdmin = computed(() => {
|
||||||
const perms = authStore.userInfo?.ctx.permissions
|
const perms = Object.values(authStore.userInfo.permissions).map(p => p.scope)
|
||||||
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
||||||
})
|
})
|
||||||
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
|
||||||
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
const credentials = computed(() =>
|
||||||
|
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||||
|
)
|
||||||
|
const useWideLayout = computed(() => {
|
||||||
|
// Check if any single site has more than 8 sessions
|
||||||
|
const groups = {}
|
||||||
|
for (const session of Object.values(sessions.value)) {
|
||||||
|
const host = session.host || ''
|
||||||
|
if (!groups[host]) groups[host] = []
|
||||||
|
groups[host].push(session)
|
||||||
|
}
|
||||||
|
const hasLargeSessionGroup = Object.values(groups).some(group => group.length > 8)
|
||||||
|
|
||||||
const saveName = async () => {
|
// Check if passkeys exceed 8
|
||||||
const name = newName.value.trim()
|
const hasManyCredentials = credentials.value.length > 8
|
||||||
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
|
||||||
|
return hasLargeSessionGroup || hasManyCredentials
|
||||||
|
})
|
||||||
|
const breadcrumbEntries = computed(() => { const entries = [{ label: 'My Profile', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
||||||
|
|
||||||
|
const saveProfile = async () => {
|
||||||
|
const name = editName.value.trim()
|
||||||
|
if (!name) { editError.value = 'Name cannot be empty'; return }
|
||||||
|
const user = authStore.userInfo.user
|
||||||
|
const emailVal = editEmail.value.trim() || null
|
||||||
|
const usernameVal = editUsername.value.trim() || null
|
||||||
|
const telephoneVal = editTelephone.value.trim() || null
|
||||||
try {
|
try {
|
||||||
|
editError.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })
|
const body = {}
|
||||||
showNameDialog.value = false
|
if (name !== user.display_name) body.display_name = name
|
||||||
await authStore.loadUserInfo()
|
if (emailVal !== (user.email || null)) body.email = emailVal
|
||||||
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
|
||||||
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
||||||
finally { saving.value = false }
|
if (Object.keys(body).length) {
|
||||||
|
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
||||||
|
await authStore.loadUserInfo()
|
||||||
|
authStore.showMessage('Profile updated!', 'success', 3000)
|
||||||
|
}
|
||||||
|
showEditDialog.value = false
|
||||||
|
} catch (e) {
|
||||||
|
editError.value = e.message || 'Failed to update profile'
|
||||||
|
} finally { saving.value = false }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
|
|
||||||
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
|
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
|
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
|
||||||
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
.logout-note { margin: 0; color: var(--color-text-muted); }
|
||||||
|
.logout-footer { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-top: 0.75rem; }
|
||||||
|
.paskia-version { color: var(--color-text-muted); text-decoration: none; white-space: nowrap; font-weight: 700; }
|
||||||
|
.paskia-version:hover { color: var(--color-link-hover); }
|
||||||
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
|
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
|
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
|
||||||
.remote-auth-description {
|
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
font-size: 0.75rem;
|
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
|
||||||
color: var(--color-text-muted);
|
.profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
|
<div v-if="linkUrl" class="dialog-overlay" @click="$emit('close')">
|
||||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
|
||||||
|
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||||
<div class="reg-header-row">
|
<div class="reg-header-row">
|
||||||
<h2 id="regTitle" class="reg-title">
|
<h2 id="regTitle" class="reg-title">
|
||||||
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
|
📱 <span v-if="userName">{{ tokenType === 'account recovery' ? 'Recovery' : 'Registration' }} for {{ userName }}</span><span v-else>Add Another Device</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1">✕</button>
|
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="device-link-section">
|
<div class="device-link-section">
|
||||||
<p class="reg-help">
|
<p class="reg-help">
|
||||||
Scan this QR code on the new device, or copy the link and open it there.
|
{{ helpText }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<QRCodeDisplay
|
<QRCodeDisplay
|
||||||
@@ -28,14 +29,15 @@
|
|||||||
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
|
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
|
||||||
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||||
import { apiJson } from 'paskia'
|
import { apiJson, AuthCancelledError, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
import { getDirection } from '@/utils/keynav'
|
import { getDirection } from '@/utils/keynav'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -51,32 +53,46 @@ const authStore = useAuthStore()
|
|||||||
const dialog = ref(null)
|
const dialog = ref(null)
|
||||||
const linkUrl = ref(null)
|
const linkUrl = ref(null)
|
||||||
const expiresAt = ref(null)
|
const expiresAt = ref(null)
|
||||||
|
const tokenType = ref(null)
|
||||||
const actionsRow = ref(null)
|
const actionsRow = ref(null)
|
||||||
// Store the element that had focus before modal opened
|
// Store the element that had focus before modal opened
|
||||||
const previouslyFocusedElement = ref(null)
|
const previouslyFocusedElement = ref(null)
|
||||||
|
|
||||||
|
// Determine if this is an admin action for another user
|
||||||
|
const isAdminAction = computed(() => !!props.userName)
|
||||||
|
|
||||||
|
// Compute the help text based on token type and context
|
||||||
|
const helpText = computed(() => {
|
||||||
|
if (!isAdminAction.value) {
|
||||||
|
// User adding their own device
|
||||||
|
return 'Scan this QR code on the new device, or copy the link and open it there.'
|
||||||
|
}
|
||||||
|
// Admin action for another user
|
||||||
|
return `Send this link to ${props.userName}, or have them scan the QR code.`
|
||||||
|
})
|
||||||
|
|
||||||
async function generateLink() {
|
async function generateLink() {
|
||||||
try {
|
try {
|
||||||
const data = await apiJson(props.endpoint, { method: 'POST' })
|
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||||
if (data.url) {
|
if (data.url) {
|
||||||
linkUrl.value = data.url
|
linkUrl.value = data.url
|
||||||
expiresAt.value = data.expires ? new Date(data.expires) : null
|
expiresAt.value = data.expires ? new Date(data.expires) : null
|
||||||
|
tokenType.value = data.token_type || null
|
||||||
|
|
||||||
// Show the dialog as modal
|
holdGlobalBackdrop()
|
||||||
|
|
||||||
|
// Focus primary button (or first button if no primary) after content renders
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (dialog.value) {
|
const actions = actionsRow.value
|
||||||
dialog.value.showModal()
|
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
|
||||||
|
target?.focus()
|
||||||
// Focus primary button (or first button if no primary) after content renders
|
|
||||||
const actions = actionsRow.value
|
|
||||||
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
|
|
||||||
target?.focus()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to generate link', 'error')
|
if (!(e instanceof AuthCancelledError)) {
|
||||||
|
authStore.showMessage(e.message || 'Failed to generate link', 'error')
|
||||||
|
}
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +102,12 @@ function onCopied() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDialogKeydown = (event) => {
|
const handleDialogKeydown = (event) => {
|
||||||
// ESC is handled automatically by <dialog>
|
// ESC to close
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
// Handle other key navigation
|
// Handle other key navigation
|
||||||
const direction = getDirection(event)
|
const direction = getDirection(event)
|
||||||
if (!direction) return
|
if (!direction) return
|
||||||
@@ -132,6 +153,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (linkUrl.value) releaseGlobalBackdrop()
|
||||||
// Restore focus when modal closes
|
// Restore focus when modal closes
|
||||||
const prev = previouslyFocusedElement.value
|
const prev = previouslyFocusedElement.value
|
||||||
if (prev && document.body.contains(prev) && !prev.disabled) {
|
if (prev && document.body.contains(prev) && !prev.disabled) {
|
||||||
@@ -141,23 +163,6 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
dialog {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
max-width: none;
|
|
||||||
width: fit-content;
|
|
||||||
height: fit-content;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog::backdrop {
|
|
||||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
|
||||||
backdrop-filter: blur(.2rem) brightness(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
|
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
|
||||||
.icon-btn:hover { opacity: 1; }
|
.icon-btn:hover { opacity: 1; }
|
||||||
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
|
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
|
||||||
|
|||||||
@@ -53,17 +53,16 @@
|
|||||||
<!-- Device info display (shown when 3 words match a request) -->
|
<!-- Device info display (shown when 3 words match a request) -->
|
||||||
<div v-else-if="deviceInfo" class="device-info">
|
<div v-else-if="deviceInfo" class="device-info">
|
||||||
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
||||||
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p>
|
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
|
||||||
|
|
||||||
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
|
<p v-if="error" class="error-message">{{ error }}</p>
|
||||||
|
|
||||||
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
<div class="button-row device-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="deny"
|
@click="deny"
|
||||||
style="flex: 1;"
|
|
||||||
>
|
>
|
||||||
Deny
|
Deny
|
||||||
</button>
|
</button>
|
||||||
@@ -72,7 +71,6 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
style="flex: 1;"
|
|
||||||
>
|
>
|
||||||
{{ loading ? 'Authenticating…' : 'Authorize' }}
|
{{ loading ? 'Authenticating…' : 'Authorize' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -773,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
|||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 280px;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,10 +918,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-info {
|
.device-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -945,9 +938,18 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
margin: 0;
|
margin: 0.5rem 0 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-error, #ef4444);
|
color: var(--color-error, #ef4444);
|
||||||
margin-bottom: 1rem;
|
}
|
||||||
|
|
||||||
|
.device-actions {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions button {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-else-if="error" class="error-section">
|
<div v-else-if="error" class="error-section">
|
||||||
<p class="error-message">{{ error }}</p>
|
<p class="error-message">{{ error }}</p>
|
||||||
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button>
|
<button class="btn-primary" @click="retry">Try Again</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connecting phase -->
|
<!-- Connecting phase -->
|
||||||
@@ -163,6 +163,9 @@ async function startRemoteAuth() {
|
|||||||
|
|
||||||
// PoW challenge
|
// PoW challenge
|
||||||
const powChallenge = await ws.receive_json()
|
const powChallenge = await ws.receive_json()
|
||||||
|
if (powChallenge.status) {
|
||||||
|
throw new Error(powChallenge.detail || `Failed to connect: ${powChallenge.status}`)
|
||||||
|
}
|
||||||
if (powChallenge.pow) {
|
if (powChallenge.pow) {
|
||||||
const challenge = b64dec(powChallenge.pow.challenge)
|
const challenge = b64dec(powChallenge.pow.challenge)
|
||||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||||
@@ -196,7 +199,7 @@ async function startRemoteAuth() {
|
|||||||
} else if (msg.status === 'authenticated') {
|
} else if (msg.status === 'authenticated') {
|
||||||
// Success
|
// Success
|
||||||
completed.value = true
|
completed.value = true
|
||||||
emit('authenticated', { session_token: msg.session_token })
|
emit('authenticated', { exchange_code: msg.exchange_code })
|
||||||
break
|
break
|
||||||
} else if (msg.status === 'denied') {
|
} else if (msg.status === 'denied') {
|
||||||
// Explicitly denied by the authenticating device
|
// Explicitly denied by the authenticating device
|
||||||
@@ -293,10 +296,6 @@ defineExpose({ retry, cancel })
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-display {
|
.auth-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -499,6 +498,10 @@ defineExpose({ retry, cancel })
|
|||||||
color: var(--color-error, #ef4444);
|
color: var(--color-error, #ef4444);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-section button {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.auth-content {
|
.auth-content {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<div v-if="status.show" class="global-status" style="display: block;">
|
<div v-if="status.show" class="global-status show">
|
||||||
<div :class="['status', status.type]">
|
<div :class="['status', status.type]">
|
||||||
{{ status.message }}
|
{{ status.message }}
|
||||||
</div>
|
</div>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<div class="section-body center">
|
<div class="section-body center">
|
||||||
<!-- Local passkey authentication view -->
|
<!-- Local passkey authentication view -->
|
||||||
<div v-if="authView === 'local'" class="auth-view">
|
<div v-if="authView === 'local'" class="auth-view">
|
||||||
<div class="button-row center" ref="buttonRow">
|
<div class="button-row button-row--center" ref="buttonRow">
|
||||||
<slot name="actions"
|
<slot name="actions"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:can-authenticate="canAuthenticate"
|
:can-authenticate="canAuthenticate"
|
||||||
@@ -61,12 +61,17 @@ import { getSettings, uiBasePath } from '@/utils/settings'
|
|||||||
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
|
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
|
||||||
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
||||||
import { focusDialogButton } from '@/utils/keynav'
|
import { focusDialogButton } from '@/utils/keynav'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
mode: {
|
mode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'login',
|
default: 'login',
|
||||||
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
|
validator: (value) => ['login', 'reauth', 'forbidden', 'oidc'].includes(value)
|
||||||
|
},
|
||||||
|
oidcQueryString: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -143,6 +148,7 @@ async function fetchSettings() {
|
|||||||
async function validateSession() {
|
async function validateSession() {
|
||||||
try {
|
try {
|
||||||
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
|
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
|
||||||
|
updateThemeFromSession(session.value?.ctx)
|
||||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||||
currentView.value = 'forbidden'
|
currentView.value = 'forbidden'
|
||||||
emit('forbidden', session.value)
|
emit('forbidden', session.value)
|
||||||
@@ -163,7 +169,7 @@ async function authenticateUser() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
showMessage('Starting authentication…', 'info')
|
showMessage('Starting authentication…', 'info')
|
||||||
let result
|
let result
|
||||||
try { result = await passkey.authenticate() } catch (error) {
|
try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
const message = error?.message || 'Passkey authentication cancelled'
|
const message = error?.message || 'Passkey authentication cancelled'
|
||||||
const cancelled = message === 'Passkey authentication cancelled'
|
const cancelled = message === 'Passkey authentication cancelled'
|
||||||
@@ -171,7 +177,13 @@ async function authenticateUser() {
|
|||||||
emit('auth-error', { message, cancelled })
|
emit('auth-error', { message, cancelled })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try { await setSessionCookie(result) } catch (error) {
|
// OIDC flow: no session cookie, just emit the redirect_url
|
||||||
|
if (result.redirect_url) {
|
||||||
|
loading.value = false
|
||||||
|
emit('authenticated', result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { await exchangeCode(result) } catch (error) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
const message = error?.message || 'Failed to establish session'
|
const message = error?.message || 'Failed to establish session'
|
||||||
showMessage(message, 'error', 4000)
|
showMessage(message, 'error', 4000)
|
||||||
@@ -202,13 +214,13 @@ function openProfile() {
|
|||||||
if (profileWindow) profileWindow.focus()
|
if (profileWindow) profileWindow.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSessionCookie(result) {
|
async function exchangeCode(result) {
|
||||||
if (!result?.session_token) {
|
if (!result?.exchange_code) {
|
||||||
console.error('setSessionCookie called with missing session_token:', result)
|
console.error('exchangeCode called with missing exchange_code:', result)
|
||||||
throw new Error('Authentication response missing session_token')
|
throw new Error('Authentication response missing exchange_code')
|
||||||
}
|
}
|
||||||
return await fetchJson('/auth/api/set-session', {
|
return await fetchJson('/auth/api/set-session', {
|
||||||
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` }
|
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +235,7 @@ function switchToLocal() {
|
|||||||
async function handleRemoteAuthenticated(result) {
|
async function handleRemoteAuthenticated(result) {
|
||||||
showMessage('Authenticated from another device!', 'success', 2000)
|
showMessage('Authenticated from another device!', 'success', 2000)
|
||||||
try {
|
try {
|
||||||
await setSessionCookie(result)
|
await exchangeCode(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error?.message || 'Failed to establish session'
|
const message = error?.message || 'Failed to establish session'
|
||||||
showMessage(message, 'error', 4000)
|
showMessage(message, 'error', 4000)
|
||||||
@@ -265,7 +277,12 @@ watch(initializing, (newVal) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchSettings()
|
await fetchSettings()
|
||||||
await validateSession()
|
// OIDC mode doesn't depend on session state - skip validation
|
||||||
|
if (props.mode !== 'oidc') {
|
||||||
|
await validateSession()
|
||||||
|
} else {
|
||||||
|
currentView.value = 'login'
|
||||||
|
}
|
||||||
initializing.value = false
|
initializing.value = false
|
||||||
|
|
||||||
// Add click handler for inline links
|
// Add click handler for inline links
|
||||||
@@ -284,7 +301,6 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
|
|
||||||
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
|
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
|
||||||
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||||
.surface.surface--tight {
|
.surface.surface--tight {
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="section-block" data-component="session-list-section">
|
<section :class="['section-block', sectionClass]" data-component="session-list-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Active Sessions</h2>
|
<h2>Active Sessions</h2>
|
||||||
<p class="section-description">{{ sectionDescription }}</p>
|
<p class="section-description">{{ sectionDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<div>
|
<div>
|
||||||
<template v-if="Array.isArray(sessions) && sessions.length">
|
<template v-if="sessionsArray.length">
|
||||||
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
|
<div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
|
||||||
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
|
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
|
||||||
<span class="session-group-icon">🌐</span>
|
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
|
||||||
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
|
<template v-if="group.isOIDC">{{ group.displayName }}</template>
|
||||||
|
<a v-else-if="key" :href="hostUrl(key)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ key }}</a>
|
||||||
<template v-else>Unbound host</template>
|
<template v-else>Unbound host</template>
|
||||||
</span>
|
</span>
|
||||||
<div class="session-list">
|
<div class="session-list">
|
||||||
<div
|
<div
|
||||||
v-for="session in group.sessions"
|
v-for="session in group.sessions"
|
||||||
:key="session.id"
|
:key="session.key"
|
||||||
:class="['session-item', {
|
:class="['session-item', {
|
||||||
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
|
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
|
||||||
'is-hovered': hoveredSession?.id === session.id,
|
'is-hovered': hoveredSession?.key === session.key,
|
||||||
'is-linked-credential': hoveredCredentialUuid === session.credential
|
'is-linked-credential': hoveredCredentialUuid === session.credential
|
||||||
}]"
|
}]"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -30,17 +31,17 @@
|
|||||||
@keydown="handleItemKeydown($event, session)"
|
@keydown="handleItemKeydown($event, session)"
|
||||||
>
|
>
|
||||||
<div class="item-top">
|
<div class="item-top">
|
||||||
<h4 class="item-title">{{ session.user_agent }}</h4>
|
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
||||||
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
|
<span v-else-if="hoveredSession?.key === session.key" class="badge badge-current">Selected</span>
|
||||||
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
|
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
|
||||||
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
|
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
|
||||||
<button
|
<button
|
||||||
@click="$emit('terminate', session)"
|
@click="$emit('terminate', session)"
|
||||||
class="btn-card-delete"
|
class="btn-card-delete"
|
||||||
:disabled="isTerminating(session.id)"
|
:disabled="isTerminating(session.key)"
|
||||||
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
|
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>❌</button>
|
>❌</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,12 +70,13 @@ import { hostIP } from '@/utils/helpers'
|
|||||||
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
|
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
sessions: { type: Array, default: () => [] },
|
sessions: { type: Object, default: () => ({}) },
|
||||||
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
|
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
|
||||||
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
|
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
|
||||||
terminatingSessions: { type: Object, default: () => ({}) },
|
terminatingSessions: { type: Object, default: () => ({}) },
|
||||||
hoveredCredentialUuid: { type: String, default: null },
|
hoveredCredentialUuid: { type: String, default: null },
|
||||||
navigationDisabled: { type: Boolean, default: false },
|
navigationDisabled: { type: Boolean, default: false },
|
||||||
|
sectionClass: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
|
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
|
||||||
@@ -106,7 +108,7 @@ const handleCardClick = (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
|
const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
|
||||||
|
|
||||||
const handleGroupKeydown = (event, host) => {
|
const handleGroupKeydown = (event, host) => {
|
||||||
const group = event.currentTarget
|
const group = event.currentTarget
|
||||||
@@ -149,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
|
|||||||
const handleItemKeydown = (event, session) => {
|
const handleItemKeydown = (event, session) => {
|
||||||
// Handle delete (always allowed even with modal)
|
// Handle delete (always allowed even with modal)
|
||||||
handleDeleteKey(event, () => {
|
handleDeleteKey(event, () => {
|
||||||
if (!isTerminating(session.id)) emit('terminate', session)
|
if (!isTerminating(session.key)) emit('terminate', session)
|
||||||
})
|
})
|
||||||
if (event.defaultPrevented) return
|
if (event.defaultPrevented) return
|
||||||
|
|
||||||
@@ -208,9 +210,14 @@ const copyIp = async (ip) => {
|
|||||||
|
|
||||||
const displayIp = ip => hostIP(ip) ?? ip
|
const displayIp = ip => hostIP(ip) ?? ip
|
||||||
|
|
||||||
|
// Convert sessions dict to array with key attached
|
||||||
|
const sessionsArray = computed(() =>
|
||||||
|
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
|
||||||
|
)
|
||||||
|
|
||||||
const currentHostIP = computed(() => {
|
const currentHostIP = computed(() => {
|
||||||
if (hoveredIp.value) return hostIP(hoveredIp.value)
|
if (hoveredIp.value) return hostIP(hoveredIp.value)
|
||||||
const current = props.sessions.find(s => s.is_current)
|
const current = sessionsArray.value.find(s => s.is_current)
|
||||||
return current ? hostIP(current.ip) : null
|
return current ? hostIP(current.ip) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -218,27 +225,20 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
|
|||||||
|
|
||||||
const groupedSessions = computed(() => {
|
const groupedSessions = computed(() => {
|
||||||
const groups = {}
|
const groups = {}
|
||||||
for (const session of props.sessions) {
|
for (const session of sessionsArray.value) {
|
||||||
const host = session.host || ''
|
const groupKey = session.client || session.host || ''
|
||||||
if (!groups[host]) {
|
if (!groups[groupKey]) {
|
||||||
groups[host] = { sessions: [], isCurrentSite: false }
|
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
|
||||||
}
|
|
||||||
groups[host].sessions.push(session)
|
|
||||||
if (session.is_current_host) {
|
|
||||||
groups[host].isCurrentSite = true
|
|
||||||
}
|
}
|
||||||
|
groups[groupKey].sessions.push(session)
|
||||||
|
if (session.is_current_host) groups[groupKey].isCurrentSite = true
|
||||||
}
|
}
|
||||||
// Sort sessions within each group by last_renewed descending
|
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
|
||||||
for (const host in groups) {
|
|
||||||
groups[host].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
|
|
||||||
}
|
|
||||||
// Sort groups by host name (natural sort)
|
|
||||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||||
const sortedHosts = Object.keys(groups).sort(collator.compare)
|
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
|
||||||
const sortedGroups = {}
|
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
|
||||||
for (const host of sortedHosts) {
|
return collator.compare(a.displayName, b.displayName) || collator.compare(a.sessions[0]?.client || '', b.sessions[0]?.client || '')
|
||||||
sortedGroups[host] = groups[host]
|
})
|
||||||
}
|
return Object.fromEntries(sorted)
|
||||||
return sortedGroups
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="authStore.status.show" class="global-status" style="display: block;">
|
<div v-if="authStore.status.show" class="global-status show">
|
||||||
<div :class="['status', authStore.status.type]">
|
<div :class="['status', authStore.status.type]">
|
||||||
{{ authStore.status.message }}
|
{{ authStore.status.message }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,23 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||||
<h3 class="user-name-heading">
|
<div class="user-info-content">
|
||||||
<span class="icon">👤</span>
|
<div class="user-picture">
|
||||||
<span class="user-name-row">
|
<span>👤</span>
|
||||||
<span class="display-name" :title="name">{{ name }}</span>
|
</div>
|
||||||
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name">✏️</button>
|
<h3 class="user-name-heading">
|
||||||
</span>
|
<span class="user-name-row">
|
||||||
</h3>
|
<span class="display-name" :title="name">{{ name }}</span>
|
||||||
<div v-if="orgDisplayName || roleName" class="org-role-sub">
|
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('edit')" title="Edit profile">✏️</button>
|
||||||
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
</span>
|
||||||
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
</h3>
|
||||||
</div>
|
<div v-if="orgDisplayName || roleName" class="org-role-sub">
|
||||||
<div class="user-details">
|
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
||||||
<span class="date-label"><strong>Visits:</strong></span>
|
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
||||||
<span class="date-value">{{ visits || 0 }}</span>
|
</div>
|
||||||
<span class="date-label"><strong>Registered:</strong></span>
|
<div class="info-fields-block">
|
||||||
<span class="date-value">{{ formatDate(createdAt) }}</span>
|
<div v-if="preferred_username" class="contact-item">🆔 {{ preferred_username }}</div>
|
||||||
<span class="date-label"><strong>Last seen:</strong></span>
|
<a v-if="email" :href="`mailto:${email}`" class="contact-link">✉️ {{ email }}</a>
|
||||||
<span class="date-value">{{ formatDate(lastSeen) }}</span>
|
<a v-if="telephone" :href="`tel:${telephone}`" class="contact-link">📞 {{ telephone }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="info-line">
|
||||||
|
<span v-if="visits">
|
||||||
|
<span class="info-date">{{ formatDate(createdAt) }}</span>
|
||||||
|
<span class="info-punct"> – </span>
|
||||||
|
<span class="info-date">{{ formatDate(lastSeen) }}</span>
|
||||||
|
<span class="info-punct"> ×</span>
|
||||||
|
<span class="info-count">{{ visits }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<span class="info-label">Created </span>
|
||||||
|
<span class="info-date">{{ formatDate(createdAt) }}</span>
|
||||||
|
<span class="info-punct"> — Never signed in</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="$slots.default" class="user-info-extra">
|
<div v-if="$slots.default" class="user-info-extra">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
@@ -26,12 +41,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
|
email: { type: String, default: null },
|
||||||
|
preferred_username: { type: String, default: null },
|
||||||
|
telephone: { type: String, default: null },
|
||||||
visits: { type: [Number, String], default: 0 },
|
visits: { type: [Number, String], default: 0 },
|
||||||
createdAt: { type: [String, Number, Date], default: null },
|
createdAt: { type: [String, Number, Date], default: null },
|
||||||
lastSeen: { type: [String, Number, Date], default: null },
|
lastSeen: { type: [String, Number, Date], default: null },
|
||||||
@@ -42,7 +60,7 @@ const props = defineProps({
|
|||||||
roleName: { type: String, default: '' }
|
roleName: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['saved', 'editName'])
|
const emit = defineEmits(['saved', 'edit'])
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const userLoaded = computed(() => !!props.name)
|
const userLoaded = computed(() => !!props.name)
|
||||||
@@ -50,56 +68,57 @@ const userLoaded = computed(() => !!props.name)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-info.has-extra {
|
.user-info.has-extra {
|
||||||
grid-template-columns: auto 1fr 2fr;
|
grid-template-columns: minmax(0, 1fr) 14rem;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"heading heading extra"
|
"content extra";
|
||||||
"org org extra"
|
gap: 1.5rem;
|
||||||
"label1 value1 extra"
|
|
||||||
"label2 value2 extra"
|
|
||||||
"label3 value3 extra";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info:not(.has-extra) {
|
.user-info:not(.has-extra) {
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"heading heading"
|
"content";
|
||||||
"org org"
|
|
||||||
"label1 value1"
|
|
||||||
"label2 value2"
|
|
||||||
"label3 value3";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.user-info.has-extra {
|
.user-info.has-extra {
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"heading heading"
|
"content"
|
||||||
"org org"
|
"extra";
|
||||||
"label1 value1"
|
|
||||||
"label2 value2"
|
|
||||||
"label3 value3"
|
|
||||||
"extra extra";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
.user-info-content {
|
||||||
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
grid-area: content;
|
||||||
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
display: grid;
|
||||||
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
|
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
|
||||||
.info-label:nth-of-type(1) { grid-area: label1; }
|
grid-template-areas:
|
||||||
.info-value:nth-of-type(2) { grid-area: value1; }
|
"picture heading fields"
|
||||||
.info-label:nth-of-type(3) { grid-area: label2; }
|
"picture org fields"
|
||||||
.info-value:nth-of-type(4) { grid-area: value2; }
|
". info info";
|
||||||
.info-label:nth-of-type(5) { grid-area: label3; }
|
gap: 0 1rem;
|
||||||
.info-value:nth-of-type(6) { grid-area: value3; }
|
min-width: 0;
|
||||||
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
|
}
|
||||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
|
|
||||||
.user-name-row.editing { flex: 1 1 auto; }
|
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
|
||||||
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
||||||
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
|
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
||||||
.user-name-heading .name-input { width: auto; }
|
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
|
.role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
|
||||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
|
.info-fields-block { grid-area: fields; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||||
|
.contact-item { display: block; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.contact-link { color: var(--color-text); text-decoration: none; display: block; transition: transform 0.1s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.contact-link:hover { transform: scale(1.01); }
|
||||||
|
.info-line { grid-area: info; line-height: 1.4; font-size: 0.9em; }
|
||||||
|
.info-date { color: var(--color-text) !important; }
|
||||||
|
.info-label { color: var(--color-text) !important; }
|
||||||
|
.info-punct { color: var(--color-text-muted) !important; }
|
||||||
|
.info-count { color: var(--color-text-muted) !important; }
|
||||||
|
.user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
|
||||||
|
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
|
||||||
|
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
|
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; background: transparent; }
|
||||||
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
||||||
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { defineStore } from 'pinia'
|
|||||||
import { register, authenticate } from '@/utils/passkey'
|
import { register, authenticate } from '@/utils/passkey'
|
||||||
import { getSettings } from '@/utils/settings'
|
import { getSettings } from '@/utils/settings'
|
||||||
import { apiJson } from 'paskia'
|
import { apiJson } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
// Auth State
|
// Auth State
|
||||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
|
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
|
||||||
|
ctx: null, // Session context from validate
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
@@ -85,7 +87,8 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async loadUserInfo() {
|
async loadUserInfo() {
|
||||||
try {
|
try {
|
||||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
|
||||||
|
updateThemeFromSession(this.userInfo)
|
||||||
console.log('User info loaded:', this.userInfo)
|
console.log('User info loaded:', this.userInfo)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||||
@@ -101,9 +104,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
||||||
await this.loadUserInfo()
|
await this.loadUserInfo()
|
||||||
},
|
},
|
||||||
async terminateSession(sessionId) {
|
async terminateSession(sessionKey) {
|
||||||
try {
|
try {
|
||||||
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
const payload = await apiJson(`/auth/api/user/session/${sessionKey}`, { method: 'DELETE' })
|
||||||
if (payload?.current_session_terminated) {
|
if (payload?.current_session_terminated) {
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
|
|||||||
@@ -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')
|
|
||||||
}
|
|
||||||
@@ -54,8 +54,13 @@ export async function register(resetToken = null, displayName = null, onstartreg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authenticate() {
|
export async function authenticate(queryString = null) {
|
||||||
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
|
// Build URL, optionally appending raw query string (e.g. for OIDC params)
|
||||||
|
let url = await makeUrl('/auth/ws/authenticate')
|
||||||
|
if (queryString) {
|
||||||
|
url += queryString.startsWith('?') ? queryString : `?${queryString}`
|
||||||
|
}
|
||||||
|
const ws = await aWebSocket(url)
|
||||||
try {
|
try {
|
||||||
let res = await ws.receive_json()
|
let res = await ws.receive_json()
|
||||||
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
|
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Theme override utilities - shared across apps
|
||||||
|
// User preference or URL hash can force light/dark mode
|
||||||
|
|
||||||
|
const TRANSITION_ID = 'theme-transition'
|
||||||
|
const STORAGE_KEY = 'paskia-theme'
|
||||||
|
|
||||||
|
/** Apply theme by setting class on documentElement */
|
||||||
|
export function applyTheme(theme, element = document.documentElement, animate = false) {
|
||||||
|
// Add temporary transition for smooth theme change
|
||||||
|
if (animate) {
|
||||||
|
let transitionStyle = document.getElementById(TRANSITION_ID)
|
||||||
|
if (!transitionStyle) {
|
||||||
|
transitionStyle = document.createElement('style')
|
||||||
|
transitionStyle.id = TRANSITION_ID
|
||||||
|
transitionStyle.textContent = '*, *::before, *::after { transition: background-color 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s !important; }'
|
||||||
|
document.head.appendChild(transitionStyle)
|
||||||
|
}
|
||||||
|
setTimeout(() => document.getElementById(TRANSITION_ID)?.remove(), 350)
|
||||||
|
}
|
||||||
|
// If no explicit theme, check system preference
|
||||||
|
const isDark = theme === 'dark' || (theme !== 'light' && matchMedia('(prefers-color-scheme:dark)').matches)
|
||||||
|
element.classList.toggle('dark', isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get theme from localStorage cache */
|
||||||
|
export function getCachedTheme() {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache theme in localStorage */
|
||||||
|
export function setCachedTheme(theme) {
|
||||||
|
if (theme) localStorage.setItem(STORAGE_KEY, theme)
|
||||||
|
else localStorage.removeItem(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialize theme from user preference (with localStorage cache for fast load) */
|
||||||
|
export function initThemeFromCache() {
|
||||||
|
applyTheme(getCachedTheme())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update theme from session context (call after login/session load) */
|
||||||
|
export function updateThemeFromSession(ctx, animate = false) {
|
||||||
|
const theme = ctx?.user?.theme || ''
|
||||||
|
setCachedTheme(theme)
|
||||||
|
applyTheme(theme, document.documentElement, animate)
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* FastAPI-Vue Vite Plugin
|
* FastAPI-Vue Vite Plugin
|
||||||
|
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
|
||||||
*
|
*
|
||||||
* Configures Vite for FastAPI backend integration:
|
* Configures Vite for FastAPI backend integration:
|
||||||
* - Proxies /api/* requests to the FastAPI backend
|
* - Proxies /api/* requests to the FastAPI backend
|
||||||
* - Builds to the Python module's frontend-build directory
|
* - Builds to the Python module's frontend-build directory
|
||||||
*
|
*
|
||||||
* Environment variables (with defaults):
|
* Options:
|
||||||
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
* paths - Array of paths to proxy (default: ["/api"])
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
|
|
||||||
|
|
||||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||||
|
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
|
||||||
|
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "fastapi-vite",
|
name: "vite-plugin-fastapi-paskia",
|
||||||
config: () => ({
|
config: () => ({
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default defineConfig(({ command }) => ({
|
|||||||
fastapiVue({ paths: [
|
fastapiVue({ paths: [
|
||||||
"/auth/api",
|
"/auth/api",
|
||||||
"/auth/ws",
|
"/auth/ws",
|
||||||
|
"/.well-known/openid-configuration",
|
||||||
// Passphrase links: /auth/word1.word2.word3.word4.word5
|
// Passphrase links: /auth/word1.word2.word3.word4.word5
|
||||||
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
|
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
|
||||||
// Passphrase links: /word1.word2.word3.word4.word5
|
// Passphrase links: /word1.word2.word3.word4.word5
|
||||||
@@ -46,6 +47,18 @@ export default defineConfig(({ command }) => ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'restricted-endpoints-rewrite',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
// Rewrite /auth/restricted/iframe and /auth/restricted/oidc to /auth/restricted/
|
||||||
|
if (req.url === '/auth/restricted/iframe' || req.url === '/auth/restricted/oidc') {
|
||||||
|
req.url = '/auth/restricted/'
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'serve-examples',
|
name: 'serve-examples',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# OIDC Provider Implementation
|
||||||
|
|
||||||
|
OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication.
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
**User** — Added: `email`, `preferred_username`
|
||||||
|
|
||||||
|
**Session** — Added: `client_uuid` (None = native, set = OIDC)
|
||||||
|
- `key: bytes` — hashed DB key, never stored raw
|
||||||
|
- `secret` → `hash_secret("session", secret)` → DB lookup
|
||||||
|
- OIDC `sid` → `base64url.encode(hash_secret("oidc", session.key))`
|
||||||
|
|
||||||
|
**OIDClient** — `uuid, client_secret_hash, name, redirect_uris`
|
||||||
|
|
||||||
|
## Auth Codes (In-Memory Only)
|
||||||
|
|
||||||
|
60-second lifetime, auto-cleaned:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from paskia.authcode import AuthCode, OIDC, codes
|
||||||
|
|
||||||
|
class AuthCode(msgspec.Struct):
|
||||||
|
session_key: str # Session DB key
|
||||||
|
created: datetime
|
||||||
|
oidc: OIDC | None # Only for OIDC mode
|
||||||
|
|
||||||
|
class OIDC(msgspec.Struct):
|
||||||
|
redirect_uri, scope, nonce, code_challenge, code_challenge_method: str
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)`
|
||||||
|
|
||||||
|
## Authorization Flows
|
||||||
|
|
||||||
|
### OIDC (Authorization Code)
|
||||||
|
|
||||||
|
1. `GET /auth/restricted/oidc?client_id=UUID&redirect_uri=...&scope=openid&nonce=...&code_challenge=...`
|
||||||
|
2. Frontend → WebSocket: `/auth/ws/authenticate?client_id=...&redirect_uri=...&...`
|
||||||
|
3. Validate client/redirect_uri, authenticate via passkey
|
||||||
|
4. `db.oidc_login()` → `(secret, session_key)`
|
||||||
|
5. Create `AuthCode(session_key, oidc=OIDC(...))` → code
|
||||||
|
6. Return: `{"redirect_url": "{redirect_uri}?code={code}&state={state}"}`
|
||||||
|
7. Client exchanges code at `/auth/oidc/token` with `code_verifier` (PKCE S256)
|
||||||
|
|
||||||
|
**Token:** `access_token, id_token, refresh_token={secret}, expires_in=3600`
|
||||||
|
|
||||||
|
**ID token:** `sub, sid (base64url), name, preferred_username, email, groups`
|
||||||
|
|
||||||
|
### Native (Cookie)
|
||||||
|
|
||||||
|
1. WebSocket: `/auth/ws/authenticate` (no OIDC params)
|
||||||
|
2. Authenticate via passkey
|
||||||
|
3. `db.login()` → secret
|
||||||
|
4. Create `AuthCode(session_key=secret, oidc=None)` → exchange_code
|
||||||
|
5. Return: `{"user": "UUID", "exchange_code": "..."}`
|
||||||
|
6. `POST /auth/api/exchange` with code → sets cookie
|
||||||
|
|
||||||
|
## Refresh & Logout
|
||||||
|
|
||||||
|
**Refresh:** `POST /auth/oidc/token` with `grant_type=refresh_token&refresh_token={secret}&client_id=...&client_secret=...`
|
||||||
|
- Looks up session, validates client match
|
||||||
|
- Extends expiry +24h (sliding window)
|
||||||
|
- Returns new tokens with same `sid`
|
||||||
|
|
||||||
|
**Back-channel logout:** `POST /auth/oidc/backchannel-logout` with `logout_token={jwt}`
|
||||||
|
- Verify signature, extract `sid` or `sub`
|
||||||
|
- Delete matched sessions
|
||||||
|
- Return 200 OK
|
||||||
|
|
||||||
|
Discovery: `backchannel_logout_supported: true`
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /.well-known/openid-configuration` — Discovery
|
||||||
|
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
||||||
|
- `POST /auth/oidc/token` — Exchange/refresh
|
||||||
|
- `GET /auth/oidc/userinfo` — User (bearer token)
|
||||||
|
- `POST /auth/oidc/backchannel-logout` — Logout
|
||||||
|
- `POST /auth/api/exchange` — Native auth code → cookie
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
|
||||||
|
|
||||||
|
**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/globals.py](paskia/globals.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Paskia
|
# Paskia
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
|
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ The JSON variants set headers automatically, with body and response in JSON.
|
|||||||
|
|
||||||
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
|
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
|
||||||
|
|
||||||
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
|
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/iframe#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { showAuthIframe, AuthCancelledError } from 'paskia'
|
import { showAuthIframe, AuthCancelledError } from 'paskia'
|
||||||
|
|||||||
+13
-4
@@ -1,7 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "paskia",
|
"name": "paskia",
|
||||||
"version": "0.1.2",
|
"version": "1.1.0",
|
||||||
"description": "Paskia authentication utilities for JavaScript",
|
"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",
|
"type": "module",
|
||||||
"main": "./dist/paskia.js",
|
"main": "./dist/paskia.js",
|
||||||
"types": "./dist/paskia.d.ts",
|
"types": "./dist/paskia.d.ts",
|
||||||
@@ -26,7 +34,8 @@
|
|||||||
"keywords": [
|
"keywords": [
|
||||||
"auth",
|
"auth",
|
||||||
"authentication",
|
"authentication",
|
||||||
"paskia"
|
"paskia",
|
||||||
],
|
"passkey",
|
||||||
"license": "Unlicense"
|
"webauthn"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ export {
|
|||||||
isAuthIframeOpen,
|
isAuthIframeOpen,
|
||||||
hideAuthIframe,
|
hideAuthIframe,
|
||||||
showAuthIframe,
|
showAuthIframe,
|
||||||
createAuthIframe,
|
|
||||||
removeAuthIframe,
|
|
||||||
} from './overlay'
|
} from './overlay'
|
||||||
|
|
||||||
export { SessionValidator } from './validate'
|
export { SessionValidator } from './validate'
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ body::before {
|
|||||||
transition: all 0.2s ease-out;
|
transition: all 0.2s ease-out;
|
||||||
}
|
}
|
||||||
body.paskia-backdrop::before {
|
body.paskia-backdrop::before {
|
||||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
backdrop-filter: blur(.2rem) brightness(0.7);
|
||||||
backdrop-filter: blur(.2rem) brightness(0.5);
|
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
body.paskia-backdrop {
|
body.paskia-backdrop {
|
||||||
@@ -57,6 +57,7 @@ export class AuthCancelledError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function holdGlobalBackdrop(): void {
|
export function holdGlobalBackdrop(): void {
|
||||||
|
injectStyles()
|
||||||
backdropHolders++
|
backdropHolders++
|
||||||
document.body.classList.add('paskia-backdrop')
|
document.body.classList.add('paskia-backdrop')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
from fastapi_vue import server
|
||||||
|
from fastapi_vue.hostutil import parse_endpoints
|
||||||
|
|
||||||
|
from paskia.db.jsonl import load_readonly
|
||||||
|
from paskia.util import startupbox
|
||||||
|
from paskia.util.hostutil import (
|
||||||
|
normalize_auth_host_and_origins,
|
||||||
|
normalize_origin,
|
||||||
|
validate_auth_host,
|
||||||
|
)
|
||||||
|
from paskia.util.runtime import RuntimeConfig
|
||||||
|
|
||||||
|
DEFAULT_PORT = 4401
|
||||||
|
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
||||||
|
|
||||||
|
EPILOG = """\
|
||||||
|
Example:
|
||||||
|
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||||
|
p.add_argument(
|
||||||
|
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||||
|
)
|
||||||
|
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
||||||
|
p.add_argument(
|
||||||
|
"--origin",
|
||||||
|
action="append",
|
||||||
|
dest="origins",
|
||||||
|
metavar="URL",
|
||||||
|
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--auth-host",
|
||||||
|
help=("Dedicated authentication site (optionally with scheme/port)"),
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--save",
|
||||||
|
action="store_true",
|
||||||
|
help="Save the CLI options to database for future runs.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Configure logging to remove the "ERROR:root:" prefix
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="paskia",
|
||||||
|
description="Paskia authentication server",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=EPILOG,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-l",
|
||||||
|
"--listen",
|
||||||
|
action="append",
|
||||||
|
metavar="LISTEN",
|
||||||
|
help=(
|
||||||
|
"Endpoint to listen on (default: localhost:4401). "
|
||||||
|
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
add_common_options(parser)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Load stored config (read-only, no writes, no global state)
|
||||||
|
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
|
||||||
|
config = load_readonly(db_path, rp_id=args.rp_id).config
|
||||||
|
|
||||||
|
# Override stored config with CLI args, or clear with empty string
|
||||||
|
if args.rp_name is not None:
|
||||||
|
config.rp_name = args.rp_name or None
|
||||||
|
if args.auth_host is not None:
|
||||||
|
config.auth_host = args.auth_host or None
|
||||||
|
if args.origins is not None:
|
||||||
|
config.origins = None if args.origins == [""] else args.origins
|
||||||
|
if args.listen is not None:
|
||||||
|
config.listen = None if args.listen == [""] else args.listen
|
||||||
|
|
||||||
|
# Process and normalize auth_host 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()
|
||||||
@@ -30,4 +30,16 @@ def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
|
|||||||
Dictionary mapping AAGUID string to authenticator information for only
|
Dictionary mapping AAGUID string to authenticator information for only
|
||||||
the AAGUIDs that the user has and that we have data for
|
the AAGUIDs that the user has and that we have data for
|
||||||
"""
|
"""
|
||||||
return {(s := str(a)): AAGUID[s] for a in aaguids if (s := str(a)) in AAGUID}
|
result = {}
|
||||||
|
for a in aaguids:
|
||||||
|
s = str(a)
|
||||||
|
if s in AAGUID:
|
||||||
|
info = AAGUID[s].copy()
|
||||||
|
# Rename icon_light to icon
|
||||||
|
if "icon_light" in info:
|
||||||
|
info["icon"] = info.pop("icon_light")
|
||||||
|
# If icons are the same, set dark to None to save space
|
||||||
|
if info.get("icon") == info.get("icon_dark"):
|
||||||
|
info["icon_dark"] = None
|
||||||
|
result[s] = info
|
||||||
|
return result
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -14,6 +14,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
|
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
|
||||||
|
from paskia.db.structs import ResetToken
|
||||||
from paskia.util import hostutil
|
from paskia.util import hostutil
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -22,6 +23,11 @@ if TYPE_CHECKING:
|
|||||||
EXPIRES = SESSION_LIFETIME
|
EXPIRES = SESSION_LIFETIME
|
||||||
|
|
||||||
|
|
||||||
|
def session_ctx(auth: str, host: str | None = None):
|
||||||
|
"""Get session context with normalized host."""
|
||||||
|
return db.data().session_ctx(auth, hostutil.normalize_host(host))
|
||||||
|
|
||||||
|
|
||||||
def expires() -> datetime:
|
def expires() -> datetime:
|
||||||
return datetime.now(UTC) + EXPIRES
|
return datetime.now(UTC) + EXPIRES
|
||||||
|
|
||||||
@@ -33,7 +39,7 @@ def reset_expires() -> datetime:
|
|||||||
def get_reset(token: str) -> "ResetToken":
|
def get_reset(token: str) -> "ResetToken":
|
||||||
"""Validate a credential reset token."""
|
"""Validate a credential reset token."""
|
||||||
|
|
||||||
record = db.get_reset_token(token)
|
record = ResetToken.by_passphrase(token)
|
||||||
if record:
|
if record:
|
||||||
return record
|
return record
|
||||||
raise ValueError("This authentication link is no longer valid.")
|
raise ValueError("This authentication link is no longer valid.")
|
||||||
@@ -41,7 +47,7 @@ def get_reset(token: str) -> "ResetToken":
|
|||||||
|
|
||||||
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
||||||
"""Delete a specific credential for the current user."""
|
"""Delete a specific credential for the current user."""
|
||||||
ctx = db.data().session_ctx(auth, hostutil.normalize_host(host))
|
ctx = session_ctx(auth, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise ValueError("Session expired")
|
raise ValueError("Session expired")
|
||||||
db.delete_credential(credential_uuid, ctx.user.uuid)
|
db.delete_credential(credential_uuid, ctx.user.uuid)
|
||||||
|
|||||||
+36
-37
@@ -6,42 +6,45 @@ including creating default admin user, organization, permissions, and
|
|||||||
generating a reset link for initial admin setup.
|
generating a reset link for initial admin setup.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from paskia import authsession, db, globals
|
from paskia import authsession, db
|
||||||
from paskia.util import hostutil, passphrase
|
from paskia.db.structs import Config
|
||||||
|
from paskia.util import hostutil
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Shared log message template for admin reset links
|
# Shared log message template for admin reset links
|
||||||
ADMIN_RESET_MESSAGE = """\
|
ADMIN_RESET_MESSAGE = """
|
||||||
%s
|
|
||||||
|
|
||||||
👤 Admin %s
|
👤 Admin %s
|
||||||
- Use this link to register a Passkey for the admin user!
|
- Use this link to register a Passkey for the admin user!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _log_reset_link(message: str, passphrase: str) -> str:
|
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||||
"""Log a reset link message and return the URL."""
|
"""Log a reset link message and return the URL."""
|
||||||
reset_link = hostutil.reset_link_url(passphrase)
|
reset_link = hostutil.reset_link_url(passphrase)
|
||||||
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
if message:
|
||||||
|
logger.info(message)
|
||||||
|
logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
||||||
return reset_link
|
return reset_link
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_system() -> None:
|
async def bootstrap_system(config: Config | None = None) -> None:
|
||||||
"""
|
"""
|
||||||
Bootstrap the entire system with default data.
|
Bootstrap the entire system with default data.
|
||||||
|
|
||||||
Uses db.bootstrap() which performs all operations in a single transaction.
|
Uses db.bootstrap() which performs all operations in a single transaction.
|
||||||
The transaction log will show a single "bootstrap" action with all changes.
|
The transaction log will show a single "bootstrap" action with all changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration to store (rp_id, rp_name, origins, etc.)
|
||||||
"""
|
"""
|
||||||
# Call the single-transaction bootstrap function
|
# Call the single-transaction bootstrap function
|
||||||
reset_passphrase = db.bootstrap()
|
reset_passphrase = db.bootstrap(config=config)
|
||||||
|
|
||||||
# Log the reset link (this is separate from the transaction log)
|
# Log the reset link (this is separate from the transaction log)
|
||||||
_log_reset_link("✅ Bootstrap completed!", reset_passphrase)
|
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
||||||
|
|
||||||
|
|
||||||
async def check_admin_credentials() -> bool:
|
async def check_admin_credentials() -> bool:
|
||||||
@@ -52,17 +55,24 @@ async def check_admin_credentials() -> bool:
|
|||||||
bool: True if a reset link was created, False if admin already has credentials
|
bool: True if a reset link was created, False if admin already has credentials
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get permission organizations to find admin users
|
# Find the auth:admin permission
|
||||||
p = next(
|
p = next(
|
||||||
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
||||||
)
|
)
|
||||||
if not p or not p.orgs:
|
if not p:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Get users from the first organization with admin permission
|
perm_uuid = p.uuid
|
||||||
first_org_uuid = next(iter(p.orgs))
|
|
||||||
org_users = db.get_organization_users(first_org_uuid)
|
# Find all roles that have the auth:admin permission
|
||||||
admin_users = [user for user, role in org_users if role == "Administration"]
|
admin_roles = [
|
||||||
|
r for r in db.data().roles.values() if perm_uuid in r.permissions
|
||||||
|
]
|
||||||
|
|
||||||
|
# Collect all users from those roles
|
||||||
|
admin_users = []
|
||||||
|
for role in admin_roles:
|
||||||
|
admin_users.extend(role.users)
|
||||||
|
|
||||||
if not admin_users:
|
if not admin_users:
|
||||||
return False
|
return False
|
||||||
@@ -70,18 +80,17 @@ async def check_admin_credentials() -> bool:
|
|||||||
# Check first admin user for credentials
|
# Check first admin user for credentials
|
||||||
admin_user = admin_users[0]
|
admin_user = admin_users[0]
|
||||||
|
|
||||||
if not db.get_user_credential_ids(admin_user.uuid):
|
if not admin_user.credential_ids:
|
||||||
# Admin exists but has no credentials, create reset link
|
# Admin exists but has no credentials, create reset link
|
||||||
|
logger.info("⚠️ Admin user has no credentials!")
|
||||||
|
|
||||||
token = passphrase.generate()
|
|
||||||
expiry = authsession.reset_expires()
|
expiry = authsession.reset_expires()
|
||||||
db.create_reset_token(
|
token = db.create_reset_token(
|
||||||
user_uuid=admin_user.uuid,
|
user_uuid=admin_user.uuid,
|
||||||
passphrase=token,
|
|
||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
token_type="admin registration",
|
token_type="admin registration",
|
||||||
)
|
)
|
||||||
_log_reset_link("⚠️ Admin user has no credentials!", token)
|
_log_reset_link(token)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -90,10 +99,13 @@ async def check_admin_credentials() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_if_needed() -> bool:
|
async def bootstrap_if_needed(config: Config | None = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if system needs bootstrapping and perform it if necessary.
|
Check if system needs bootstrapping and perform it if necessary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if bootstrapping was performed, False if system was already set up
|
bool: True if bootstrapping was performed, False if system was already set up
|
||||||
"""
|
"""
|
||||||
@@ -106,18 +118,5 @@ async def bootstrap_if_needed() -> bool:
|
|||||||
|
|
||||||
# No admin permission found, need to bootstrap
|
# No admin permission found, need to bootstrap
|
||||||
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
||||||
await bootstrap_system()
|
await bootstrap_system(config=config)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# CLI interface
|
|
||||||
async def main():
|
|
||||||
"""Main CLI entry point for bootstrapping."""
|
|
||||||
# Configure logging for CLI usage
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
|
||||||
|
|
||||||
await globals.init()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
# Shared configuration constants for session management.
|
# Shared configuration constants for session management.
|
||||||
@@ -6,19 +5,3 @@ SESSION_LIFETIME = timedelta(hours=24)
|
|||||||
|
|
||||||
# Lifetime for reset links created by admins
|
# Lifetime for reset links created by admins
|
||||||
RESET_LIFETIME = timedelta(days=14)
|
RESET_LIFETIME = timedelta(days=14)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PaskiaConfig:
|
|
||||||
"""Runtime configuration for the Paskia authentication server."""
|
|
||||||
|
|
||||||
rp_id: str
|
|
||||||
rp_name: str | None
|
|
||||||
origins: list[str] | None
|
|
||||||
auth_host: str | None
|
|
||||||
site_url: str # Base URL without trailing path (e.g. https://example.com)
|
|
||||||
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
|
|
||||||
# Listen address (one of host:port or uds)
|
|
||||||
host: str | None = None
|
|
||||||
port: int | None = None
|
|
||||||
uds: str | None = None
|
|
||||||
|
|||||||
+26
-24
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Database module for WebAuthn passkey authentication.
|
Database module for WebAuthn passkey authentication.
|
||||||
|
|
||||||
Read: Access data() directly, use build_* to convert to public structs.
|
Read: Access data() directly for structs.
|
||||||
CTX: data().session_ctx(key) returns SessionContext with effective permissions.
|
CTX: data().session_ctx(key) returns SessionContext with effective permissions.
|
||||||
Write: Functions validate and commit, or raise ValueError.
|
Write: Functions validate and commit, or raise ValueError.
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ Usage:
|
|||||||
|
|
||||||
# Read (after init)
|
# Read (after init)
|
||||||
user_data = db.data().users[user_uuid]
|
user_data = db.data().users[user_uuid]
|
||||||
user = db.build_user(user_uuid)
|
|
||||||
|
|
||||||
# Context
|
# Context
|
||||||
ctx = db.data().session_ctx(session_key)
|
ctx = db.data().session_ctx(session_key)
|
||||||
@@ -26,20 +25,22 @@ from paskia.db.background import (
|
|||||||
stop_background,
|
stop_background,
|
||||||
stop_cleanup,
|
stop_cleanup,
|
||||||
)
|
)
|
||||||
|
from paskia.db.bootstrap import bootstrap
|
||||||
|
from paskia.db.jsonl import load_readonly
|
||||||
|
from paskia.db.lifecycle import cleanup_expired, init
|
||||||
from paskia.db.operations import (
|
from paskia.db.operations import (
|
||||||
add_permission_to_org,
|
add_permission_to_org,
|
||||||
add_permission_to_role,
|
add_permission_to_role,
|
||||||
bootstrap,
|
|
||||||
cleanup_expired,
|
|
||||||
create_credential,
|
create_credential,
|
||||||
create_credential_session,
|
create_credential_session,
|
||||||
|
create_oid_client,
|
||||||
create_org,
|
create_org,
|
||||||
create_permission,
|
create_permission,
|
||||||
create_reset_token,
|
create_reset_token,
|
||||||
create_role,
|
create_role,
|
||||||
create_session,
|
|
||||||
create_user,
|
create_user,
|
||||||
delete_credential,
|
delete_credential,
|
||||||
|
delete_oid_client,
|
||||||
delete_org,
|
delete_org,
|
||||||
delete_permission,
|
delete_permission,
|
||||||
delete_reset_token,
|
delete_reset_token,
|
||||||
@@ -47,26 +48,28 @@ from paskia.db.operations import (
|
|||||||
delete_session,
|
delete_session,
|
||||||
delete_sessions_for_user,
|
delete_sessions_for_user,
|
||||||
delete_user,
|
delete_user,
|
||||||
get_organization_users,
|
is_username_taken,
|
||||||
get_reset_token,
|
|
||||||
get_user_credential_ids,
|
|
||||||
get_user_organization,
|
|
||||||
init,
|
|
||||||
login,
|
login,
|
||||||
|
oidc_login,
|
||||||
remove_permission_from_org,
|
remove_permission_from_org,
|
||||||
remove_permission_from_role,
|
remove_permission_from_role,
|
||||||
|
reset_oid_client_secret,
|
||||||
set_session_host,
|
set_session_host,
|
||||||
|
update_config,
|
||||||
update_credential_sign_count,
|
update_credential_sign_count,
|
||||||
|
update_oid_client,
|
||||||
update_org_name,
|
update_org_name,
|
||||||
update_permission,
|
update_permission,
|
||||||
update_role_name,
|
update_role_name,
|
||||||
update_session,
|
update_session,
|
||||||
update_user_display_name,
|
update_user_display_name,
|
||||||
|
update_user_info,
|
||||||
update_user_role,
|
update_user_role,
|
||||||
update_user_role_in_organization,
|
|
||||||
)
|
)
|
||||||
from paskia.db.structs import (
|
from paskia.db.structs import (
|
||||||
DB,
|
DB,
|
||||||
|
Client,
|
||||||
|
Config,
|
||||||
Credential,
|
Credential,
|
||||||
Org,
|
Org,
|
||||||
Permission,
|
Permission,
|
||||||
@@ -85,8 +88,10 @@ def data() -> DB:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Types
|
# Types
|
||||||
|
"Config",
|
||||||
"Credential",
|
"Credential",
|
||||||
"DB",
|
"DB",
|
||||||
|
"Client",
|
||||||
"Org",
|
"Org",
|
||||||
"Permission",
|
"Permission",
|
||||||
"ResetToken",
|
"ResetToken",
|
||||||
@@ -97,23 +102,13 @@ __all__ = [
|
|||||||
# Instance
|
# Instance
|
||||||
"data",
|
"data",
|
||||||
"init",
|
"init",
|
||||||
|
"load_readonly",
|
||||||
# Background
|
# Background
|
||||||
"start_background",
|
"start_background",
|
||||||
"stop_background",
|
"stop_background",
|
||||||
"start_cleanup",
|
"start_cleanup",
|
||||||
"stop_cleanup",
|
"stop_cleanup",
|
||||||
# Builders
|
|
||||||
"build_credential",
|
|
||||||
"build_permission",
|
|
||||||
"build_reset_token",
|
|
||||||
"build_role",
|
|
||||||
"build_session",
|
|
||||||
"build_user",
|
|
||||||
# Read ops
|
# Read ops
|
||||||
"get_organization_users",
|
|
||||||
"get_reset_token",
|
|
||||||
"get_user_credential_ids",
|
|
||||||
"get_user_organization",
|
|
||||||
# Write ops
|
# Write ops
|
||||||
"add_permission_to_org",
|
"add_permission_to_org",
|
||||||
"add_permission_to_role",
|
"add_permission_to_role",
|
||||||
@@ -125,7 +120,6 @@ __all__ = [
|
|||||||
"create_permission",
|
"create_permission",
|
||||||
"create_reset_token",
|
"create_reset_token",
|
||||||
"create_role",
|
"create_role",
|
||||||
"create_session",
|
|
||||||
"create_user",
|
"create_user",
|
||||||
"delete_credential",
|
"delete_credential",
|
||||||
"delete_org",
|
"delete_org",
|
||||||
@@ -136,15 +130,23 @@ __all__ = [
|
|||||||
"delete_sessions_for_user",
|
"delete_sessions_for_user",
|
||||||
"delete_user",
|
"delete_user",
|
||||||
"login",
|
"login",
|
||||||
|
"oidc_login",
|
||||||
"remove_permission_from_org",
|
"remove_permission_from_org",
|
||||||
"remove_permission_from_role",
|
"remove_permission_from_role",
|
||||||
"set_session_host",
|
"set_session_host",
|
||||||
|
"update_config",
|
||||||
"update_credential_sign_count",
|
"update_credential_sign_count",
|
||||||
"update_org_name",
|
"update_org_name",
|
||||||
"update_permission",
|
"update_permission",
|
||||||
"update_role_name",
|
"update_role_name",
|
||||||
"update_session",
|
"update_session",
|
||||||
"update_user_display_name",
|
"update_user_display_name",
|
||||||
|
"update_user_info",
|
||||||
"update_user_role",
|
"update_user_role",
|
||||||
"update_user_role_in_organization",
|
"is_username_taken",
|
||||||
|
# OIDC
|
||||||
|
"create_oid_client",
|
||||||
|
"update_oid_client",
|
||||||
|
"reset_oid_client_secret",
|
||||||
|
"delete_oid_client",
|
||||||
]
|
]
|
||||||
|
|||||||
+16
-13
@@ -8,7 +8,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from paskia.db.operations import _store, cleanup_expired
|
import paskia.db.operations as _ops
|
||||||
|
from paskia.db.lifecycle import cleanup_expired
|
||||||
|
|
||||||
FLUSH_INTERVAL = 0.1 # Flush to disk
|
FLUSH_INTERVAL = 0.1 # Flush to disk
|
||||||
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
||||||
@@ -20,11 +21,11 @@ _background_task: asyncio.Task | None = None
|
|||||||
|
|
||||||
async def flush() -> None:
|
async def flush() -> None:
|
||||||
"""Write all pending database changes to disk."""
|
"""Write all pending database changes to disk."""
|
||||||
|
store = _ops._db._store
|
||||||
if _store is None:
|
if store is None:
|
||||||
_logger.warning("flush() called but _store is None")
|
_logger.warning("flush() called but _store is None")
|
||||||
return
|
return
|
||||||
await _store.flush()
|
await store.flush()
|
||||||
|
|
||||||
|
|
||||||
async def _background_loop():
|
async def _background_loop():
|
||||||
@@ -47,6 +48,10 @@ async def _background_loop():
|
|||||||
cleanup_expired()
|
cleanup_expired()
|
||||||
await flush() # Flush cleanup changes
|
await flush() # Flush cleanup changes
|
||||||
last_cleanup = now
|
last_cleanup = now
|
||||||
|
|
||||||
|
# Conditionally write a snapshot to speed up future startups
|
||||||
|
if _ops._db._store is not None:
|
||||||
|
_ops._db._store.maybe_snapshot()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Final flush before exit
|
# Final flush before exit
|
||||||
await flush()
|
await flush()
|
||||||
@@ -74,25 +79,22 @@ async def start_background():
|
|||||||
_logger.debug("Background task in different event loop, restarting")
|
_logger.debug("Background task in different event loop, restarting")
|
||||||
_background_task = None
|
_background_task = None
|
||||||
else:
|
else:
|
||||||
# Task is running in the same event loop - this is an error
|
# Task is already running in same loop - idempotent, just return
|
||||||
raise RuntimeError(
|
# This happens with dual IPv4+IPv6 endpoints sharing the same process
|
||||||
"Background task is already running. "
|
_logger.debug(
|
||||||
"start_background() must not be called multiple times in the same event loop."
|
"Background task already running in same loop, skipping"
|
||||||
)
|
)
|
||||||
except RuntimeError:
|
return
|
||||||
raise # Re-raise RuntimeError from above
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug("Error checking background task loop: %s, restarting", e)
|
_logger.debug("Error checking background task loop: %s, restarting", e)
|
||||||
_background_task = None
|
_background_task = None
|
||||||
|
|
||||||
if _background_task is None:
|
if _background_task is None:
|
||||||
_background_task = asyncio.create_task(_background_loop())
|
_background_task = asyncio.create_task(_background_loop())
|
||||||
else:
|
|
||||||
_logger.debug("Background task already running: %s", _background_task)
|
|
||||||
|
|
||||||
|
|
||||||
async def stop_background():
|
async def stop_background():
|
||||||
"""Stop the background task and flush any pending changes."""
|
"""Stop the background task, flush pending changes, and release the file lock."""
|
||||||
global _background_task
|
global _background_task
|
||||||
if _background_task:
|
if _background_task:
|
||||||
_background_task.cancel()
|
_background_task.cancel()
|
||||||
@@ -101,6 +103,7 @@ async def stop_background():
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
_background_task = None
|
_background_task = None
|
||||||
|
_ops._db._store.close()
|
||||||
|
|
||||||
|
|
||||||
# Aliases for backwards compatibility
|
# Aliases for backwards compatibility
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
Bootstrap operations for initial system setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import uuid7
|
||||||
|
|
||||||
|
import paskia.db.operations as _ops
|
||||||
|
from paskia.authsession import reset_expires
|
||||||
|
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
|
||||||
|
from paskia.util.crypto import secret_key
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap(
|
||||||
|
org_name: str = "Organization",
|
||||||
|
admin_name: str = "Admin",
|
||||||
|
reset_passphrase: str | None = None,
|
||||||
|
reset_expiry: datetime | None = None,
|
||||||
|
config: Config | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Bootstrap the entire system in a single transaction.
|
||||||
|
|
||||||
|
Creates:
|
||||||
|
- auth:admin permission (Master Admin)
|
||||||
|
- auth:org:admin permission (Org Admin)
|
||||||
|
- Organization with Administration role
|
||||||
|
- Admin user with Administration role
|
||||||
|
- Reset token for admin registration
|
||||||
|
- Config (if provided)
|
||||||
|
|
||||||
|
This is the only way to create a new database file.
|
||||||
|
All data is created atomically - if any step fails, nothing is written.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_name: Display name for the organization (default: "Organization")
|
||||||
|
admin_name: Display name for the admin user (default: "Admin")
|
||||||
|
reset_passphrase: Passphrase for the reset token (generated if not provided)
|
||||||
|
reset_expiry: Expiry datetime for the reset token (default: 14 days)
|
||||||
|
config: Configuration to store (rp_id, rp_name, origins, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The reset passphrase for admin registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check if system is already bootstrapped
|
||||||
|
for p in _ops._db.permissions.values():
|
||||||
|
if p.scope == "auth:admin":
|
||||||
|
raise ValueError(
|
||||||
|
"System already bootstrapped (auth:admin permission exists)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate UUIDs upfront
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
perm_admin_uuid = uuid7.create(now)
|
||||||
|
perm_org_admin_uuid = uuid7.create(now)
|
||||||
|
org_uuid = uuid7.create(now)
|
||||||
|
role_uuid = uuid7.create(now)
|
||||||
|
user_uuid = uuid7.create(now)
|
||||||
|
|
||||||
|
# Set reset token expiry (passphrase generated by ResetToken.create)
|
||||||
|
if reset_expiry is None:
|
||||||
|
reset_expiry = reset_expires()
|
||||||
|
|
||||||
|
with _ops._db.transaction("bootstrap"):
|
||||||
|
# Create auth:admin permission
|
||||||
|
perm_admin = Permission(
|
||||||
|
scope="auth:admin",
|
||||||
|
display_name="Master Admin",
|
||||||
|
orgs={org_uuid: True}, # Grant to org
|
||||||
|
)
|
||||||
|
perm_admin.uuid = perm_admin_uuid
|
||||||
|
perm_admin.store()
|
||||||
|
|
||||||
|
# Create auth:org:admin permission
|
||||||
|
perm_org_admin = Permission(
|
||||||
|
scope="auth:org:admin",
|
||||||
|
display_name="Org Admin",
|
||||||
|
orgs={org_uuid: True}, # Grant to org
|
||||||
|
)
|
||||||
|
perm_org_admin.uuid = perm_org_admin_uuid
|
||||||
|
perm_org_admin.store()
|
||||||
|
|
||||||
|
# Create organization
|
||||||
|
new_org = Org.create(display_name=org_name)
|
||||||
|
new_org.uuid = org_uuid
|
||||||
|
new_org.store()
|
||||||
|
|
||||||
|
# Create Administration role with both permissions
|
||||||
|
admin_role = Role(
|
||||||
|
org_uuid=org_uuid,
|
||||||
|
display_name="Administration",
|
||||||
|
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
||||||
|
)
|
||||||
|
admin_role.uuid = role_uuid
|
||||||
|
admin_role.store()
|
||||||
|
|
||||||
|
# Create admin user
|
||||||
|
admin_user = User(
|
||||||
|
display_name=admin_name,
|
||||||
|
role_uuid=role_uuid,
|
||||||
|
created_at=now,
|
||||||
|
last_seen=None,
|
||||||
|
visits=0,
|
||||||
|
theme="",
|
||||||
|
)
|
||||||
|
admin_user.uuid = user_uuid
|
||||||
|
admin_user.store()
|
||||||
|
|
||||||
|
# Create reset token
|
||||||
|
reset_token, reset_passphrase = ResetToken.create(
|
||||||
|
user=user_uuid,
|
||||||
|
expiry=reset_expiry,
|
||||||
|
token_type="admin bootstrap",
|
||||||
|
passphrase=reset_passphrase,
|
||||||
|
)
|
||||||
|
reset_token.store()
|
||||||
|
|
||||||
|
# Set config if provided
|
||||||
|
if config is not None:
|
||||||
|
_ops._db.config = config
|
||||||
|
|
||||||
|
# Generate OIDC signing key
|
||||||
|
_ops._db.oidc.key = secret_key()
|
||||||
|
|
||||||
|
return reset_passphrase
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Cross-platform locked file for the database (no separate .lock files).
|
||||||
|
|
||||||
|
Unix: open() + fcntl.flock (advisory, cooperative among processes that flock).
|
||||||
|
Windows: CreateFileW with FILE_SHARE_READ (OS-enforced, allows readers, blocks writers).
|
||||||
|
|
||||||
|
A single file descriptor is opened once for both reading and writing.
|
||||||
|
The lock is acquired atomically (on Windows) or immediately after open (on Unix),
|
||||||
|
and the same descriptor is used for the lifetime of the process: first to read
|
||||||
|
the existing content, then to append new writes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _fatal(msg: str) -> None:
|
||||||
|
"""Log a fatal error and exit immediately, bypassing exception handlers."""
|
||||||
|
_logger.critical(msg)
|
||||||
|
os._exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
|
||||||
|
_GENERIC_READ = 0x80000000
|
||||||
|
_GENERIC_WRITE = 0x40000000
|
||||||
|
_FILE_SHARE_READ = 0x00000001
|
||||||
|
_OPEN_EXISTING = 3
|
||||||
|
_OPEN_ALWAYS = 4
|
||||||
|
_FILE_ATTRIBUTE_NORMAL = 0x80
|
||||||
|
_FILE_BEGIN = 0
|
||||||
|
_FILE_END = 2
|
||||||
|
_ERROR_SHARING_VIOLATION = 32
|
||||||
|
_INVALID_FILE_SIZE = 0xFFFFFFFF
|
||||||
|
|
||||||
|
_kernel32.CreateFileW.restype = wintypes.HANDLE
|
||||||
|
_kernel32.CreateFileW.argtypes = [
|
||||||
|
wintypes.LPCWSTR,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.DWORD,
|
||||||
|
ctypes.c_void_p,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.HANDLE,
|
||||||
|
]
|
||||||
|
_kernel32.ReadFile.restype = wintypes.BOOL
|
||||||
|
_kernel32.ReadFile.argtypes = [
|
||||||
|
wintypes.HANDLE,
|
||||||
|
ctypes.c_void_p,
|
||||||
|
wintypes.DWORD,
|
||||||
|
ctypes.POINTER(wintypes.DWORD),
|
||||||
|
ctypes.c_void_p,
|
||||||
|
]
|
||||||
|
_kernel32.WriteFile.restype = wintypes.BOOL
|
||||||
|
_kernel32.WriteFile.argtypes = [
|
||||||
|
wintypes.HANDLE,
|
||||||
|
ctypes.c_void_p,
|
||||||
|
wintypes.DWORD,
|
||||||
|
ctypes.POINTER(wintypes.DWORD),
|
||||||
|
ctypes.c_void_p,
|
||||||
|
]
|
||||||
|
_kernel32.GetFileSize.restype = wintypes.DWORD
|
||||||
|
_kernel32.GetFileSize.argtypes = [
|
||||||
|
wintypes.HANDLE,
|
||||||
|
ctypes.POINTER(wintypes.DWORD),
|
||||||
|
]
|
||||||
|
_kernel32.SetFilePointer.restype = wintypes.DWORD
|
||||||
|
_kernel32.SetFilePointer.argtypes = [
|
||||||
|
wintypes.HANDLE,
|
||||||
|
wintypes.LONG,
|
||||||
|
ctypes.POINTER(wintypes.LONG),
|
||||||
|
wintypes.DWORD,
|
||||||
|
]
|
||||||
|
_kernel32.CloseHandle.restype = wintypes.BOOL
|
||||||
|
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||||
|
|
||||||
|
def _is_invalid_handle(handle) -> bool:
|
||||||
|
return ctypes.c_void_p(handle).value == ctypes.c_void_p(-1).value
|
||||||
|
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
|
||||||
|
class LockedFile:
|
||||||
|
"""A file opened with an exclusive write lock.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
f = LockedFile()
|
||||||
|
f.open(path) # open + lock (read+write)
|
||||||
|
content = f.read() # read entire content
|
||||||
|
f.write(data) # append data (seeks to end first)
|
||||||
|
f.close() # release lock + close fd
|
||||||
|
|
||||||
|
Unix: fcntl.flock (advisory) — read-only callers that don't flock are unaffected.
|
||||||
|
Windows: CreateFileW with FILE_SHARE_READ — OS blocks other writers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._fd: int | None = None # Unix fd or Windows HANDLE
|
||||||
|
|
||||||
|
def open(self, path: Path, *, create: bool = False) -> None:
|
||||||
|
"""Open *path* for read+write with an exclusive lock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File to open and lock.
|
||||||
|
create: If True, create the file if it doesn't exist (bootstrap).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If the file is locked by another process or not found.
|
||||||
|
"""
|
||||||
|
if self._fd is not None:
|
||||||
|
return # Already open (idempotent)
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
self._open_win32(path, create)
|
||||||
|
else:
|
||||||
|
self._open_unix(path, create)
|
||||||
|
|
||||||
|
def open_and_read(self, path: Path) -> bytes:
|
||||||
|
"""Open *path* with exclusive lock and read all content.
|
||||||
|
|
||||||
|
Combined operation for efficient use with asyncio.to_thread().
|
||||||
|
"""
|
||||||
|
self.open(path)
|
||||||
|
return self.read()
|
||||||
|
|
||||||
|
def read(self) -> bytes:
|
||||||
|
"""Read the entire file content from the beginning."""
|
||||||
|
if self._fd is None:
|
||||||
|
raise RuntimeError("LockedFile.read() called on a closed file")
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return self._read_win32()
|
||||||
|
else:
|
||||||
|
return self._read_unix()
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
"""Append *data* to the end of the file."""
|
||||||
|
if self._fd is None:
|
||||||
|
raise RuntimeError("LockedFile.write() called on a closed file")
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
self._write_win32(data)
|
||||||
|
else:
|
||||||
|
self._write_unix(data)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Release the lock and close the file."""
|
||||||
|
if self._fd is None:
|
||||||
|
return
|
||||||
|
if sys.platform == "win32":
|
||||||
|
_kernel32.CloseHandle(self._fd)
|
||||||
|
else:
|
||||||
|
os.close(self._fd)
|
||||||
|
self._fd = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
return self._fd is not None
|
||||||
|
|
||||||
|
# -- Unix ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def _open_unix(self, path: Path, create: bool) -> None:
|
||||||
|
flags = os.O_RDWR | (os.O_CREAT if create else 0)
|
||||||
|
try:
|
||||||
|
fd = os.open(path, flags, 0o666)
|
||||||
|
except FileNotFoundError:
|
||||||
|
_fatal(f"Database file not found: {path.resolve()}")
|
||||||
|
try:
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
os.close(fd)
|
||||||
|
_fatal(f"🛑 {path.resolve()}: database already locked by another instance")
|
||||||
|
self._fd = fd
|
||||||
|
|
||||||
|
def _read_unix(self) -> bytes:
|
||||||
|
os.lseek(self._fd, 0, os.SEEK_SET)
|
||||||
|
chunks = []
|
||||||
|
while True:
|
||||||
|
chunk = os.read(self._fd, 1 << 20) # 1 MiB
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
def _write_unix(self, data: bytes) -> None:
|
||||||
|
os.lseek(self._fd, 0, os.SEEK_END)
|
||||||
|
os.write(self._fd, data)
|
||||||
|
|
||||||
|
# -- Windows -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _open_win32(self, path: Path, create: bool) -> None:
|
||||||
|
disposition = _OPEN_ALWAYS if create else _OPEN_EXISTING
|
||||||
|
handle = _kernel32.CreateFileW(
|
||||||
|
str(path),
|
||||||
|
_GENERIC_READ | _GENERIC_WRITE,
|
||||||
|
_FILE_SHARE_READ,
|
||||||
|
None,
|
||||||
|
disposition,
|
||||||
|
_FILE_ATTRIBUTE_NORMAL,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if _is_invalid_handle(handle):
|
||||||
|
err = ctypes.get_last_error()
|
||||||
|
if err == _ERROR_SHARING_VIOLATION:
|
||||||
|
_fatal(
|
||||||
|
f"🛑 {path.resolve()}: database already locked by another instance"
|
||||||
|
)
|
||||||
|
_fatal(f"Failed to open database {path.resolve()}: Windows error {err}")
|
||||||
|
self._fd = handle
|
||||||
|
|
||||||
|
def _read_win32(self) -> bytes:
|
||||||
|
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_BEGIN)
|
||||||
|
size = _kernel32.GetFileSize(self._fd, None)
|
||||||
|
if size == _INVALID_FILE_SIZE:
|
||||||
|
raise OSError(
|
||||||
|
f"GetFileSize failed: Windows error {ctypes.get_last_error()}"
|
||||||
|
)
|
||||||
|
if size == 0:
|
||||||
|
return b""
|
||||||
|
buf = ctypes.create_string_buffer(size)
|
||||||
|
bytes_read = wintypes.DWORD()
|
||||||
|
ok = _kernel32.ReadFile(self._fd, buf, size, ctypes.byref(bytes_read), None)
|
||||||
|
if not ok:
|
||||||
|
raise OSError(f"ReadFile failed: Windows error {ctypes.get_last_error()}")
|
||||||
|
return buf.raw[: bytes_read.value]
|
||||||
|
|
||||||
|
def _write_win32(self, data: bytes) -> None:
|
||||||
|
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_END)
|
||||||
|
written = wintypes.DWORD()
|
||||||
|
ok = _kernel32.WriteFile(
|
||||||
|
self._fd,
|
||||||
|
data,
|
||||||
|
len(data),
|
||||||
|
ctypes.byref(written),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
raise OSError(f"WriteFile failed: Windows error {ctypes.get_last_error()}")
|
||||||
+197
-128
@@ -2,8 +2,11 @@
|
|||||||
JSONL persistence layer for the database.
|
JSONL persistence layer for the database.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -11,177 +14,194 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
import jsondiff
|
import jsondiff
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
|
from paskia.db.filelock import LockedFile
|
||||||
from paskia.db.logging import log_change
|
from paskia.db.logging import log_change
|
||||||
from paskia.db.migrations import DBVER, apply_all_migrations
|
from paskia.db.migrations import (
|
||||||
from paskia.db.structs import DB, SessionContext
|
DBVER,
|
||||||
|
MigrationCtx,
|
||||||
|
apply_all_migrations,
|
||||||
|
apply_migrations_readonly,
|
||||||
|
)
|
||||||
|
from paskia.db.snapshot import SnapshotState
|
||||||
|
from paskia.db.structs import DB, Config, SessionContext
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Default database path
|
|
||||||
DB_PATH_DEFAULT = "paskia.jsonl"
|
class ReplayResult(msgspec.Struct, frozen=False):
|
||||||
|
"""Return value of _replay_from_data"""
|
||||||
|
|
||||||
|
state: dict
|
||||||
|
v: int = 0
|
||||||
|
ts: datetime | None = None
|
||||||
|
snapts: datetime | None = None
|
||||||
|
changes: int = 0
|
||||||
|
|
||||||
|
|
||||||
class _ChangeRecord(msgspec.Struct, omit_defaults=True):
|
class DatabaseError(Exception):
|
||||||
"""A single change record in the JSONL file."""
|
"""Exception raised for database loading errors."""
|
||||||
|
|
||||||
ts: datetime
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
|
||||||
|
"""Replay database state from file data, using the last snapshot if available."""
|
||||||
|
resolved_path = str(Path(db_path).resolve())
|
||||||
|
result = ReplayResult(state={})
|
||||||
|
|
||||||
|
# Find and apply the last snapshot
|
||||||
|
snap, start_offset = SnapshotState.load(data)
|
||||||
|
if snap:
|
||||||
|
result.state = snap.state
|
||||||
|
result.v = snap.v
|
||||||
|
result.snapts = snap.ts
|
||||||
|
|
||||||
|
# Replay change records after the snapshot
|
||||||
|
lines = data[start_offset:].split(b"\n")
|
||||||
|
for line_num, raw in enumerate(lines, start=1): # 1-based line numbering
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
change = msgspec.json.decode(line, type=ChangeRecord)
|
||||||
|
except msgspec.DecodeError as e:
|
||||||
|
raise DatabaseError(f"{resolved_path}:{line_num}: {e}")
|
||||||
|
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
|
||||||
|
result.v = change.v
|
||||||
|
result.ts = change.ts
|
||||||
|
result.changes += 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
||||||
|
"""Replay JSONL and apply migrations to produce a DB, without writing anything.
|
||||||
|
|
||||||
|
This is suitable for reading settings before the server starts.
|
||||||
|
Migrations are applied in-memory only; nothing is queued or flushed.
|
||||||
|
"""
|
||||||
|
path = Path(db_path)
|
||||||
|
if not path.exists():
|
||||||
|
return DB(config=Config(rp_id=rp_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
r = _replay_from_data(content, str(path.resolve()))
|
||||||
|
data_dict = r.state
|
||||||
|
version = r.v
|
||||||
|
except OSError as e:
|
||||||
|
_logger.exception("Failed to load database")
|
||||||
|
raise SystemExit(f"{e}")
|
||||||
|
except (ValueError, msgspec.DecodeError, DatabaseError) as e:
|
||||||
|
raise SystemExit(f"{e}")
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("Unexpected error loading database")
|
||||||
|
raise SystemExit(f"{e}")
|
||||||
|
|
||||||
|
if not data_dict:
|
||||||
|
return DB(config=Config(rp_id=rp_id))
|
||||||
|
|
||||||
|
# Apply migrations in-memory (no persistence)
|
||||||
|
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
||||||
|
|
||||||
|
# Decode to msgspec struct
|
||||||
|
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
|
||||||
|
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
|
||||||
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
|
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
|
||||||
v: int # schema version after this change
|
v: int = 0 # schema version after this change
|
||||||
u: str | None = None # user UUID who performed the action (None for system)
|
u: str | None = None # user UUID who performed the action (None for system)
|
||||||
diff: dict = {}
|
diff: dict
|
||||||
|
|
||||||
|
|
||||||
# msgspec encoder for change records
|
|
||||||
_change_encoder = msgspec.json.Encoder()
|
|
||||||
|
|
||||||
|
|
||||||
def compute_diff(previous: dict, current: dict) -> dict | None:
|
def compute_diff(previous: dict, current: dict) -> dict | None:
|
||||||
"""Compute JSON diff between two states.
|
return jsondiff.diff(previous, current, marshal=True) or None
|
||||||
|
|
||||||
Args:
|
|
||||||
previous: Previous state (JSON-compatible dict)
|
|
||||||
current: Current state (JSON-compatible dict)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The diff, or None if no changes
|
|
||||||
"""
|
|
||||||
diff = jsondiff.diff(previous, current, marshal=True)
|
|
||||||
return diff if diff else None
|
|
||||||
|
|
||||||
|
|
||||||
def create_change_record(
|
|
||||||
action: str, version: int, diff: dict, user: str | None = None
|
|
||||||
) -> _ChangeRecord:
|
|
||||||
"""Create a change record for persistence."""
|
|
||||||
return _ChangeRecord(
|
|
||||||
ts=datetime.now(UTC),
|
|
||||||
a=action,
|
|
||||||
v=version,
|
|
||||||
u=user,
|
|
||||||
diff=diff,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Actions that are allowed to create a new database file
|
# Actions that are allowed to create a new database file
|
||||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap", "migrate:sql"})
|
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
|
||||||
|
|
||||||
|
|
||||||
async def flush_changes(
|
|
||||||
db_path: Path,
|
|
||||||
pending_changes: deque[_ChangeRecord],
|
|
||||||
) -> bool:
|
|
||||||
"""Write all pending changes to disk.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to the JSONL database file
|
|
||||||
pending_changes: Queue of pending change records (will be cleared on success)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if flush succeeded, False otherwise
|
|
||||||
"""
|
|
||||||
if not pending_changes:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if not db_path.exists():
|
|
||||||
first_action = pending_changes[0].a
|
|
||||||
if first_action not in _BOOTSTRAP_ACTIONS:
|
|
||||||
_logger.error(
|
|
||||||
"Refusing to create database file with action '%s' - "
|
|
||||||
"only bootstrap or migrate can create a new database",
|
|
||||||
first_action,
|
|
||||||
)
|
|
||||||
pending_changes.clear()
|
|
||||||
return False
|
|
||||||
|
|
||||||
changes_to_write = list(pending_changes)
|
|
||||||
pending_changes.clear()
|
|
||||||
|
|
||||||
try:
|
|
||||||
lines = [_change_encoder.encode(change) for change in changes_to_write]
|
|
||||||
if not lines:
|
|
||||||
return True
|
|
||||||
|
|
||||||
async with aiofiles.open(db_path, "ab") as f:
|
|
||||||
await f.write(b"\n".join(lines) + b"\n")
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
_logger.exception("Failed to flush database changes")
|
|
||||||
# Re-queue the changes on failure
|
|
||||||
for change in reversed(changes_to_write):
|
|
||||||
pending_changes.appendleft(change)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class JsonlStore:
|
class JsonlStore:
|
||||||
"""JSONL persistence layer for a DB instance."""
|
"""JSONL persistence layer for a DB instance."""
|
||||||
|
|
||||||
def __init__(self, db: DB, db_path: str = DB_PATH_DEFAULT):
|
def __init__(self, db: DB, db_path: str):
|
||||||
self.db: DB = db
|
self.db: DB = db
|
||||||
self.db_path = Path(db_path)
|
self.db_path = Path(db_path)
|
||||||
self._previous_builtins: dict[str, Any] = {}
|
self._file = LockedFile()
|
||||||
self._pending_changes: deque[_ChangeRecord] = deque()
|
self._flush_failed = False
|
||||||
|
self._statedict: dict[str, Any] = {}
|
||||||
|
self._pending_changes: deque[ChangeRecord] = deque()
|
||||||
self._current_action: str = "system"
|
self._current_action: str = "system"
|
||||||
self._current_user: str | None = None
|
self._current_user: str | None = None
|
||||||
self._in_transaction: bool = False
|
self._in_transaction: bool = False
|
||||||
self._transaction_snapshot: dict[str, Any] | None = None
|
self._transaction_snapshot: dict[str, Any] | None = None
|
||||||
self._current_version: int = DBVER # Schema version for new databases
|
self._v: int = DBVER # Schema version for new databases
|
||||||
|
self._snapshot = SnapshotState()
|
||||||
|
|
||||||
async def load(self, db_path: str | None = None) -> None:
|
async def load(
|
||||||
|
self, db_path: str | None = None, *, rp_id: str = "localhost"
|
||||||
|
) -> None:
|
||||||
"""Load data from JSONL change log."""
|
"""Load data from JSONL change log."""
|
||||||
if db_path is not None:
|
if db_path is not None:
|
||||||
self.db_path = Path(db_path)
|
self.db_path = Path(db_path)
|
||||||
|
self._rp_id = rp_id
|
||||||
if not self.db_path.exists():
|
if not self.db_path.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Replay change log to reconstruct state
|
# Open with exclusive write lock and read contents — single threadpool call
|
||||||
data_dict: dict = {}
|
content = await asyncio.to_thread(self._file.open_and_read, self.db_path)
|
||||||
try:
|
|
||||||
async with aiofiles.open(self.db_path, "rb") as f:
|
|
||||||
content = await f.read()
|
|
||||||
for line_num, line in enumerate(content.split(b"\n"), 1):
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
change = msgspec.json.decode(line)
|
|
||||||
data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True)
|
|
||||||
self._current_version = change.get("v", 0)
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"Error parsing line {line_num}: {e}")
|
|
||||||
except (OSError, ValueError, msgspec.DecodeError) as e:
|
|
||||||
raise ValueError(f"Failed to load database: {e}")
|
|
||||||
|
|
||||||
if not data_dict:
|
# Replay change log to reconstruct state (snapshot-accelerated)
|
||||||
|
try:
|
||||||
|
r = _replay_from_data(content, str(self.db_path.resolve()))
|
||||||
|
statedict = r.state
|
||||||
|
self._v = r.v
|
||||||
|
self._snapshot.ts = r.snapts
|
||||||
|
self._snapshot.changes = r.changes
|
||||||
|
except (OSError, ValueError, msgspec.DecodeError, DatabaseError) as e:
|
||||||
|
raise SystemExit(f"{e}")
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("Unexpected error loading database")
|
||||||
|
raise SystemExit(f"{e}")
|
||||||
|
|
||||||
|
if not statedict:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Set previous state for diffing (will be updated by _queue_change)
|
# Set previous state for diffing (will be updated by _queue_change)
|
||||||
self._previous_builtins = copy.deepcopy(data_dict)
|
self._statedict = copy.deepcopy(statedict)
|
||||||
|
|
||||||
# Callback to persist each migration
|
# Callback to persist each migration
|
||||||
async def persist_migration(
|
async def persist_migration(
|
||||||
action: str, new_version: int, current: dict
|
action: str, new_version: int, current: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
self._current_version = new_version
|
self._v = new_version
|
||||||
self._queue_change(action, new_version, current)
|
self._queue_change(action, new_version, current)
|
||||||
|
|
||||||
# Apply schema migrations one at a time
|
# Apply schema migrations one at a time
|
||||||
await apply_all_migrations(data_dict, self._current_version, persist_migration)
|
await apply_all_migrations(
|
||||||
|
statedict,
|
||||||
|
self._v,
|
||||||
|
persist_migration,
|
||||||
|
MigrationCtx(rp_id=rp_id),
|
||||||
|
)
|
||||||
|
|
||||||
# Decode to msgspec struct
|
# Decode to msgspec struct
|
||||||
decoder = msgspec.json.Decoder(DB)
|
decoder = msgspec.json.Decoder(DB)
|
||||||
self.db = decoder.decode(msgspec.json.encode(data_dict))
|
self.db = decoder.decode(msgspec.json.encode(statedict))
|
||||||
self.db._store = self
|
self.db._store = self
|
||||||
|
|
||||||
# Normalize via msgspec round-trip (handles omit_defaults etc.)
|
# Normalize via msgspec round-trip (handles omit_defaults etc.)
|
||||||
# This ensures _previous_builtins matches what msgspec would produce
|
# This ensures _previous_builtins matches what msgspec would produce
|
||||||
normalized_dict = msgspec.to_builtins(self.db)
|
normalized_dict = msgspec.to_builtins(self.db)
|
||||||
await persist_migration(
|
await persist_migration("migrate:msgspec", self._v, normalized_dict)
|
||||||
"migrate:msgspec", self._current_version, normalized_dict
|
|
||||||
)
|
|
||||||
|
|
||||||
def _queue_change(
|
def _queue_change(
|
||||||
self, action: str, version: int, current: dict, user: str | None = None
|
self, action: str, version: int, current: dict, user: str | None = None
|
||||||
@@ -194,10 +214,17 @@ class JsonlStore:
|
|||||||
current: The current state as a plain dict
|
current: The current state as a plain dict
|
||||||
user: Optional user UUID who performed the action
|
user: Optional user UUID who performed the action
|
||||||
"""
|
"""
|
||||||
diff = compute_diff(self._previous_builtins, current)
|
diff = compute_diff(self._statedict, current)
|
||||||
if not diff:
|
if not diff:
|
||||||
return
|
return
|
||||||
self._pending_changes.append(create_change_record(action, version, diff, user))
|
self._pending_changes.append(
|
||||||
|
ChangeRecord(
|
||||||
|
a=action,
|
||||||
|
v=version,
|
||||||
|
u=user,
|
||||||
|
diff=diff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Log the change with user display name if available
|
# Log the change with user display name if available
|
||||||
user_display = None
|
user_display = None
|
||||||
@@ -209,8 +236,8 @@ class JsonlStore:
|
|||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
user_display = user
|
user_display = user
|
||||||
|
|
||||||
log_change(action, diff, user_display, self._previous_builtins)
|
log_change(action, diff, user_display, self._statedict, self.db)
|
||||||
self._previous_builtins = copy.deepcopy(current)
|
self._statedict = copy.deepcopy(current)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def transaction(
|
def transaction(
|
||||||
@@ -232,13 +259,13 @@ class JsonlStore:
|
|||||||
|
|
||||||
# Check for out-of-transaction modifications
|
# Check for out-of-transaction modifications
|
||||||
current_state = msgspec.to_builtins(self.db)
|
current_state = msgspec.to_builtins(self.db)
|
||||||
if current_state != self._previous_builtins:
|
if current_state != self._statedict:
|
||||||
# Allow bootstrap/migrate to create a new database from empty state
|
# Allow bootstrap to create a new database from empty state
|
||||||
is_bootstrap = action in _BOOTSTRAP_ACTIONS or action.startswith("migrate:")
|
is_bootstrap = action in _BOOTSTRAP_ACTIONS
|
||||||
if is_bootstrap and not self._previous_builtins:
|
if is_bootstrap and not self._statedict:
|
||||||
pass # Expected: creating database from scratch
|
pass # Expected: creating database from scratch
|
||||||
else:
|
else:
|
||||||
diff = compute_diff(self._previous_builtins, current_state)
|
diff = compute_diff(self._statedict, current_state)
|
||||||
diff_json = msgspec.json.encode(diff).decode()
|
diff_json = msgspec.json.encode(diff).decode()
|
||||||
_logger.critical(
|
_logger.critical(
|
||||||
"Database state modified outside of transaction! "
|
"Database state modified outside of transaction! "
|
||||||
@@ -259,7 +286,7 @@ class JsonlStore:
|
|||||||
yield
|
yield
|
||||||
current = msgspec.to_builtins(self.db)
|
current = msgspec.to_builtins(self.db)
|
||||||
self._queue_change(
|
self._queue_change(
|
||||||
self._current_action, self._current_version, current, self._current_user
|
self._current_action, self._v, current, self._current_user
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Rollback on error: restore from snapshot
|
# Rollback on error: restore from snapshot
|
||||||
@@ -277,6 +304,48 @@ class JsonlStore:
|
|||||||
self._in_transaction = False
|
self._in_transaction = False
|
||||||
self._transaction_snapshot = None
|
self._transaction_snapshot = None
|
||||||
|
|
||||||
async def flush(self) -> bool:
|
async def flush(self) -> None:
|
||||||
"""Write all pending changes to disk."""
|
"""Write all pending changes to disk.
|
||||||
return await flush_changes(self.db_path, self._pending_changes)
|
|
||||||
|
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
|
||||||
|
"""
|
||||||
|
if self._flush_failed or not self._pending_changes:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._file.is_open:
|
||||||
|
first_action = self._pending_changes[0].a
|
||||||
|
if first_action not in _BOOTSTRAP_ACTIONS:
|
||||||
|
_logger.error(
|
||||||
|
"Refusing to create database file with action '%s' - "
|
||||||
|
"only bootstrap can create a new database",
|
||||||
|
first_action,
|
||||||
|
)
|
||||||
|
self._flush_failed = True
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
return
|
||||||
|
# Bootstrap: create and open the file with lock
|
||||||
|
await asyncio.to_thread(self._file.open, self.db_path, create=True)
|
||||||
|
|
||||||
|
changes_to_write = list(self._pending_changes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = [msgspec.json.encode(change) for change in changes_to_write]
|
||||||
|
if not lines:
|
||||||
|
self._pending_changes.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.to_thread(self._file.write, b"\n".join(lines) + b"\n")
|
||||||
|
self._snapshot.record_lines(len(lines))
|
||||||
|
self._pending_changes.clear()
|
||||||
|
except OSError as e:
|
||||||
|
_logger.error("Failed to flush database: %s", e)
|
||||||
|
self._flush_failed = True
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
|
||||||
|
def maybe_snapshot(self) -> None:
|
||||||
|
"""Write a snapshot if conditions are met."""
|
||||||
|
self._snapshot.maybe_write(self._file, self._v, self._statedict)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Release the file lock and close the file."""
|
||||||
|
self._file.close()
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
Database lifecycle: initialization and maintenance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import paskia.db.operations as _ops
|
||||||
|
from paskia import oidc_notify
|
||||||
|
from paskia.authsession import EXPIRES
|
||||||
|
from paskia.db.jsonl import JsonlStore
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def init(rp_id: str, *args, **kwargs):
|
||||||
|
"""Load database from JSONL file."""
|
||||||
|
if _ops._db._store:
|
||||||
|
_logger.debug("Database already initialized, skipping reload")
|
||||||
|
return
|
||||||
|
db_path = os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb")
|
||||||
|
store = JsonlStore(_ops._db, db_path)
|
||||||
|
await store.load(db_path, rp_id=rp_id)
|
||||||
|
_ops._db = store.db
|
||||||
|
_ops._db._store = store
|
||||||
|
# Request a snapshot after successful startup
|
||||||
|
store._snapshot.request_force()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired() -> int:
|
||||||
|
"""Remove expired sessions and reset tokens. Returns count removed."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
count = 0
|
||||||
|
limit = now - EXPIRES
|
||||||
|
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
|
||||||
|
if expired_sessions:
|
||||||
|
oidc_notify.schedule_notifications(expired_sessions)
|
||||||
|
with _ops._db.transaction("expiry"):
|
||||||
|
for k in expired_sessions:
|
||||||
|
del _ops._db.sessions[k]
|
||||||
|
count += 1
|
||||||
|
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
|
||||||
|
for k in expired_tokens:
|
||||||
|
del _ops._db.reset_tokens[k]
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
+222
-74
@@ -3,15 +3,27 @@ Database change logging with pretty-printed diffs.
|
|||||||
|
|
||||||
Provides a logger for JSONL database changes that formats diffs
|
Provides a logger for JSONL database changes that formats diffs
|
||||||
in a human-readable path.notation style with color coding.
|
in a human-readable path.notation style with color coding.
|
||||||
|
|
||||||
|
UUIDs are replaced with display names where available, or the full UUID string
|
||||||
|
for types without display names.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from paskia.db.structs import DB
|
||||||
|
|
||||||
logger = logging.getLogger("paskia.db")
|
logger = logging.getLogger("paskia.db")
|
||||||
|
|
||||||
|
# UUID regex pattern (8-4-4-4-12 hex format)
|
||||||
|
_UUID_PATTERN = re.compile(
|
||||||
|
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||||
|
)
|
||||||
|
|
||||||
# Pattern to match control characters and bidirectional overrides
|
# Pattern to match control characters and bidirectional overrides
|
||||||
_UNSAFE_CHARS = re.compile(
|
_UNSAFE_CHARS = re.compile(
|
||||||
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
|
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
|
||||||
@@ -23,22 +35,123 @@ _UNSAFE_CHARS = re.compile(
|
|||||||
|
|
||||||
# ANSI color codes (matching FastAPI logging style)
|
# ANSI color codes (matching FastAPI logging style)
|
||||||
_RESET = "\033[0m"
|
_RESET = "\033[0m"
|
||||||
_DIM = "\033[2m"
|
_SEP = "\033[38;5;242m" # Dark grey for separators (like host/timing in access log)
|
||||||
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
|
_PATH_PREFIX = "\033[38;5;242m" # Dark grey for path prefix (like host in access log)
|
||||||
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
|
_PATH_FINAL = "\033[38;5;250m" # Default for final element (like path in access log)
|
||||||
_DELETE = "\033[1;31m" # Red for deletions
|
_DELETE = "\033[1;31m" # Red for deletions
|
||||||
_ADD = "\033[0;32m" # Green for additions
|
_ADD = "\033[0;32m" # Green for additions
|
||||||
_ACTION = "\033[1;34m" # Bold blue for action name
|
_ACTION = "\033[1;34m" # Bold blue for action name
|
||||||
_USER = "\033[0;34m" # Blue for user display
|
_USER = "\033[0;34m" # Blue for user display
|
||||||
|
|
||||||
|
|
||||||
def _use_color() -> bool:
|
def _is_uuid(value: str) -> bool:
|
||||||
"""Check if we should use color output."""
|
"""Check if a string is a UUID."""
|
||||||
return sys.stderr.isatty()
|
return bool(_UUID_PATTERN.match(value))
|
||||||
|
|
||||||
|
|
||||||
def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
|
class UuidResolver:
|
||||||
"""Format a value for display, truncating if needed."""
|
"""Resolve UUIDs to display names or short suffixes.
|
||||||
|
|
||||||
|
Uses the previous state for lookups to show the name before any changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
|
||||||
|
self._db = db
|
||||||
|
self._previous = previous
|
||||||
|
|
||||||
|
def resolve(self, uuid_str: str) -> str:
|
||||||
|
"""Resolve a UUID to its display name or the full UUID string."""
|
||||||
|
display = self._get_display_name(uuid_str)
|
||||||
|
if display:
|
||||||
|
return display
|
||||||
|
return uuid_str
|
||||||
|
|
||||||
|
def _get_display_name(self, uuid_str: str) -> str | None:
|
||||||
|
"""Look up display name for a UUID.
|
||||||
|
|
||||||
|
First checks the previous state (to show names before changes),
|
||||||
|
then falls back to the current database.
|
||||||
|
"""
|
||||||
|
# Try previous state first (for showing name before a change)
|
||||||
|
name = self._lookup_in_previous(uuid_str)
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Fall back to current database
|
||||||
|
return self._lookup_in_db(uuid_str)
|
||||||
|
|
||||||
|
def _lookup_in_previous(self, uuid_str: str) -> str | None:
|
||||||
|
"""Look up display name in the previous state dict."""
|
||||||
|
if not self._previous:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check users
|
||||||
|
if "users" in self._previous and uuid_str in self._previous["users"]:
|
||||||
|
user_data = self._previous["users"][uuid_str]
|
||||||
|
if isinstance(user_data, dict) and "display_name" in user_data:
|
||||||
|
return user_data["display_name"]
|
||||||
|
|
||||||
|
# Check orgs
|
||||||
|
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
|
||||||
|
org_data = self._previous["orgs"][uuid_str]
|
||||||
|
if isinstance(org_data, dict) and "display_name" in org_data:
|
||||||
|
return org_data["display_name"]
|
||||||
|
|
||||||
|
# Check roles
|
||||||
|
if "roles" in self._previous and uuid_str in self._previous["roles"]:
|
||||||
|
role_data = self._previous["roles"][uuid_str]
|
||||||
|
if isinstance(role_data, dict) and "display_name" in role_data:
|
||||||
|
return role_data["display_name"]
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if (
|
||||||
|
"permissions" in self._previous
|
||||||
|
and uuid_str in self._previous["permissions"]
|
||||||
|
):
|
||||||
|
perm_data = self._previous["permissions"][uuid_str]
|
||||||
|
if isinstance(perm_data, dict) and "display_name" in perm_data:
|
||||||
|
return perm_data["display_name"]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _lookup_in_db(self, uuid_str: str) -> str | None:
|
||||||
|
"""Look up display name in the current database."""
|
||||||
|
if not self._db:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
uuid_obj = UUID(uuid_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check users
|
||||||
|
if uuid_obj in self._db.users:
|
||||||
|
return self._db.users[uuid_obj].display_name
|
||||||
|
|
||||||
|
# Check orgs
|
||||||
|
if uuid_obj in self._db.orgs:
|
||||||
|
return self._db.orgs[uuid_obj].display_name
|
||||||
|
|
||||||
|
# Check roles
|
||||||
|
if uuid_obj in self._db.roles:
|
||||||
|
return self._db.roles[uuid_obj].display_name
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if uuid_obj in self._db.permissions:
|
||||||
|
return self._db.permissions[uuid_obj].display_name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_value(
|
||||||
|
value: Any,
|
||||||
|
max_len: int = 60,
|
||||||
|
resolver: UuidResolver | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Format a value for display, truncating if needed.
|
||||||
|
|
||||||
|
If resolver is provided, UUIDs are replaced with display names or short suffixes.
|
||||||
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return "null"
|
return "null"
|
||||||
|
|
||||||
@@ -49,6 +162,9 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
# Check if it's a UUID and resolve to display name
|
||||||
|
if resolver and _is_uuid(value):
|
||||||
|
return resolver.resolve(value)
|
||||||
# Filter out control characters and bidirectional overrides
|
# Filter out control characters and bidirectional overrides
|
||||||
value = _UNSAFE_CHARS.sub("", value)
|
value = _UNSAFE_CHARS.sub("", value)
|
||||||
# Truncate long strings
|
# Truncate long strings
|
||||||
@@ -59,18 +175,24 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
|
|||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
if not value:
|
if not value:
|
||||||
return "{}"
|
return "{}"
|
||||||
# For small dicts, show inline
|
# Check if all values are True - render as set-like {key1, key2}
|
||||||
if len(value) == 1:
|
all_true = all(v is True for v in value.values())
|
||||||
k, v = next(iter(value.items()))
|
parts = []
|
||||||
return "{" + f"{k}: {_format_value(v, use_color, max_len=30)}" + "}"
|
for k, v in value.items():
|
||||||
return f"{{...{len(value)} keys}}"
|
# Replace UUID keys with display names
|
||||||
|
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
|
||||||
|
if all_true:
|
||||||
|
parts.append(key_display)
|
||||||
|
else:
|
||||||
|
val_display = _format_value(v, max_len=30, resolver=resolver)
|
||||||
|
parts.append(f"{key_display}: {val_display}")
|
||||||
|
return "{" + ", ".join(parts) + "}"
|
||||||
|
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
if not value:
|
if not value:
|
||||||
return "[]"
|
return "[]"
|
||||||
if len(value) == 1:
|
parts = [_format_value(v, max_len=30, resolver=resolver) for v in value]
|
||||||
return "[" + _format_value(value[0], use_color, max_len=30) + "]"
|
return "[" + ", ".join(parts) + "]"
|
||||||
return f"[...{len(value)} items]"
|
|
||||||
|
|
||||||
# Fallback for other types
|
# Fallback for other types
|
||||||
text = str(value)
|
text = str(value)
|
||||||
@@ -79,12 +201,18 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _format_path(path: list[str], use_color: bool) -> str:
|
def _format_path(path: list[str], resolver: UuidResolver | None = None) -> str:
|
||||||
"""Format a path as dot notation with prefix in dark grey, final in default."""
|
"""Format a path as dot notation with prefix in dark grey, final in default.
|
||||||
|
|
||||||
|
If resolver is provided, UUIDs in the path are replaced with display names.
|
||||||
|
"""
|
||||||
if not path:
|
if not path:
|
||||||
return ""
|
return ""
|
||||||
if not use_color:
|
|
||||||
return ".".join(path)
|
# Replace UUIDs in path with display names
|
||||||
|
if resolver:
|
||||||
|
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
|
||||||
|
|
||||||
if len(path) == 1:
|
if len(path) == 1:
|
||||||
return f"{_PATH_FINAL}{path[0]}{_RESET}"
|
return f"{_PATH_FINAL}{path[0]}{_RESET}"
|
||||||
prefix = ".".join(path[:-1])
|
prefix = ".".join(path[:-1])
|
||||||
@@ -176,16 +304,35 @@ def _collect_changes(
|
|||||||
|
|
||||||
|
|
||||||
def _format_change_lines(
|
def _format_change_lines(
|
||||||
change_type: str, path: list[str], value: Any, use_color: bool
|
change_type: str,
|
||||||
|
path: list[str],
|
||||||
|
value: Any,
|
||||||
|
resolver: UuidResolver | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Format a single change as one or more lines."""
|
"""Format a single change as one or more lines.
|
||||||
|
|
||||||
|
If resolver is provided, UUIDs are replaced with display names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Helper to format a value, checking for censored paths
|
||||||
|
def fmt_value(v: Any, child_path: list[str]) -> str:
|
||||||
|
if child_path[-2:] == ["oidc", "key"]:
|
||||||
|
return f"{_SEP}<hidden>{_RESET}"
|
||||||
|
return _format_value(v, resolver=resolver)
|
||||||
|
|
||||||
|
# Helper to format path with UUID replacement
|
||||||
|
def fmt_path(p: list[str]) -> list[str]:
|
||||||
|
if resolver:
|
||||||
|
return [resolver.resolve(x) if _is_uuid(x) else x for x in p]
|
||||||
|
return p
|
||||||
|
|
||||||
|
formatted_path = fmt_path(path)
|
||||||
|
|
||||||
if change_type == "delete":
|
if change_type == "delete":
|
||||||
if not use_color:
|
if len(formatted_path) == 1:
|
||||||
return [f" {'.'.join(path)} ✗"]
|
return [f" {_DELETE}{formatted_path[0]} ✗{_RESET}"]
|
||||||
if len(path) == 1:
|
prefix = ".".join(formatted_path[:-1])
|
||||||
return [f" {_DELETE}{path[0]} ✗{_RESET}"]
|
final = formatted_path[-1]
|
||||||
prefix = ".".join(path[:-1])
|
|
||||||
final = path[-1]
|
|
||||||
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
||||||
|
|
||||||
if change_type == "add":
|
if change_type == "add":
|
||||||
@@ -194,87 +341,83 @@ def _format_change_lines(
|
|||||||
if isinstance(value, dict) and value:
|
if isinstance(value, dict) and value:
|
||||||
lines = []
|
lines = []
|
||||||
# First line: path with green final element and grey =
|
# First line: path with green final element and grey =
|
||||||
if not use_color:
|
if len(formatted_path) == 1:
|
||||||
lines.append(f" {'.'.join(path)} =")
|
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET}")
|
||||||
elif len(path) == 1:
|
|
||||||
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
|
|
||||||
else:
|
else:
|
||||||
prefix = ".".join(path[:-1])
|
prefix = ".".join(formatted_path[:-1])
|
||||||
final = path[-1]
|
final = formatted_path[-1]
|
||||||
lines.append(
|
lines.append(
|
||||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
|
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET}"
|
||||||
)
|
)
|
||||||
# Child lines: indented key: value, with aligned values
|
# Child lines: indented key: value, with aligned values
|
||||||
max_key_len = max(len(k) for k in value.keys())
|
# Format keys (may contain UUIDs)
|
||||||
field_width = max(max_key_len, 12) # minimum 12 chars
|
formatted_items = []
|
||||||
for k, v in value.items():
|
for k, v in value.items():
|
||||||
v_str = _format_value(v, use_color)
|
k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
|
||||||
padding = " " * (field_width - len(k))
|
v_str = fmt_value(v, path + [k])
|
||||||
if use_color:
|
formatted_items.append((k_display, v_str))
|
||||||
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
|
max_key_len = max(len(k) for k, _ in formatted_items)
|
||||||
else:
|
field_width = max(max_key_len, 12) # minimum 12 chars
|
||||||
lines.append(f" {k}:{padding} {v_str}")
|
for k_display, v_str in formatted_items:
|
||||||
|
padding = " " * (field_width - len(k_display))
|
||||||
|
lines.append(f" {k_display}{_SEP}:{_RESET}{padding} {v_str}")
|
||||||
return lines
|
return lines
|
||||||
else:
|
else:
|
||||||
value_str = _format_value(value, use_color)
|
value_str = fmt_value(value, path)
|
||||||
if not use_color:
|
if len(formatted_path) == 1:
|
||||||
return [f" {'.'.join(path)} = {value_str}"]
|
return [
|
||||||
if len(path) == 1:
|
f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET} {value_str}"
|
||||||
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
|
]
|
||||||
prefix = ".".join(path[:-1])
|
prefix = ".".join(formatted_path[:-1])
|
||||||
final = path[-1]
|
final = formatted_path[-1]
|
||||||
return [
|
return [
|
||||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
|
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET} {value_str}"
|
||||||
]
|
]
|
||||||
|
|
||||||
# update: Existing item being updated - normal path colors
|
# update: Existing item being updated - normal path colors
|
||||||
value_str = _format_value(value, use_color)
|
value_str = fmt_value(value, path)
|
||||||
path_str = _format_path(path, use_color)
|
path_str = _format_path(path, resolver=resolver)
|
||||||
if use_color:
|
return [f" {path_str} {_SEP}={_RESET} {value_str}"]
|
||||||
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
|
|
||||||
return [f" {path_str} = {value_str}"]
|
|
||||||
|
|
||||||
|
|
||||||
def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
|
def format_diff(
|
||||||
|
diff: dict, previous: dict | None = None, db: "DB | None" = None
|
||||||
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Format a JSON diff as human-readable lines.
|
Format a JSON diff as human-readable lines.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
diff: The JSON diff dict
|
diff: The JSON diff dict
|
||||||
previous: The previous state dict (for determining add vs update)
|
previous: The previous state dict (for determining add vs update)
|
||||||
|
db: Optional database for looking up display names
|
||||||
|
|
||||||
Returns a list of formatted lines (without newlines).
|
Returns a list of formatted lines (without newlines).
|
||||||
Single changes return one line, multiple changes return multiple lines.
|
UUIDs are replaced with display names (using previous state for lookups).
|
||||||
"""
|
"""
|
||||||
use_color = _use_color()
|
|
||||||
changes: list[tuple[str, list[str], Any]] = []
|
changes: list[tuple[str, list[str], Any]] = []
|
||||||
_collect_changes(diff, [], changes, previous)
|
_collect_changes(diff, [], changes, previous)
|
||||||
|
|
||||||
if not changes:
|
if not changes:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Create resolver for UUID replacement (uses previous state for lookups)
|
||||||
|
resolver = UuidResolver(db, previous)
|
||||||
|
|
||||||
# Format each change
|
# Format each change
|
||||||
lines = []
|
lines = []
|
||||||
for change_type, path, value in changes:
|
for change_type, path, value in changes:
|
||||||
lines.extend(_format_change_lines(change_type, path, value, use_color))
|
lines.extend(_format_change_lines(change_type, path, value, resolver))
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def format_action_header(action: str, user_display: str | None = None) -> str:
|
def format_action_header(action: str, user_display: str | None = None) -> str:
|
||||||
"""Format the action header line."""
|
"""Format the action header line."""
|
||||||
use_color = _use_color()
|
action_str = f"{_ACTION}{action}{_RESET}"
|
||||||
|
if user_display:
|
||||||
if use_color:
|
user_str = f"{_USER}{user_display}{_RESET}"
|
||||||
action_str = f"{_ACTION}{action}{_RESET}"
|
return f"{action_str} by {user_str}"
|
||||||
if user_display:
|
return action_str
|
||||||
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(
|
def log_change(
|
||||||
@@ -282,18 +425,23 @@ def log_change(
|
|||||||
diff: dict,
|
diff: dict,
|
||||||
user_display: str | None = None,
|
user_display: str | None = None,
|
||||||
previous: dict | None = None,
|
previous: dict | None = None,
|
||||||
|
db: "DB | None" = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Log a database change with pretty-printed diff.
|
Log a database change with pretty-printed diff.
|
||||||
|
|
||||||
|
UUIDs are replaced with display names for readability. For types without
|
||||||
|
display names, the full UUID string is used.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
action: The action name (e.g., "login", "admin:delete_user")
|
action: The action name (e.g., "login", "admin:delete_user")
|
||||||
diff: The JSON diff dict
|
diff: The JSON diff dict
|
||||||
user_display: Optional display name of the user who performed the action
|
user_display: Optional display name of the user who performed the action
|
||||||
previous: The previous state dict (for determining add vs update)
|
previous: The previous state dict (for determining add vs update)
|
||||||
|
db: Optional database for looking up display names
|
||||||
"""
|
"""
|
||||||
header = format_action_header(action, user_display)
|
header = format_action_header(action, user_display)
|
||||||
diff_lines = format_diff(diff, previous)
|
diff_lines = format_diff(diff, previous, db)
|
||||||
|
|
||||||
if not diff_lines:
|
if not diff_lines:
|
||||||
logger.info(header)
|
logger.info(header)
|
||||||
|
|||||||
+50
-3
@@ -5,29 +5,76 @@ Migrations are applied during database load based on the version field.
|
|||||||
Each migration should be idempotent and only run when needed.
|
Each migration should be idempotent and only run when needed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
|
||||||
def migrate_v1(d: dict) -> None:
|
from paskia.util.crypto import secret_key
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationCtx(msgspec.Struct):
|
||||||
|
"""Context passed to each migration function."""
|
||||||
|
|
||||||
|
rp_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v1(d: dict, ctx: MigrationCtx) -> None:
|
||||||
"""Remove Org.created_at fields."""
|
"""Remove Org.created_at fields."""
|
||||||
for org_data in d["orgs"].values():
|
for org_data in d["orgs"].values():
|
||||||
org_data.pop("created_at", None)
|
org_data.pop("created_at", None)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v2(d: dict, ctx: MigrationCtx) -> None:
|
||||||
|
"""Add config field if missing."""
|
||||||
|
if "config" not in d:
|
||||||
|
d["config"] = {"rp_id": ctx.rp_id}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v3(d: dict, ctx: MigrationCtx) -> None:
|
||||||
|
"""Ensure all users have visits field."""
|
||||||
|
for user_data in d["users"].values():
|
||||||
|
user_data.setdefault("visits", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
|
||||||
|
"""OpenID Connect support and hardened session keys."""
|
||||||
|
# Session keys changed to hashes, drop old sessions
|
||||||
|
d["sessions"] = {}
|
||||||
|
# Create OIDC structure with a generated new key
|
||||||
|
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
|
||||||
|
|
||||||
|
|
||||||
migrations = sorted(
|
migrations = sorted(
|
||||||
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
||||||
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
||||||
)
|
)
|
||||||
|
|
||||||
DBVER = len(migrations) # Used by bootstrap and migrate:sql to set initial version
|
DBVER = len(migrations) # Used by bootstrap to set initial version
|
||||||
|
|
||||||
|
|
||||||
|
def apply_migrations_readonly(
|
||||||
|
data_dict: dict,
|
||||||
|
current_version: int,
|
||||||
|
ctx: MigrationCtx,
|
||||||
|
) -> int:
|
||||||
|
"""Apply migration functions in-place without persistence.
|
||||||
|
|
||||||
|
Returns the new version after all migrations.
|
||||||
|
"""
|
||||||
|
while current_version < DBVER:
|
||||||
|
migrations[current_version](data_dict, ctx)
|
||||||
|
current_version += 1
|
||||||
|
return current_version
|
||||||
|
|
||||||
|
|
||||||
async def apply_all_migrations(
|
async def apply_all_migrations(
|
||||||
data_dict: dict,
|
data_dict: dict,
|
||||||
current_version: int,
|
current_version: int,
|
||||||
persist: Callable[[str, int, dict], Awaitable[None]],
|
persist: Callable[[str, int, dict], Awaitable[None]],
|
||||||
|
ctx: MigrationCtx,
|
||||||
) -> None:
|
) -> None:
|
||||||
while current_version < DBVER:
|
while current_version < DBVER:
|
||||||
migrations[current_version](data_dict)
|
migrations[current_version](data_dict, ctx)
|
||||||
current_version += 1
|
current_version += 1
|
||||||
await persist(f"migrate:v{current_version}", current_version, data_dict)
|
await persist(f"migrate:v{current_version}", current_version, data_dict)
|
||||||
|
|||||||
+287
-336
@@ -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.
|
Write operations: Functions that validate and commit, or raise ValueError.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
|
from paskia import oidc_notify
|
||||||
from paskia.config import SESSION_LIFETIME
|
from paskia.config import SESSION_LIFETIME
|
||||||
from paskia.db.jsonl import (
|
|
||||||
DB_PATH_DEFAULT,
|
|
||||||
JsonlStore,
|
|
||||||
)
|
|
||||||
from paskia.db.structs import (
|
from paskia.db.structs import (
|
||||||
DB,
|
DB,
|
||||||
|
Client,
|
||||||
|
Config,
|
||||||
Credential,
|
Credential,
|
||||||
Org,
|
Org,
|
||||||
Permission,
|
Permission,
|
||||||
@@ -31,94 +28,27 @@ from paskia.db.structs import (
|
|||||||
SessionContext,
|
SessionContext,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from paskia.util.passphrase import generate as generate_passphrase
|
from paskia.util.crypto import hash_secret
|
||||||
from paskia.util.passphrase import is_well_formed as _is_passphrase
|
from paskia.util.nameutil import slugify_name
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Sentinel for distinguishing "not provided" from None
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
# Global database instance (empty until init() loads data)
|
# Global database instance (empty until init() loads data)
|
||||||
_db = DB()
|
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
||||||
_store = JsonlStore(_db)
|
|
||||||
_db._store = _store
|
|
||||||
_initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
async def init(*args, **kwargs):
|
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||||
"""Load database from JSONL file."""
|
"""Check if a preferred_username is already taken by another user."""
|
||||||
global _db, _initialized
|
if not username:
|
||||||
if _initialized:
|
return False
|
||||||
_logger.debug("Database already initialized, skipping reload")
|
for user in _db.users.values():
|
||||||
return
|
if user.preferred_username == username:
|
||||||
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
|
if exclude_uuid is None or user.uuid != exclude_uuid:
|
||||||
if db_path.startswith("json:"):
|
return True
|
||||||
db_path = db_path[5:]
|
return False
|
||||||
await _store.load(db_path)
|
|
||||||
_db = _store.db
|
|
||||||
_initialized = True
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Read/lookup functions
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
|
|
||||||
"""Get the organization a user belongs to and their role name.
|
|
||||||
|
|
||||||
Raises ValueError if user not found.
|
|
||||||
|
|
||||||
Call sites:
|
|
||||||
- update_user_role_in_organization: org only
|
|
||||||
- admin_create_user_registration_link: org only
|
|
||||||
- admin_get_user_detail: org and role
|
|
||||||
- admin_update_user_display_name: org only
|
|
||||||
- admin_delete_user_credential: org only
|
|
||||||
- admin_delete_user_session: org only
|
|
||||||
"""
|
|
||||||
if user_uuid not in _db.users:
|
|
||||||
raise ValueError(f"User {user_uuid} not found")
|
|
||||||
user = _db.users[user_uuid]
|
|
||||||
role = user.role
|
|
||||||
return role.org, role.display_name
|
|
||||||
|
|
||||||
|
|
||||||
def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
|
|
||||||
"""Get all users in an organization with their role names.
|
|
||||||
|
|
||||||
Returns list of (User, role_display_name) tuples.
|
|
||||||
"""
|
|
||||||
org = _db.orgs[org_uuid]
|
|
||||||
return [(u, u.role.display_name) for role in org.roles for u in role.users]
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
|
|
||||||
"""Get credential IDs for a user (for WebAuthn exclude lists).
|
|
||||||
|
|
||||||
Returns empty list if user has no credentials.
|
|
||||||
"""
|
|
||||||
assert user_uuid
|
|
||||||
return [c.credential_id for c in _db.users[user_uuid].credentials]
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_key(passphrase: str) -> bytes:
|
|
||||||
"""Hash a passphrase to bytes for reset token storage."""
|
|
||||||
if not _is_passphrase(passphrase):
|
|
||||||
raise ValueError(
|
|
||||||
"Trying to reset with a session token in place of a passphrase"
|
|
||||||
if len(passphrase) == 16
|
|
||||||
else "Invalid passphrase format"
|
|
||||||
)
|
|
||||||
return hashlib.sha512(passphrase.encode()).digest()[:9]
|
|
||||||
|
|
||||||
|
|
||||||
def get_reset_token(passphrase: str) -> ResetToken | None:
|
|
||||||
"""Get reset token by passphrase.
|
|
||||||
|
|
||||||
Call sites:
|
|
||||||
- Get reset token to validate it (authsession.py:34)
|
|
||||||
"""
|
|
||||||
key = _reset_key(passphrase)
|
|
||||||
return _db.reset_tokens.get(key)
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -126,12 +56,18 @@ def get_reset_token(passphrase: str) -> ResetToken | None:
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def update_config(config: Config) -> None:
|
||||||
|
"""Update the stored configuration."""
|
||||||
|
with _db.transaction("update_config"):
|
||||||
|
_db.config = config
|
||||||
|
|
||||||
|
|
||||||
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
|
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
|
||||||
"""Create a new permission."""
|
"""Create a new permission."""
|
||||||
if perm.uuid in _db.permissions:
|
if perm.uuid in _db.permissions:
|
||||||
raise ValueError(f"Permission {perm.uuid} already exists")
|
raise ValueError(f"Permission {perm.uuid} already exists")
|
||||||
with _db.transaction("admin:create_permission", ctx):
|
with _db.transaction("admin:create_permission", ctx):
|
||||||
_db.permissions[perm.uuid] = perm
|
perm.store()
|
||||||
|
|
||||||
|
|
||||||
def update_permission(
|
def update_permission(
|
||||||
@@ -159,10 +95,7 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|||||||
if uuid not in _db.permissions:
|
if uuid not in _db.permissions:
|
||||||
raise ValueError(f"Permission {uuid} not found")
|
raise ValueError(f"Permission {uuid} not found")
|
||||||
with _db.transaction("admin:delete_permission", ctx):
|
with _db.transaction("admin:delete_permission", ctx):
|
||||||
# Remove this permission from all roles
|
_db.permissions[uuid].delete()
|
||||||
for role in _db.roles.values():
|
|
||||||
role.permissions.pop(uuid, None)
|
|
||||||
del _db.permissions[uuid]
|
|
||||||
|
|
||||||
|
|
||||||
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
||||||
@@ -172,13 +105,14 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
|||||||
"""
|
"""
|
||||||
if org.uuid in _db.orgs:
|
if org.uuid in _db.orgs:
|
||||||
raise ValueError(f"Organization {org.uuid} already exists")
|
raise ValueError(f"Organization {org.uuid} already exists")
|
||||||
|
now = datetime.now(UTC)
|
||||||
with _db.transaction("admin:create_org", ctx):
|
with _db.transaction("admin:create_org", ctx):
|
||||||
new_org = Org.create(display_name=org.display_name)
|
new_org = Org.create(display_name=org.display_name, created_at=now)
|
||||||
new_org.uuid = org.uuid
|
new_org.uuid = org.uuid
|
||||||
_db.orgs[org.uuid] = new_org
|
new_org.store()
|
||||||
# Create Administration role with org admin permission
|
# Create Administration role with org admin permission
|
||||||
|
|
||||||
admin_role_uuid = uuid7.create()
|
admin_role_uuid = uuid7.create(now)
|
||||||
# Find the auth:org:admin permission UUID
|
# Find the auth:org:admin permission UUID
|
||||||
org_admin_perm_uuid = None
|
org_admin_perm_uuid = None
|
||||||
for pid, p in _db.permissions.items():
|
for pid, p in _db.permissions.items():
|
||||||
@@ -192,7 +126,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
|||||||
permissions=role_permissions,
|
permissions=role_permissions,
|
||||||
)
|
)
|
||||||
admin_role.uuid = admin_role_uuid
|
admin_role.uuid = admin_role_uuid
|
||||||
_db.roles[admin_role_uuid] = admin_role
|
admin_role.store()
|
||||||
|
|
||||||
|
|
||||||
def update_org_name(
|
def update_org_name(
|
||||||
@@ -213,16 +147,7 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|||||||
if uuid not in _db.orgs:
|
if uuid not in _db.orgs:
|
||||||
raise ValueError(f"Organization {uuid} not found")
|
raise ValueError(f"Organization {uuid} not found")
|
||||||
with _db.transaction("admin:delete_org", ctx):
|
with _db.transaction("admin:delete_org", ctx):
|
||||||
org = _db.orgs[uuid]
|
_db.orgs[uuid].delete()
|
||||||
# Remove org from all permissions
|
|
||||||
for p in _db.permissions.values():
|
|
||||||
p.orgs.pop(uuid, None)
|
|
||||||
# Delete roles in this org and their users
|
|
||||||
for role in org.roles:
|
|
||||||
for user in role.users:
|
|
||||||
del _db.users[user.uuid]
|
|
||||||
del _db.roles[role.uuid]
|
|
||||||
del _db.orgs[uuid]
|
|
||||||
|
|
||||||
|
|
||||||
def add_permission_to_org(
|
def add_permission_to_org(
|
||||||
@@ -266,7 +191,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
|
|||||||
if role.org_uuid not in _db.orgs:
|
if role.org_uuid not in _db.orgs:
|
||||||
raise ValueError(f"Organization {role.org_uuid} not found")
|
raise ValueError(f"Organization {role.org_uuid} not found")
|
||||||
with _db.transaction("admin:create_role", ctx):
|
with _db.transaction("admin:create_role", ctx):
|
||||||
_db.roles[role.uuid] = role
|
role.store()
|
||||||
|
|
||||||
|
|
||||||
def update_role_name(
|
def update_role_name(
|
||||||
@@ -319,7 +244,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|||||||
if role.users:
|
if role.users:
|
||||||
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
|
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
|
||||||
with _db.transaction("admin:delete_role", ctx):
|
with _db.transaction("admin:delete_role", ctx):
|
||||||
del _db.roles[uuid]
|
_db.roles[uuid].delete()
|
||||||
|
|
||||||
|
|
||||||
def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
||||||
@@ -329,7 +254,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
|||||||
if new_user.role_uuid not in _db.roles:
|
if new_user.role_uuid not in _db.roles:
|
||||||
raise ValueError(f"Role {new_user.role_uuid} not found")
|
raise ValueError(f"Role {new_user.role_uuid} not found")
|
||||||
with _db.transaction("admin:create_user", ctx):
|
with _db.transaction("admin:create_user", ctx):
|
||||||
_db.users[new_user.uuid] = new_user
|
new_user.store()
|
||||||
|
|
||||||
|
|
||||||
def update_user_display_name(
|
def update_user_display_name(
|
||||||
@@ -343,13 +268,103 @@ def update_user_display_name(
|
|||||||
The acting user should be logged via ctx.
|
The acting user should be logged via ctx.
|
||||||
For self-service (user updating own name), pass user's ctx.
|
For self-service (user updating own name), pass user's ctx.
|
||||||
For admin operations, pass admin's ctx.
|
For admin operations, pass admin's ctx.
|
||||||
|
|
||||||
|
If the user's preferred_username is currently None, this will auto-fill it
|
||||||
|
with a slugified version of the display name (if unique and non-empty).
|
||||||
"""
|
"""
|
||||||
if isinstance(uuid, str):
|
if isinstance(uuid, str):
|
||||||
uuid = UUID(uuid)
|
uuid = UUID(uuid)
|
||||||
if uuid not in _db.users:
|
if uuid not in _db.users:
|
||||||
raise ValueError(f"User {uuid} not found")
|
raise ValueError(f"User {uuid} not found")
|
||||||
|
display_name = (display_name or "").strip()
|
||||||
|
if not display_name:
|
||||||
|
raise ValueError("Display name cannot be empty")
|
||||||
|
user = _db.users[uuid]
|
||||||
with _db.transaction("update_user_display_name", ctx):
|
with _db.transaction("update_user_display_name", ctx):
|
||||||
_db.users[uuid].display_name = display_name
|
user.display_name = display_name
|
||||||
|
# Auto-fill preferred_username if not already set
|
||||||
|
if user.preferred_username is None:
|
||||||
|
slug = slugify_name(display_name)
|
||||||
|
if slug and not is_username_taken(slug, exclude_uuid=uuid):
|
||||||
|
user.preferred_username = slug
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_info(
|
||||||
|
uuid: UUID,
|
||||||
|
*,
|
||||||
|
display_name: str | object = _UNSET,
|
||||||
|
theme: str | object = _UNSET,
|
||||||
|
email: str | None | object = _UNSET,
|
||||||
|
preferred_username: str | None | object = _UNSET,
|
||||||
|
telephone: str | None | object = _UNSET,
|
||||||
|
ctx: SessionContext | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update user profile information.
|
||||||
|
|
||||||
|
Pass only the fields you want to update. Use None to clear optional fields.
|
||||||
|
This does NOT auto-fill preferred_username - use update_user_display_name
|
||||||
|
for the registration flow where auto-fill is desired.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
uuid: User UUID
|
||||||
|
display_name: User display name (cannot be empty)
|
||||||
|
theme: Theme preference ('' for auto, 'light', 'dark')
|
||||||
|
email: Email address (None to clear)
|
||||||
|
preferred_username: Username for OIDC claims (None to clear, must be unique)
|
||||||
|
telephone: Phone number (None to clear)
|
||||||
|
ctx: Session context for audit logging
|
||||||
|
"""
|
||||||
|
if isinstance(uuid, str):
|
||||||
|
uuid = UUID(uuid)
|
||||||
|
if uuid not in _db.users:
|
||||||
|
raise ValueError(f"User {uuid} not found")
|
||||||
|
|
||||||
|
user = _db.users[uuid]
|
||||||
|
|
||||||
|
# Validate all fields before transaction
|
||||||
|
if display_name is not _UNSET:
|
||||||
|
display_name = (display_name or "").strip()
|
||||||
|
if not display_name:
|
||||||
|
raise ValueError("Display name cannot be empty")
|
||||||
|
|
||||||
|
if theme is not _UNSET:
|
||||||
|
if theme not in ("", "light", "dark"):
|
||||||
|
raise ValueError(f"Invalid theme: {theme}")
|
||||||
|
|
||||||
|
if email is not _UNSET and email is not None:
|
||||||
|
email = (email or "").strip()
|
||||||
|
if not email:
|
||||||
|
email = None
|
||||||
|
elif "@" not in email or len(email) > 254:
|
||||||
|
raise ValueError("Invalid email format")
|
||||||
|
|
||||||
|
if preferred_username is not _UNSET and preferred_username is not None:
|
||||||
|
preferred_username = (preferred_username or "").strip()
|
||||||
|
if not preferred_username:
|
||||||
|
raise ValueError("Preferred username cannot be empty (use None to clear)")
|
||||||
|
if len(preferred_username) > 128:
|
||||||
|
raise ValueError("preferred_username too long")
|
||||||
|
if is_username_taken(preferred_username, exclude_uuid=uuid):
|
||||||
|
raise ValueError("Username already taken")
|
||||||
|
|
||||||
|
if telephone is not _UNSET and telephone is not None:
|
||||||
|
telephone = (telephone or "").strip()
|
||||||
|
if not telephone:
|
||||||
|
telephone = None
|
||||||
|
elif len(telephone) > 32:
|
||||||
|
raise ValueError("telephone too long")
|
||||||
|
|
||||||
|
with _db.transaction("update_user_info", ctx):
|
||||||
|
if display_name is not _UNSET:
|
||||||
|
user.display_name = display_name
|
||||||
|
if theme is not _UNSET:
|
||||||
|
user.theme = theme
|
||||||
|
if email is not _UNSET:
|
||||||
|
user.email = email
|
||||||
|
if preferred_username is not _UNSET:
|
||||||
|
user.preferred_username = preferred_username
|
||||||
|
if telephone is not _UNSET:
|
||||||
|
user.telephone = telephone
|
||||||
|
|
||||||
|
|
||||||
def update_user_role(
|
def update_user_role(
|
||||||
@@ -367,45 +382,12 @@ def update_user_role(
|
|||||||
_db.users[uuid].role_uuid = role_uuid
|
_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:
|
def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||||
"""Delete user and their credentials/sessions."""
|
"""Delete user and their credentials/sessions."""
|
||||||
if uuid not in _db.users:
|
if uuid not in _db.users:
|
||||||
raise ValueError(f"User {uuid} not found")
|
raise ValueError(f"User {uuid} not found")
|
||||||
user = _db.users[uuid]
|
|
||||||
with _db.transaction("admin:delete_user", ctx):
|
with _db.transaction("admin:delete_user", ctx):
|
||||||
# Delete credentials
|
_db.users[uuid].delete()
|
||||||
for cred in user.credentials:
|
|
||||||
del _db.credentials[cred.uuid]
|
|
||||||
# Delete sessions
|
|
||||||
for sess in user.sessions:
|
|
||||||
del _db.sessions[sess.key]
|
|
||||||
# Delete reset tokens
|
|
||||||
for token in user.reset_tokens:
|
|
||||||
del _db.reset_tokens[token.key]
|
|
||||||
del _db.users[uuid]
|
|
||||||
|
|
||||||
|
|
||||||
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
|
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
|
||||||
@@ -415,7 +397,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
|
|||||||
if cred.user_uuid not in _db.users:
|
if cred.user_uuid not in _db.users:
|
||||||
raise ValueError(f"User {cred.user_uuid} not found")
|
raise ValueError(f"User {cred.user_uuid} not found")
|
||||||
with _db.transaction("create_credential", ctx):
|
with _db.transaction("create_credential", ctx):
|
||||||
_db.credentials[cred.uuid] = cred
|
cred.store()
|
||||||
|
|
||||||
|
|
||||||
def update_credential_sign_count(
|
def update_credential_sign_count(
|
||||||
@@ -451,49 +433,15 @@ def delete_credential(
|
|||||||
if cred.user_uuid != user_uuid:
|
if cred.user_uuid != user_uuid:
|
||||||
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
||||||
with _db.transaction("delete_credential", ctx):
|
with _db.transaction("delete_credential", ctx):
|
||||||
# Delete all sessions using this credential
|
cred.delete()
|
||||||
for sess in cred.sessions:
|
|
||||||
print(sess, repr(sess.key))
|
|
||||||
del _db.sessions[sess.key]
|
|
||||||
del _db.credentials[uuid]
|
|
||||||
|
|
||||||
|
|
||||||
def create_session(
|
|
||||||
user_uuid: UUID,
|
|
||||||
credential_uuid: UUID,
|
|
||||||
host: str,
|
|
||||||
ip: str,
|
|
||||||
user_agent: str,
|
|
||||||
expiry: datetime,
|
|
||||||
*,
|
|
||||||
ctx: SessionContext | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Create a new session. Returns the session key."""
|
|
||||||
if user_uuid not in _db.users:
|
|
||||||
raise ValueError(f"User {user_uuid} not found")
|
|
||||||
if credential_uuid not in _db.credentials:
|
|
||||||
raise ValueError(f"Credential {credential_uuid} not found")
|
|
||||||
session = Session.create(
|
|
||||||
user=user_uuid,
|
|
||||||
credential=credential_uuid,
|
|
||||||
host=host,
|
|
||||||
ip=ip,
|
|
||||||
user_agent=user_agent,
|
|
||||||
expiry=expiry,
|
|
||||||
)
|
|
||||||
if session.key in _db.sessions:
|
|
||||||
raise ValueError("Session already exists")
|
|
||||||
with _db.transaction("create_session", ctx):
|
|
||||||
_db.sessions[session.key] = session
|
|
||||||
return session.key
|
|
||||||
|
|
||||||
|
|
||||||
def update_session(
|
def update_session(
|
||||||
key: str,
|
key: bytes,
|
||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
ip: str | None = None,
|
ip: str | None = None,
|
||||||
user_agent: str | None = None,
|
user_agent: str | None = None,
|
||||||
expiry: datetime | None = None,
|
validated: datetime | None = None,
|
||||||
*,
|
*,
|
||||||
ctx: SessionContext | None = None,
|
ctx: SessionContext | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -508,11 +456,13 @@ def update_session(
|
|||||||
s.ip = ip
|
s.ip = ip
|
||||||
if user_agent is not None:
|
if user_agent is not None:
|
||||||
s.user_agent = user_agent
|
s.user_agent = user_agent
|
||||||
if expiry is not None:
|
if validated is not None:
|
||||||
s.expiry = expiry
|
s.validated = validated
|
||||||
|
|
||||||
|
|
||||||
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
|
def set_session_host(
|
||||||
|
key: bytes, host: str, *, ctx: SessionContext | None = None
|
||||||
|
) -> None:
|
||||||
"""Set the host for a session (first-time binding)."""
|
"""Set the host for a session (first-time binding)."""
|
||||||
update_session(key, host=host, ctx=ctx)
|
update_session(key, host=host, ctx=ctx)
|
||||||
|
|
||||||
@@ -528,8 +478,10 @@ def delete_session(
|
|||||||
"""
|
"""
|
||||||
if key not in _db.sessions:
|
if key not in _db.sessions:
|
||||||
raise ValueError("Session not found")
|
raise ValueError("Session not found")
|
||||||
|
|
||||||
|
oidc_notify.schedule_notifications([key])
|
||||||
with _db.transaction(action, ctx):
|
with _db.transaction(action, ctx):
|
||||||
del _db.sessions[key]
|
_db.sessions[key].delete()
|
||||||
|
|
||||||
|
|
||||||
def delete_sessions_for_user(
|
def delete_sessions_for_user(
|
||||||
@@ -544,37 +496,43 @@ def delete_sessions_for_user(
|
|||||||
user = _db.users.get(user_uuid)
|
user = _db.users.get(user_uuid)
|
||||||
if not user:
|
if not user:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
keys = [s.key for s in user.sessions]
|
||||||
|
oidc_notify.schedule_notifications(keys)
|
||||||
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
||||||
for sess in user.sessions:
|
for sess in user.sessions:
|
||||||
del _db.sessions[sess.key]
|
sess.delete()
|
||||||
|
|
||||||
|
|
||||||
def create_reset_token(
|
def create_reset_token(
|
||||||
passphrase: str,
|
|
||||||
user_uuid: UUID,
|
user_uuid: UUID,
|
||||||
expiry: datetime,
|
expiry: datetime,
|
||||||
token_type: str,
|
token_type: str,
|
||||||
*,
|
*,
|
||||||
ctx: SessionContext | None = None,
|
ctx: SessionContext | None = None,
|
||||||
user: str | None = None,
|
user: str | None = None,
|
||||||
) -> None:
|
) -> str:
|
||||||
"""Create a reset token from a passphrase.
|
"""Create a reset token and return the passphrase.
|
||||||
|
|
||||||
The acting user should be logged via ctx.
|
The acting user should be logged via ctx.
|
||||||
For self-service (user creating own recovery link), pass user's ctx.
|
For self-service (user creating own recovery link), pass user's ctx.
|
||||||
For admin operations, pass admin's ctx.
|
For admin operations, pass admin's ctx.
|
||||||
For system operations (bootstrap), pass neither to log no user.
|
For system operations (bootstrap), pass neither to log no user.
|
||||||
For API operations where ctx is not available but user is known, pass 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:
|
if user_uuid not in _db.users:
|
||||||
raise ValueError(f"User {user_uuid} not found")
|
raise ValueError(f"User {user_uuid} not found")
|
||||||
|
token, passphrase = ResetToken.create(
|
||||||
|
user=user_uuid, expiry=expiry, token_type=token_type
|
||||||
|
)
|
||||||
|
if token.key in _db.reset_tokens:
|
||||||
|
raise ValueError("Reset token already exists")
|
||||||
with _db.transaction("create_reset_token", ctx, user=user):
|
with _db.transaction("create_reset_token", ctx, user=user):
|
||||||
_db.reset_tokens[key] = ResetToken(
|
token.store()
|
||||||
user_uuid=user_uuid, expiry=expiry, token_type=token_type
|
return passphrase
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
|
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
|
||||||
@@ -582,28 +540,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
|
|||||||
if key not in _db.reset_tokens:
|
if key not in _db.reset_tokens:
|
||||||
raise ValueError("Reset token not found")
|
raise ValueError("Reset token not found")
|
||||||
with _db.transaction("delete_reset_token", ctx):
|
with _db.transaction("delete_reset_token", ctx):
|
||||||
del _db.reset_tokens[key]
|
_db.reset_tokens[key].delete()
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Cleanup (called by background task)
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired() -> int:
|
|
||||||
"""Remove expired sessions and reset tokens. Returns count removed."""
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
count = 0
|
|
||||||
with _db.transaction("expiry"):
|
|
||||||
expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now]
|
|
||||||
for k in expired_sessions:
|
|
||||||
del _db.sessions[k]
|
|
||||||
count += 1
|
|
||||||
expired_tokens = [k for k, t in _db.reset_tokens.items() if t.expiry < now]
|
|
||||||
for k in expired_tokens:
|
|
||||||
del _db.reset_tokens[k]
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -611,11 +548,6 @@ def cleanup_expired() -> int:
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _create_token() -> str:
|
|
||||||
"""Generate a 16-character URL-safe session token."""
|
|
||||||
return secrets.token_urlsafe(12)
|
|
||||||
|
|
||||||
|
|
||||||
def login(
|
def login(
|
||||||
user_uuid: UUID,
|
user_uuid: UUID,
|
||||||
credential_uuid: UUID,
|
credential_uuid: UUID,
|
||||||
@@ -623,7 +555,7 @@ def login(
|
|||||||
host: str,
|
host: str,
|
||||||
ip: str,
|
ip: str,
|
||||||
user_agent: str,
|
user_agent: str,
|
||||||
expiry: datetime,
|
duration: timedelta = SESSION_LIFETIME,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Update user/credential on login and create session in a single transaction.
|
"""Update user/credential on login and create session in a single transaction.
|
||||||
|
|
||||||
@@ -643,25 +575,51 @@ def login(
|
|||||||
if credential_uuid not in _db.credentials:
|
if credential_uuid not in _db.credentials:
|
||||||
raise ValueError(f"Credential {credential_uuid} not found")
|
raise ValueError(f"Credential {credential_uuid} not found")
|
||||||
|
|
||||||
|
# Generate token and derive key
|
||||||
|
token = secrets.token_urlsafe(12)
|
||||||
|
|
||||||
session = Session.create(
|
session = Session.create(
|
||||||
user=user_uuid,
|
user=user_uuid,
|
||||||
credential=credential_uuid,
|
credential=credential_uuid,
|
||||||
|
key=hash_secret("cookie", token),
|
||||||
host=host,
|
host=host,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
expiry=expiry,
|
validated=now,
|
||||||
)
|
)
|
||||||
user_str = str(user_uuid)
|
user_str = str(user_uuid)
|
||||||
with _db.transaction("login", user=user_str):
|
with _db.transaction("login", user=user_str):
|
||||||
# Update user
|
session.store(now)
|
||||||
_db.users[user_uuid].last_seen = now
|
# Update credential
|
||||||
_db.users[user_uuid].visits += 1
|
_db.credentials[credential_uuid].sign_count = sign_count
|
||||||
|
_db.credentials[credential_uuid].last_used = now
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def oidc_login(
|
||||||
|
session: Session,
|
||||||
|
credential_uuid: UUID,
|
||||||
|
sign_count: int,
|
||||||
|
) -> None:
|
||||||
|
"""Store an OIDC session and update credential in a single transaction.
|
||||||
|
|
||||||
|
The caller is responsible for generating the token, deriving the key,
|
||||||
|
and creating the Session object. This function only handles the
|
||||||
|
database transaction.
|
||||||
|
|
||||||
|
Updates:
|
||||||
|
- user.last_seen, user.visits
|
||||||
|
- credential.sign_count, credential.last_used
|
||||||
|
Stores:
|
||||||
|
- the provided session
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
user_str = str(session.user_uuid)
|
||||||
|
with _db.transaction("oidc_login", user=user_str):
|
||||||
|
session.store(now)
|
||||||
# Update credential
|
# Update credential
|
||||||
_db.credentials[credential_uuid].sign_count = sign_count
|
_db.credentials[credential_uuid].sign_count = sign_count
|
||||||
_db.credentials[credential_uuid].last_used = now
|
_db.credentials[credential_uuid].last_used = now
|
||||||
# Create session
|
|
||||||
_db.sessions[session.key] = session
|
|
||||||
return session.key
|
|
||||||
|
|
||||||
|
|
||||||
def create_credential_session(
|
def create_credential_session(
|
||||||
@@ -685,18 +643,22 @@ def create_credential_session(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
expiry = now + SESSION_LIFETIME
|
|
||||||
|
|
||||||
if user_uuid not in _db.users:
|
if user_uuid not in _db.users:
|
||||||
raise ValueError(f"User {user_uuid} not found")
|
raise ValueError(f"User {user_uuid} not found")
|
||||||
|
|
||||||
|
# Generate token and derive key
|
||||||
|
token = secrets.token_urlsafe(12)
|
||||||
|
key = hash_secret("cookie", token)
|
||||||
|
|
||||||
session = Session.create(
|
session = Session.create(
|
||||||
user=user_uuid,
|
user=user_uuid,
|
||||||
credential=credential.uuid,
|
credential=credential.uuid,
|
||||||
|
key=key,
|
||||||
host=host,
|
host=host,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
expiry=expiry,
|
validated=now,
|
||||||
)
|
)
|
||||||
user_str = str(user_uuid)
|
user_str = str(user_uuid)
|
||||||
with _db.transaction("create_credential_session", user=user_str):
|
with _db.transaction("create_credential_session", user=user_str):
|
||||||
@@ -704,126 +666,115 @@ def create_credential_session(
|
|||||||
if display_name:
|
if display_name:
|
||||||
_db.users[user_uuid].display_name = display_name
|
_db.users[user_uuid].display_name = display_name
|
||||||
|
|
||||||
# Create credential
|
# Align credential timestamps with transaction time
|
||||||
_db.credentials[credential.uuid] = credential
|
credential.created_at = now
|
||||||
|
credential.last_used = now
|
||||||
|
credential.last_verified = now
|
||||||
|
|
||||||
# Create session
|
# Create credential
|
||||||
_db.sessions[session.key] = session
|
credential.store()
|
||||||
|
|
||||||
|
# Store session and record visit
|
||||||
|
session.store(now)
|
||||||
|
|
||||||
# Delete reset token if provided
|
# Delete reset token if provided
|
||||||
if reset_key:
|
if reset_key:
|
||||||
if reset_key in _db.reset_tokens:
|
reset_token = _db.reset_tokens.get(reset_key)
|
||||||
del _db.reset_tokens[reset_key]
|
if reset_token:
|
||||||
return session.key
|
reset_token.delete()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Bootstrap (single transaction for initial system setup)
|
# OIDC Provider operations
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def bootstrap(
|
def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> None:
|
||||||
org_name: str = "Organization",
|
"""Create a new OIDC client."""
|
||||||
admin_name: str = "Admin",
|
if client.uuid in _db.oidc.clients:
|
||||||
reset_passphrase: str | None = None,
|
raise ValueError(f"OIDC client {client.uuid} already exists")
|
||||||
reset_expiry: datetime | None = None,
|
with _db.transaction("admin:create_oid_client", ctx):
|
||||||
) -> str:
|
_db.oidc.clients[client.uuid] = client
|
||||||
"""Bootstrap the entire system in a single transaction.
|
|
||||||
|
|
||||||
Creates:
|
|
||||||
- auth:admin permission (Master Admin)
|
|
||||||
- auth:org:admin permission (Org Admin)
|
|
||||||
- Organization with Administration role
|
|
||||||
- Admin user with Administration role
|
|
||||||
- Reset token for admin registration
|
|
||||||
|
|
||||||
This is the only way to create a new database file (besides migrate).
|
def update_oid_client(
|
||||||
All data is created atomically - if any step fails, nothing is written.
|
client_uuid: UUID,
|
||||||
|
name: str | None = None,
|
||||||
|
redirect_uris: list[str] | None = None,
|
||||||
|
secret_hash: bytes | None = None,
|
||||||
|
backchannel_logout_uri: str | None = _UNSET,
|
||||||
|
*,
|
||||||
|
ctx: SessionContext | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update an OIDC client's name, redirect URIs, and/or secret."""
|
||||||
|
if client_uuid not in _db.oidc.clients:
|
||||||
|
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||||
|
|
||||||
Args:
|
client = _db.oidc.clients[client_uuid]
|
||||||
org_name: Display name for the organization (default: "Organization")
|
changes = {}
|
||||||
admin_name: Display name for the admin user (default: "Admin")
|
|
||||||
reset_passphrase: Passphrase for the reset token (generated if not provided)
|
|
||||||
reset_expiry: Expiry datetime for the reset token (default: 14 days)
|
|
||||||
|
|
||||||
Returns:
|
if name is not None and name != client.name:
|
||||||
The reset passphrase for admin registration.
|
changes["name"] = name
|
||||||
"""
|
if redirect_uris is not None and redirect_uris != client.redirect_uris:
|
||||||
|
changes["redirect_uris"] = redirect_uris
|
||||||
|
if secret_hash is not None and secret_hash != client.client_secret_hash:
|
||||||
|
changes["client_secret_hash"] = secret_hash
|
||||||
|
if (
|
||||||
|
backchannel_logout_uri is not _UNSET
|
||||||
|
and backchannel_logout_uri != client.backchannel_logout_uri
|
||||||
|
):
|
||||||
|
changes["backchannel_logout_uri"] = backchannel_logout_uri
|
||||||
|
|
||||||
# Check if system is already bootstrapped
|
if not changes:
|
||||||
for p in _db.permissions.values():
|
return # No changes to make
|
||||||
if p.scope == "auth:admin":
|
|
||||||
raise ValueError(
|
|
||||||
"System already bootstrapped (auth:admin permission exists)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate UUIDs upfront
|
new_logout_uri = (
|
||||||
perm_admin_uuid = uuid7.create()
|
backchannel_logout_uri
|
||||||
perm_org_admin_uuid = uuid7.create()
|
if backchannel_logout_uri is not _UNSET
|
||||||
org_uuid = uuid7.create()
|
else client.backchannel_logout_uri
|
||||||
role_uuid = uuid7.create()
|
)
|
||||||
user_uuid = uuid7.create()
|
|
||||||
|
|
||||||
# Generate reset token components
|
with _db.transaction("admin:update_oid_client", ctx):
|
||||||
if reset_passphrase is None:
|
# Create updated client with new values
|
||||||
reset_passphrase = generate_passphrase()
|
updated_client = Client(
|
||||||
if reset_expiry is None:
|
client_secret_hash=secret_hash
|
||||||
from paskia.authsession import reset_expires # noqa: PLC0415
|
if secret_hash is not None
|
||||||
|
else client.client_secret_hash,
|
||||||
reset_expiry = reset_expires()
|
name=name if name is not None else client.name,
|
||||||
reset_key = _reset_key(reset_passphrase)
|
redirect_uris=redirect_uris
|
||||||
|
if redirect_uris is not None
|
||||||
now = datetime.now(UTC)
|
else client.redirect_uris,
|
||||||
|
backchannel_logout_uri=new_logout_uri,
|
||||||
with _db.transaction("bootstrap"):
|
|
||||||
# Create auth:admin permission
|
|
||||||
perm_admin = Permission(
|
|
||||||
scope="auth:admin",
|
|
||||||
display_name="Master Admin",
|
|
||||||
orgs={org_uuid: True}, # Grant to org
|
|
||||||
)
|
)
|
||||||
perm_admin.uuid = perm_admin_uuid
|
updated_client.uuid = client.uuid
|
||||||
_db.permissions[perm_admin_uuid] = perm_admin
|
_db.oidc.clients[client_uuid] = updated_client
|
||||||
|
|
||||||
# Create auth:org:admin permission
|
|
||||||
perm_org_admin = Permission(
|
def reset_oid_client_secret(
|
||||||
scope="auth:org:admin",
|
client_uuid: UUID,
|
||||||
display_name="Org Admin",
|
new_secret_hash: bytes,
|
||||||
orgs={org_uuid: True}, # Grant to org
|
*,
|
||||||
|
ctx: SessionContext | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Reset an OIDC client's secret."""
|
||||||
|
if client_uuid not in _db.oidc.clients:
|
||||||
|
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||||
|
client = _db.oidc.clients[client_uuid]
|
||||||
|
with _db.transaction("admin:reset_oid_client_secret", ctx):
|
||||||
|
updated = Client(
|
||||||
|
client_secret_hash=new_secret_hash,
|
||||||
|
name=client.name,
|
||||||
|
redirect_uris=client.redirect_uris,
|
||||||
|
backchannel_logout_uri=client.backchannel_logout_uri,
|
||||||
)
|
)
|
||||||
perm_org_admin.uuid = perm_org_admin_uuid
|
updated.uuid = client.uuid
|
||||||
_db.permissions[perm_org_admin_uuid] = perm_org_admin
|
_db.oidc.clients[client_uuid] = updated
|
||||||
|
|
||||||
# Create organization
|
|
||||||
new_org = Org.create(display_name=org_name)
|
|
||||||
new_org.uuid = org_uuid
|
|
||||||
_db.orgs[org_uuid] = new_org
|
|
||||||
|
|
||||||
# Create Administration role with both permissions
|
def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||||
admin_role = Role(
|
"""Delete an OIDC client."""
|
||||||
org_uuid=org_uuid,
|
if client_uuid not in _db.oidc.clients:
|
||||||
display_name="Administration",
|
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||||
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
with _db.transaction("admin:delete_oid_client", ctx):
|
||||||
)
|
del _db.oidc.clients[client_uuid]
|
||||||
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
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Snapshot handling for JSONL database persistence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LINEPREFIX = b"SNAPSHOT "
|
||||||
|
MINDIFFS = 100
|
||||||
|
|
||||||
|
|
||||||
|
class Snapshot(msgspec.Struct):
|
||||||
|
"""Snapshot data structure for database persistence."""
|
||||||
|
|
||||||
|
ts: datetime
|
||||||
|
v: int
|
||||||
|
state: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotState:
|
||||||
|
"""Tracks snapshot timing and line counts for a database file."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.ts: datetime | None = None
|
||||||
|
self.changes: int = 0
|
||||||
|
self._force_pending: bool = False
|
||||||
|
|
||||||
|
def request_force(self) -> None:
|
||||||
|
"""Request a forced snapshot on the next maybe_write call."""
|
||||||
|
self._force_pending = True
|
||||||
|
|
||||||
|
def record_lines(self, count: int) -> None:
|
||||||
|
self.changes += count
|
||||||
|
|
||||||
|
def maybe_write(self, file, version: int, state: dict) -> None:
|
||||||
|
"""Write a snapshot if conditions are met (enough changes, and Sunday UTC or forced)."""
|
||||||
|
if self.changes < MINDIFFS:
|
||||||
|
return
|
||||||
|
force = self._force_pending
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if not force and now.weekday() != 6: # 6 = Sunday
|
||||||
|
return
|
||||||
|
sunday_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
if not force and self.ts is not None and self.ts >= sunday_midnight:
|
||||||
|
return
|
||||||
|
if not file.is_open:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._write(file, version, state, now)
|
||||||
|
self._force_pending = False
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error("snapshot: failed to write snapshot: %r", exc)
|
||||||
|
|
||||||
|
def _write(self, file, version: int, state: dict, now: datetime) -> None:
|
||||||
|
"""Write a snapshot and update internal state."""
|
||||||
|
data = msgspec.json.encode(Snapshot(ts=now, v=version, state=state))
|
||||||
|
file.write(LINEPREFIX + data + b"\n")
|
||||||
|
self.changes = 0
|
||||||
|
self.ts = now
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load(data: bytes) -> tuple[Snapshot | None, int]:
|
||||||
|
"""Find and parse the last snapshot in file data.
|
||||||
|
|
||||||
|
Returns (snapshot, replay_offset) where replay_offset is the byte
|
||||||
|
position to start replaying change records from. If no valid snapshot
|
||||||
|
is found, returns (None, 0).
|
||||||
|
"""
|
||||||
|
marker = b"\n" + LINEPREFIX
|
||||||
|
pos = data.rfind(marker)
|
||||||
|
if pos != -1:
|
||||||
|
pos += 1 # skip the newline
|
||||||
|
elif data.startswith(LINEPREFIX):
|
||||||
|
pos = 0
|
||||||
|
else:
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
end = data.find(b"\n", pos)
|
||||||
|
if end == -1:
|
||||||
|
raise ValueError("Incomplete snapshot line at end of file")
|
||||||
|
|
||||||
|
snap = msgspec.json.decode(data[pos + len(LINEPREFIX) : end], type=Snapshot)
|
||||||
|
return snap, end + 1
|
||||||
+287
-30
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -8,7 +9,8 @@ import msgspec
|
|||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.util.hostutil import normalize_host
|
from paskia.util import passphrase as passphrase_util
|
||||||
|
from paskia.util.crypto import hash_secret
|
||||||
|
|
||||||
# Sentinel for uuid fields before they are set by create() or DB post init
|
# Sentinel for uuid fields before they are set by create() or DB post init
|
||||||
_UUID_UNSET = UUID(int=0)
|
_UUID_UNSET = UUID(int=0)
|
||||||
@@ -45,20 +47,36 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
if org_uuid in db.data().orgs
|
if org_uuid in db.data().orgs
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this permission in the database. Must be called inside a transaction."""
|
||||||
|
db.data().permissions[self.uuid] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this permission and remove it from all roles.
|
||||||
|
|
||||||
|
Must be called inside a transaction.
|
||||||
|
"""
|
||||||
|
_data = db.data()
|
||||||
|
for role in _data.roles.values():
|
||||||
|
role.permissions.pop(self.uuid, None)
|
||||||
|
del _data.permissions[self.uuid]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
scope: str,
|
scope: str,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
domain: str | None = None,
|
domain: str | None = None,
|
||||||
|
created_at: datetime | None = None,
|
||||||
) -> Permission:
|
) -> Permission:
|
||||||
"""Create a new Permission with auto-generated uuid7."""
|
"""Create a new Permission with auto-generated uuid7."""
|
||||||
|
now = created_at or datetime.now(UTC)
|
||||||
perm = cls(
|
perm = cls(
|
||||||
scope=scope,
|
scope=scope,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
)
|
)
|
||||||
perm.uuid = uuid7.create()
|
perm.uuid = uuid7.create(now)
|
||||||
return perm
|
return perm
|
||||||
|
|
||||||
|
|
||||||
@@ -81,11 +99,30 @@ class Org(msgspec.Struct, dict=True):
|
|||||||
"""Get all permissions that this organization can grant."""
|
"""Get all permissions that this organization can grant."""
|
||||||
return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
|
return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this organization in the database. Must be called inside a transaction."""
|
||||||
|
db.data().orgs[self.uuid] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this org and cascade to roles, users. Remove from permissions.
|
||||||
|
|
||||||
|
Must be called inside a transaction.
|
||||||
|
"""
|
||||||
|
_data = db.data()
|
||||||
|
for p in _data.permissions.values():
|
||||||
|
p.orgs.pop(self.uuid, None)
|
||||||
|
for role in self.roles:
|
||||||
|
for user in role.users:
|
||||||
|
del _data.users[user.uuid]
|
||||||
|
del _data.roles[role.uuid]
|
||||||
|
del _data.orgs[self.uuid]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, display_name: str) -> Org:
|
def create(cls, display_name: str, created_at: datetime | None = None) -> Org:
|
||||||
"""Create a new Org with auto-generated uuid7."""
|
"""Create a new Org with auto-generated uuid7."""
|
||||||
|
now = created_at or datetime.now(UTC)
|
||||||
org = cls(display_name=display_name)
|
org = cls(display_name=display_name)
|
||||||
org.uuid = uuid7.create()
|
org.uuid = uuid7.create(now)
|
||||||
return org
|
return org
|
||||||
|
|
||||||
|
|
||||||
@@ -129,28 +166,38 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
"""Get all users that have this role."""
|
"""Get all users that have this role."""
|
||||||
return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
|
return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this role in the database. Must be called inside a transaction."""
|
||||||
|
db.data().roles[self.uuid] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this role from the database. Must be called inside a transaction."""
|
||||||
|
del db.data().roles[self.uuid]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
org: UUID | Org,
|
org: UUID | Org,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
permissions: set[UUID] | None = None,
|
permissions: set[UUID] | None = None,
|
||||||
|
created_at: datetime | None = None,
|
||||||
) -> Role:
|
) -> Role:
|
||||||
"""Create a new Role with auto-generated uuid7."""
|
"""Create a new Role with auto-generated uuid7."""
|
||||||
|
now = created_at or datetime.now(UTC)
|
||||||
org_uuid = org if isinstance(org, UUID) else org.uuid
|
org_uuid = org if isinstance(org, UUID) else org.uuid
|
||||||
role = cls(
|
role = cls(
|
||||||
org_uuid=org_uuid,
|
org_uuid=org_uuid,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
permissions={p: True for p in (permissions or set())},
|
permissions={p: True for p in (permissions or set())},
|
||||||
)
|
)
|
||||||
role.uuid = uuid7.create()
|
role.uuid = uuid7.create(now)
|
||||||
return role
|
return role
|
||||||
|
|
||||||
|
|
||||||
class User(msgspec.Struct, dict=True):
|
class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
|
||||||
"""User data structure.
|
"""User data structure.
|
||||||
|
|
||||||
Mutable fields: display_name, role_uuid, last_seen, visits
|
Mutable fields: display_name, role_uuid, last_seen, visits, theme, email, preferred_username
|
||||||
Immutable fields: created_at (set at creation, never modified)
|
Immutable fields: created_at (set at creation, never modified)
|
||||||
uuid is derived from created_at using uuid7.
|
uuid is derived from created_at using uuid7.
|
||||||
"""
|
"""
|
||||||
@@ -158,8 +205,12 @@ class User(msgspec.Struct, dict=True):
|
|||||||
display_name: str
|
display_name: str
|
||||||
role_uuid: UUID = msgspec.field(name="role")
|
role_uuid: UUID = msgspec.field(name="role")
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
visits: int
|
||||||
last_seen: datetime | None = None
|
last_seen: datetime | None = None
|
||||||
visits: int = 0
|
theme: str = ""
|
||||||
|
email: str | None = None # OIDC email claim
|
||||||
|
preferred_username: str | None = None # OIDC preferred_username claim
|
||||||
|
telephone: str | None = None # Telephone number
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if not hasattr(self, "uuid"):
|
if not hasattr(self, "uuid"):
|
||||||
@@ -180,6 +231,11 @@ class User(msgspec.Struct, dict=True):
|
|||||||
"""Get all credentials for this user."""
|
"""Get all credentials for this user."""
|
||||||
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
|
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credential_ids(self) -> list[bytes]:
|
||||||
|
"""Get credential IDs for this user (for WebAuthn exclude lists)."""
|
||||||
|
return [c.credential_id for c in self.credentials]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sessions(self) -> list[Session]:
|
def sessions(self) -> list[Session]:
|
||||||
"""Get all sessions for this user."""
|
"""Get all sessions for this user."""
|
||||||
@@ -190,6 +246,24 @@ class User(msgspec.Struct, dict=True):
|
|||||||
"""Get all reset tokens for this user."""
|
"""Get all reset tokens for this user."""
|
||||||
return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
|
return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this user in the database. Must be called inside a transaction."""
|
||||||
|
db.data().users[self.uuid] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this user and cascade to credentials, sessions, reset tokens.
|
||||||
|
|
||||||
|
Must be called inside a transaction.
|
||||||
|
"""
|
||||||
|
_data = db.data()
|
||||||
|
for cred in self.credentials:
|
||||||
|
del _data.credentials[cred.uuid]
|
||||||
|
for sess in self.sessions:
|
||||||
|
del _data.sessions[sess.key]
|
||||||
|
for token in self.reset_tokens:
|
||||||
|
del _data.reset_tokens[token.key]
|
||||||
|
del _data.users[self.uuid]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
@@ -203,6 +277,9 @@ class User(msgspec.Struct, dict=True):
|
|||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
role_uuid=role_uuid,
|
role_uuid=role_uuid,
|
||||||
created_at=created_at or datetime.now(UTC),
|
created_at=created_at or datetime.now(UTC),
|
||||||
|
last_seen=None,
|
||||||
|
visits=0,
|
||||||
|
theme="",
|
||||||
)
|
)
|
||||||
user.uuid = uuid7.create(user.created_at)
|
user.uuid = uuid7.create(user.created_at)
|
||||||
return user
|
return user
|
||||||
@@ -241,6 +318,20 @@ class Credential(msgspec.Struct, dict=True):
|
|||||||
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
|
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this credential in the database. Must be called inside a transaction."""
|
||||||
|
db.data().credentials[self.uuid] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this credential and all its sessions.
|
||||||
|
|
||||||
|
Must be called inside a transaction.
|
||||||
|
"""
|
||||||
|
_data = db.data()
|
||||||
|
for sess in self.sessions:
|
||||||
|
del _data.sessions[sess.key]
|
||||||
|
del _data.credentials[self.uuid]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
@@ -268,12 +359,17 @@ class Credential(msgspec.Struct, dict=True):
|
|||||||
return cred
|
return cred
|
||||||
|
|
||||||
|
|
||||||
class Session(msgspec.Struct, dict=True):
|
class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||||
"""Session data structure.
|
"""Session data structure.
|
||||||
|
|
||||||
Mutable fields: expiry (updated on session refresh)
|
Mutable fields: validated (updated on session refresh)
|
||||||
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent
|
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
|
||||||
key is stored in the dict key, not in the struct.
|
key is the hashed db_key, stored in the dict key, not in the struct.
|
||||||
|
|
||||||
|
If client_uuid is set, this is an OIDC session.
|
||||||
|
|
||||||
|
Security: The database stores only derived keys, never the raw secret.
|
||||||
|
A database leak does not expose working session credentials.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user_uuid: UUID = msgspec.field(name="user")
|
user_uuid: UUID = msgspec.field(name="user")
|
||||||
@@ -281,7 +377,8 @@ class Session(msgspec.Struct, dict=True):
|
|||||||
host: str
|
host: str
|
||||||
ip: str
|
ip: str
|
||||||
user_agent: str
|
user_agent: str
|
||||||
expiry: datetime
|
validated: datetime
|
||||||
|
client_uuid: UUID | None = msgspec.field(name="client", default=None)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if not hasattr(self, "key"):
|
if not hasattr(self, "key"):
|
||||||
@@ -302,33 +399,60 @@ class Session(msgspec.Struct, dict=True):
|
|||||||
return {
|
return {
|
||||||
"ip": self.ip,
|
"ip": self.ip,
|
||||||
"user_agent": self.user_agent,
|
"user_agent": self.user_agent,
|
||||||
"expiry": self.expiry.isoformat(),
|
"validated": self.validated.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def store(self, last_seen: datetime) -> None:
|
||||||
|
"""Store this session in the database and record a visit.
|
||||||
|
|
||||||
|
Updates user.last_seen and user.visits. Must be called inside
|
||||||
|
a database transaction.
|
||||||
|
"""
|
||||||
|
_data = db.data()
|
||||||
|
_data.sessions[self.key] = self
|
||||||
|
_data.users[self.user_uuid].last_seen = last_seen
|
||||||
|
_data.users[self.user_uuid].visits += 1
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this session from the database. Must be called inside a transaction."""
|
||||||
|
del db.data().sessions[self.key]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
user: UUID | User,
|
user: UUID | User,
|
||||||
credential: UUID | Credential,
|
credential: UUID | Credential,
|
||||||
|
key: str,
|
||||||
host: str,
|
host: str,
|
||||||
ip: str,
|
ip: str,
|
||||||
user_agent: str,
|
user_agent: str,
|
||||||
expiry: datetime,
|
validated: datetime,
|
||||||
|
client: UUID | None = None,
|
||||||
) -> Session:
|
) -> Session:
|
||||||
"""Create a new Session with auto-generated key."""
|
"""Create a new Session with the provided key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The hashed session key (derived from secret via hash_secret)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session object with key set
|
||||||
|
"""
|
||||||
|
|
||||||
user_uuid = user if isinstance(user, UUID) else user.uuid
|
user_uuid = user if isinstance(user, UUID) else user.uuid
|
||||||
credential_uuid = (
|
credential_uuid = (
|
||||||
credential if isinstance(credential, UUID) else credential.uuid
|
credential if isinstance(credential, UUID) else credential.uuid
|
||||||
)
|
)
|
||||||
|
|
||||||
session = cls(
|
session = cls(
|
||||||
user_uuid=user_uuid,
|
user_uuid=user_uuid,
|
||||||
credential_uuid=credential_uuid,
|
credential_uuid=credential_uuid,
|
||||||
host=host,
|
host=host,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
expiry=expiry,
|
validated=validated,
|
||||||
|
client_uuid=client,
|
||||||
)
|
)
|
||||||
session.key = secrets.token_urlsafe(12)
|
session.key = key
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
@@ -345,13 +469,122 @@ class ResetToken(msgspec.Struct, dict=True):
|
|||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if not hasattr(self, "key"):
|
if not hasattr(self, "key"):
|
||||||
self.key: bytes = b""
|
self.key: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user(self) -> User:
|
def user(self) -> User:
|
||||||
"""Get the User object for this reset token."""
|
"""Get the User object for this reset token."""
|
||||||
return db.data().users[self.user_uuid]
|
return db.data().users[self.user_uuid]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this reset token in the database. Must be called inside a transaction."""
|
||||||
|
db.data().reset_tokens[self.key] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this reset token from the database. Must be called inside a transaction."""
|
||||||
|
del db.data().reset_tokens[self.key]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash(passphrase: str) -> str:
|
||||||
|
"""Hash a passphrase to string for reset token storage."""
|
||||||
|
if not passphrase_util.is_well_formed(passphrase):
|
||||||
|
raise ValueError(
|
||||||
|
"Trying to reset with a session token in place of a passphrase"
|
||||||
|
if len(passphrase) == 16
|
||||||
|
else "Invalid passphrase format"
|
||||||
|
)
|
||||||
|
return hash_secret("reset", passphrase)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
|
||||||
|
"""Get a reset token by passphrase."""
|
||||||
|
key = cls.hash(passphrase)
|
||||||
|
return db.data().reset_tokens.get(key)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
user: UUID | User,
|
||||||
|
expiry: datetime,
|
||||||
|
token_type: str,
|
||||||
|
passphrase: str | None = None,
|
||||||
|
) -> tuple[ResetToken, str]:
|
||||||
|
"""Create a new ResetToken with auto-generated or provided passphrase.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User UUID or User object
|
||||||
|
expiry: Token expiration datetime
|
||||||
|
token_type: Type of token (e.g., "device addition", "account recovery")
|
||||||
|
passphrase: Optional passphrase to use (auto-generated if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (token, passphrase) where passphrase is the human-readable
|
||||||
|
code to give to the user.
|
||||||
|
"""
|
||||||
|
if passphrase is None:
|
||||||
|
passphrase = passphrase_util.generate()
|
||||||
|
key = cls.hash(passphrase)
|
||||||
|
user_uuid = user if isinstance(user, UUID) else user.uuid
|
||||||
|
token = cls(
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
expiry=expiry,
|
||||||
|
token_type=token_type,
|
||||||
|
)
|
||||||
|
token.key = key
|
||||||
|
return token, passphrase
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# OIDC Provider structures
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Client(msgspec.Struct, dict=True, omit_defaults=True):
|
||||||
|
"""OIDC client (relying party) registration.
|
||||||
|
|
||||||
|
client_id is the dict key (UUID).
|
||||||
|
"""
|
||||||
|
|
||||||
|
client_secret_hash: bytes
|
||||||
|
name: str
|
||||||
|
redirect_uris: list[str]
|
||||||
|
backchannel_logout_uri: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not hasattr(self, "uuid"):
|
||||||
|
self.uuid: UUID = _UUID_UNSET
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
name: str,
|
||||||
|
redirect_uris: list[str],
|
||||||
|
client_secret: str,
|
||||||
|
created_at: datetime | None = None,
|
||||||
|
backchannel_logout_uri: str | None = None,
|
||||||
|
) -> tuple[Client, str]:
|
||||||
|
"""Create a new OIDClient with hashed secret.
|
||||||
|
|
||||||
|
Returns (client, client_secret) tuple.
|
||||||
|
"""
|
||||||
|
now = created_at or datetime.now(UTC)
|
||||||
|
secret_hash = hashlib.sha256(client_secret.encode()).digest()
|
||||||
|
client = cls(
|
||||||
|
client_secret_hash=secret_hash,
|
||||||
|
name=name,
|
||||||
|
redirect_uris=redirect_uris,
|
||||||
|
backchannel_logout_uri=backchannel_logout_uri,
|
||||||
|
)
|
||||||
|
client.uuid = uuid7.create(now)
|
||||||
|
return client, client_secret
|
||||||
|
|
||||||
|
def verify_secret(self, client_secret: str) -> bool:
|
||||||
|
"""Verify a client secret against stored hash."""
|
||||||
|
return secrets.compare_digest(
|
||||||
|
self.client_secret_hash,
|
||||||
|
hashlib.sha256(client_secret.encode()).digest(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SessionContext(msgspec.Struct):
|
class SessionContext(msgspec.Struct):
|
||||||
session: Session
|
session: Session
|
||||||
@@ -362,6 +595,21 @@ class SessionContext(msgspec.Struct):
|
|||||||
permissions: list[Permission] = []
|
permissions: list[Permission] = []
|
||||||
|
|
||||||
|
|
||||||
|
class OIDC(msgspec.Struct, dict=True):
|
||||||
|
clients: dict[UUID, Client] = {}
|
||||||
|
key: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Config(msgspec.Struct, omit_defaults=True):
|
||||||
|
"""Stored configuration for the instance."""
|
||||||
|
|
||||||
|
rp_id: str
|
||||||
|
rp_name: str | None = None
|
||||||
|
auth_host: str | None = None
|
||||||
|
origins: list[str] | None = None
|
||||||
|
listen: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Database storage structure
|
# Database storage structure
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -370,13 +618,16 @@ class SessionContext(msgspec.Struct):
|
|||||||
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||||
"""In-memory database. Access fields directly for reads."""
|
"""In-memory database. Access fields directly for reads."""
|
||||||
|
|
||||||
|
config: Config
|
||||||
permissions: dict[UUID, Permission] = {}
|
permissions: dict[UUID, Permission] = {}
|
||||||
orgs: dict[UUID, Org] = {}
|
orgs: dict[UUID, Org] = {}
|
||||||
roles: dict[UUID, Role] = {}
|
roles: dict[UUID, Role] = {}
|
||||||
users: dict[UUID, User] = {}
|
users: dict[UUID, User] = {}
|
||||||
credentials: dict[UUID, Credential] = {}
|
credentials: dict[UUID, Credential] = {}
|
||||||
sessions: dict[str, Session] = {}
|
sessions: dict[str, Session] = {}
|
||||||
reset_tokens: dict[bytes, ResetToken] = {}
|
reset_tokens: dict[str, ResetToken] = {}
|
||||||
|
# OIDC provider data
|
||||||
|
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
# Store reference for persistence (not serialized)
|
# Store reference for persistence (not serialized)
|
||||||
@@ -396,30 +647,40 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
session.key = key
|
session.key = key
|
||||||
for key, token in self.reset_tokens.items():
|
for key, token in self.reset_tokens.items():
|
||||||
token.key = key
|
token.key = key
|
||||||
|
# OIDC
|
||||||
|
for uuid, client in self.oidc.clients.items():
|
||||||
|
client.uuid = uuid
|
||||||
|
|
||||||
def transaction(self, action, ctx=None, *, user=None):
|
def transaction(self, action, ctx=None, *, user=None):
|
||||||
"""Wrap writes in transaction. Delegates to JsonlStore."""
|
"""Wrap writes in transaction. Delegates to JsonlStore."""
|
||||||
return self._store.transaction(action, ctx, user=user)
|
return self._store.transaction(action, ctx, user=user)
|
||||||
|
|
||||||
def session_ctx(
|
def session_ctx(
|
||||||
self, session_key: str, host: str | None = None
|
self, session_secret: str, host: str | None = None
|
||||||
) -> SessionContext | None:
|
) -> SessionContext | None:
|
||||||
"""Get full session context with effective permissions.
|
"""Get full session context with effective permissions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session_key: The session key string
|
session_secret: The session secret (cookie value) - will be hashed for lookup
|
||||||
host: Optional host for binding/validation and domain-scoped permissions
|
host: Optional host for binding/validation and domain-scoped permissions
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
SessionContext if valid, None if session not found, expired, or host mismatch
|
SessionContext if valid, None if session not found, expired, or host mismatch
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
key = hash_secret("cookie", session_secret)
|
||||||
try:
|
try:
|
||||||
s = self.sessions[session_key]
|
s = self.sessions[key]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# 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)
|
# 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
|
# Session bound to different host
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -434,10 +695,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
# Effective permissions: role's permissions that the org can grant
|
# Effective permissions: role's permissions that the org can grant
|
||||||
# Also filter by domain if host is provided
|
# Also filter by domain if host is provided
|
||||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
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 = []
|
effective_perms = []
|
||||||
for perm_uuid in role.permission_set:
|
for perm_uuid in role.permission_set:
|
||||||
@@ -447,8 +704,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
p = self.permissions[perm_uuid]
|
p = self.permissions[perm_uuid]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
continue
|
continue
|
||||||
# Check domain restriction
|
# Check domain restriction (normalized_input already has port stripped)
|
||||||
if p.domain is not None and p.domain != host_without_port:
|
if p.domain is not None and p.domain != normalized_input:
|
||||||
continue
|
continue
|
||||||
effective_perms.append(p)
|
effective_perms.append(p)
|
||||||
|
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
|
||||||
from uvicorn import Config, Server
|
|
||||||
from uvicorn import run as uvicorn_run
|
|
||||||
|
|
||||||
from paskia import globals as _globals
|
|
||||||
from paskia.bootstrap import bootstrap_if_needed
|
|
||||||
from paskia.config import PaskiaConfig
|
|
||||||
from paskia.db.background import flush
|
|
||||||
from paskia.fastapi import reset as reset_cmd
|
|
||||||
from paskia.util import startupbox
|
|
||||||
from paskia.util.hostutil import normalize_origin
|
|
||||||
|
|
||||||
DEFAULT_PORT = 4401
|
|
||||||
|
|
||||||
EPILOG = """\
|
|
||||||
Examples:
|
|
||||||
paskia # localhost:4401
|
|
||||||
paskia :8080 # All interfaces, port 8080
|
|
||||||
paskia unix:/tmp/paskia.sock
|
|
||||||
paskia reset [user] # Generate passkey reset link
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def is_subdomain(sub: str, domain: str) -> bool:
|
|
||||||
"""Check if sub is a subdomain of domain (or equal)."""
|
|
||||||
sub_parts = sub.lower().split(".")
|
|
||||||
domain_parts = domain.lower().split(".")
|
|
||||||
if len(sub_parts) < len(domain_parts):
|
|
||||||
return False
|
|
||||||
return sub_parts[-len(domain_parts) :] == domain_parts
|
|
||||||
|
|
||||||
|
|
||||||
def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
|
||||||
"""Validate that auth_host is a subdomain of rp_id."""
|
|
||||||
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
|
||||||
host = parsed.hostname or parsed.path
|
|
||||||
if not host:
|
|
||||||
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
|
|
||||||
if not is_subdomain(host, rp_id):
|
|
||||||
raise SystemExit(
|
|
||||||
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def add_common_options(p: argparse.ArgumentParser) -> None:
|
|
||||||
p.add_argument(
|
|
||||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
|
||||||
)
|
|
||||||
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
|
||||||
p.add_argument(
|
|
||||||
"--origin",
|
|
||||||
action="append",
|
|
||||||
dest="origins",
|
|
||||||
metavar="URL",
|
|
||||||
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
|
||||||
"--auth-host",
|
|
||||||
help=(
|
|
||||||
"Dedicated host (optionally with scheme/port) to serve the auth UI at the root,"
|
|
||||||
" e.g. auth.example.com or https://auth.example.com"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# Configure logging to remove the "ERROR:root:" prefix
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="paskia",
|
|
||||||
description="Paskia authentication server",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog=EPILOG,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Primary argument: either host:port or "reset" subcommand
|
|
||||||
parser.add_argument(
|
|
||||||
"hostport",
|
|
||||||
nargs="?",
|
|
||||||
help=(
|
|
||||||
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
|
|
||||||
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"reset_query",
|
|
||||||
nargs="?",
|
|
||||||
help="For 'reset' command: user UUID or substring of display name",
|
|
||||||
)
|
|
||||||
add_common_options(parser)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Detect "reset" subcommand (first positional is "reset")
|
|
||||||
is_reset = args.hostport == "reset"
|
|
||||||
|
|
||||||
if is_reset:
|
|
||||||
endpoints = []
|
|
||||||
else:
|
|
||||||
# Parse endpoint using fastapi_vue.hostutil
|
|
||||||
endpoints = parse_endpoint(args.hostport, DEFAULT_PORT)
|
|
||||||
|
|
||||||
# Extract host/port/uds from first endpoint for config display and site_url
|
|
||||||
ep = endpoints[0] if endpoints else {}
|
|
||||||
host = ep.get("host")
|
|
||||||
port = ep.get("port")
|
|
||||||
uds = ep.get("uds")
|
|
||||||
|
|
||||||
# Collect and normalize origins, handle auth_host
|
|
||||||
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
|
||||||
if args.auth_host:
|
|
||||||
# Normalize auth_host with scheme
|
|
||||||
if "://" not in args.auth_host:
|
|
||||||
args.auth_host = f"https://{args.auth_host}"
|
|
||||||
|
|
||||||
validate_auth_host(args.auth_host, args.rp_id)
|
|
||||||
|
|
||||||
# If origins are configured, ensure auth_host is included at top
|
|
||||||
if origins:
|
|
||||||
# Insert auth_host at the beginning
|
|
||||||
origins.insert(0, args.auth_host)
|
|
||||||
|
|
||||||
# Remove duplicates while preserving order
|
|
||||||
seen = set()
|
|
||||||
origins = [x for x in origins if not (x in seen or seen.add(x))]
|
|
||||||
|
|
||||||
# Compute site_url and site_path for reset links
|
|
||||||
# Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port
|
|
||||||
explicit_site_url = os.environ.get("PASKIA_SITE_URL")
|
|
||||||
if explicit_site_url:
|
|
||||||
# Explicit site URL from devserver or deployment config
|
|
||||||
site_url = explicit_site_url.rstrip("/")
|
|
||||||
site_path = "/" if args.auth_host else "/auth/"
|
|
||||||
elif args.auth_host:
|
|
||||||
site_url = args.auth_host.rstrip("/")
|
|
||||||
site_path = "/"
|
|
||||||
elif origins:
|
|
||||||
# Find localhost origin if rp_id is localhost, else use first origin
|
|
||||||
localhost_origin = (
|
|
||||||
next((o for o in origins if "://localhost" in o), None)
|
|
||||||
if args.rp_id == "localhost"
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
site_url = (localhost_origin or origins[0]).rstrip("/")
|
|
||||||
site_path = "/auth/"
|
|
||||||
elif args.rp_id == "localhost" and port:
|
|
||||||
# Dev mode: use http with port
|
|
||||||
site_url = f"http://localhost:{port}"
|
|
||||||
site_path = "/auth/"
|
|
||||||
else:
|
|
||||||
site_url = f"https://{args.rp_id}"
|
|
||||||
site_path = "/auth/"
|
|
||||||
|
|
||||||
# Build runtime configuration
|
|
||||||
config = PaskiaConfig(
|
|
||||||
rp_id=args.rp_id,
|
|
||||||
rp_name=args.rp_name or None,
|
|
||||||
origins=origins or None,
|
|
||||||
auth_host=args.auth_host or None,
|
|
||||||
site_url=site_url,
|
|
||||||
site_path=site_path,
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
uds=uds,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Export configuration via single JSON env variable for worker processes
|
|
||||||
config_json = {
|
|
||||||
"rp_id": config.rp_id,
|
|
||||||
"rp_name": config.rp_name,
|
|
||||||
"origins": config.origins,
|
|
||||||
"auth_host": config.auth_host,
|
|
||||||
"site_url": config.site_url,
|
|
||||||
"site_path": config.site_path,
|
|
||||||
}
|
|
||||||
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
|
||||||
|
|
||||||
startupbox.print_startup_config(config)
|
|
||||||
|
|
||||||
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
|
|
||||||
|
|
||||||
run_kwargs: dict = {
|
|
||||||
"log_level": "warning", # Suppress startup messages; we use custom logging
|
|
||||||
"access_log": False, # We use custom AccessLogMiddleware instead
|
|
||||||
}
|
|
||||||
|
|
||||||
if devmode:
|
|
||||||
# Security: dev mode must run on localhost:4402 to prevent
|
|
||||||
# accidental public exposure of the Vite dev server
|
|
||||||
if host != "localhost" or port != 4402:
|
|
||||||
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
|
||||||
run_kwargs["reload"] = True
|
|
||||||
run_kwargs["reload_dirs"] = ["paskia"]
|
|
||||||
|
|
||||||
async def async_main():
|
|
||||||
await _globals.init(
|
|
||||||
rp_id=config.rp_id,
|
|
||||||
rp_name=config.rp_name,
|
|
||||||
origins=config.origins,
|
|
||||||
bootstrap=False,
|
|
||||||
)
|
|
||||||
await bootstrap_if_needed()
|
|
||||||
await flush()
|
|
||||||
|
|
||||||
if is_reset:
|
|
||||||
exit_code = reset_cmd.run(args.reset_query)
|
|
||||||
raise SystemExit(exit_code)
|
|
||||||
|
|
||||||
if len(endpoints) > 1:
|
|
||||||
async with asyncio.TaskGroup() as tg:
|
|
||||||
for ep in endpoints:
|
|
||||||
tg.create_task(
|
|
||||||
Server(
|
|
||||||
Config(app="paskia.fastapi:app", **run_kwargs, **ep)
|
|
||||||
).serve()
|
|
||||||
)
|
|
||||||
elif devmode:
|
|
||||||
# Use uvicorn.run for proper reload support (it handles subprocess spawning)
|
|
||||||
ep = endpoints[0]
|
|
||||||
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
|
|
||||||
else:
|
|
||||||
server = Server(
|
|
||||||
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
|
|
||||||
)
|
|
||||||
await server.serve()
|
|
||||||
|
|
||||||
try:
|
|
||||||
asyncio.run(async_main())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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"}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from paskia.fastapi.admin.adminapp import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
from paskia import db
|
||||||
|
from paskia.fastapi import authz
|
||||||
|
from paskia.fastapi.admin import (
|
||||||
|
oidc_clients,
|
||||||
|
orgs,
|
||||||
|
permissions,
|
||||||
|
roles,
|
||||||
|
server_config,
|
||||||
|
users,
|
||||||
|
)
|
||||||
|
from paskia.fastapi.admin.errors import install_error_handlers
|
||||||
|
from paskia.fastapi.front import frontend
|
||||||
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.util import (
|
||||||
|
permutil,
|
||||||
|
vitedev,
|
||||||
|
)
|
||||||
|
from paskia.util.apistructs import (
|
||||||
|
ApiAdminInfo,
|
||||||
|
ApiOidcClient,
|
||||||
|
ApiOrg,
|
||||||
|
ApiOrgResponse,
|
||||||
|
ApiPermission,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
install_error_handlers(app)
|
||||||
|
app.mount("/oidc-clients", oidc_clients.app)
|
||||||
|
app.mount("/orgs", orgs.app)
|
||||||
|
app.mount("/roles", roles.app)
|
||||||
|
app.mount("/users", users.app)
|
||||||
|
app.mount("/permissions", permissions.app)
|
||||||
|
app.mount("/server-config", server_config.app)
|
||||||
|
|
||||||
|
|
||||||
|
def master_admin(ctx) -> bool:
|
||||||
|
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def org_admin(ctx, org_uuid: UUID) -> bool:
|
||||||
|
return ctx.org.uuid == org_uuid and any(
|
||||||
|
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
||||||
|
return master_admin(ctx) or org_admin(ctx, org_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
||||||
|
return await vitedev.handle(request, frontend, "/auth/admin/")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/info")
|
||||||
|
async def admin_info(request: Request, auth=AUTH_COOKIE):
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Orgs
|
||||||
|
orgs = list(db.data().orgs.values())
|
||||||
|
if not master_admin(ctx):
|
||||||
|
# Org admins can only see their own organization
|
||||||
|
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
||||||
|
|
||||||
|
def org_to_dict(o):
|
||||||
|
roles = o.roles
|
||||||
|
return ApiOrgResponse(
|
||||||
|
org=ApiOrg.from_db(o),
|
||||||
|
permissions={p.uuid: p for p in o.permissions},
|
||||||
|
roles={r.uuid: r for r in roles},
|
||||||
|
users={u.uuid: u for r in roles for u in r.users},
|
||||||
|
)
|
||||||
|
|
||||||
|
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
|
||||||
|
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}
|
||||||
|
|
||||||
|
# OIDC Clients (master admin only)
|
||||||
|
oidc_clients_dict = {}
|
||||||
|
if master_admin(ctx):
|
||||||
|
clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid)
|
||||||
|
sessions = db.data().sessions
|
||||||
|
# Count active sessions per client
|
||||||
|
client_session_counts = {}
|
||||||
|
for session in sessions.values():
|
||||||
|
if session.client_uuid:
|
||||||
|
client_session_counts[session.client_uuid] = (
|
||||||
|
client_session_counts.get(session.client_uuid, 0) + 1
|
||||||
|
)
|
||||||
|
oidc_clients_dict = {
|
||||||
|
client.uuid: ApiOidcClient.from_db(
|
||||||
|
client, client_session_counts.get(client.uuid, 0)
|
||||||
|
)
|
||||||
|
for client in clients
|
||||||
|
}
|
||||||
|
|
||||||
|
return MsgspecResponse(
|
||||||
|
ApiAdminInfo(
|
||||||
|
orgs=orgs_dict,
|
||||||
|
permissions=perms_dict,
|
||||||
|
oidc_clients=oidc_clients_dict,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Shared exception handlers for admin sub-apps."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from paskia.fastapi import authz
|
||||||
|
|
||||||
|
|
||||||
|
def install_error_handlers(app: FastAPI) -> None:
|
||||||
|
"""Register standard exception handlers on *app*."""
|
||||||
|
|
||||||
|
@app.exception_handler(ValueError)
|
||||||
|
async def value_error_handler(_request, exc: ValueError):
|
||||||
|
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
@app.exception_handler(authz.AuthException)
|
||||||
|
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=await authz.auth_error_content(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def general_exception_handler(_request, exc: Exception): # pragma: no cover
|
||||||
|
logging.exception("Unhandled exception in admin app")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500, content={"detail": "Internal server error"}
|
||||||
|
)
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Body, FastAPI, HTTPException, Request
|
||||||
|
|
||||||
|
from paskia import db
|
||||||
|
from paskia.db.operations import _UNSET
|
||||||
|
from paskia.db.structs import Client
|
||||||
|
from paskia.fastapi import authz
|
||||||
|
from paskia.fastapi.admin.errors import install_error_handlers
|
||||||
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.util import permutil
|
||||||
|
|
||||||
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
install_error_handlers(app)
|
||||||
|
|
||||||
|
|
||||||
|
def master_admin(ctx) -> bool:
|
||||||
|
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/")
|
||||||
|
async def admin_create_oidc_client(
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Create a new OIDC client (master admin only)."""
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
if not master_admin(ctx):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Only master admin can manage OIDC clients",
|
||||||
|
mode="forbidden",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Client ID and secret hash are generated client-side
|
||||||
|
client_id = payload.get("client_id", "").strip()
|
||||||
|
secret_hash_hex = payload.get("secret_hash", "").strip()
|
||||||
|
name = payload.get("name", "").strip()
|
||||||
|
redirect_uris = payload.get("redirect_uris", [])
|
||||||
|
backchannel_logout_uri = payload.get("backchannel_logout_uri")
|
||||||
|
if isinstance(backchannel_logout_uri, str):
|
||||||
|
backchannel_logout_uri = backchannel_logout_uri.strip() or None
|
||||||
|
|
||||||
|
if not client_id or not secret_hash_hex:
|
||||||
|
raise ValueError("client_id and secret_hash are required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client_uuid = UUID(client_id)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
raise ValueError("client_id must be a valid UUID")
|
||||||
|
|
||||||
|
try:
|
||||||
|
secret_hash = bytes.fromhex(secret_hash_hex)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
|
||||||
|
if len(secret_hash) != 32:
|
||||||
|
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
|
||||||
|
|
||||||
|
if not isinstance(redirect_uris, list):
|
||||||
|
raise ValueError("redirect_uris must be a list")
|
||||||
|
|
||||||
|
# Validate redirect URIs
|
||||||
|
for uri in redirect_uris:
|
||||||
|
if not isinstance(uri, str) or not uri.startswith("http"):
|
||||||
|
raise ValueError(f"Invalid redirect URI: {uri}")
|
||||||
|
|
||||||
|
if backchannel_logout_uri and not backchannel_logout_uri.startswith("http"):
|
||||||
|
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
|
||||||
|
|
||||||
|
client = Client(
|
||||||
|
client_secret_hash=secret_hash,
|
||||||
|
name=name,
|
||||||
|
redirect_uris=redirect_uris,
|
||||||
|
backchannel_logout_uri=backchannel_logout_uri,
|
||||||
|
)
|
||||||
|
client.uuid = client_uuid
|
||||||
|
|
||||||
|
db.create_oid_client(client, ctx=ctx)
|
||||||
|
|
||||||
|
return {"status": "ok", "client_id": str(client.uuid)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/{client_uuid}")
|
||||||
|
async def admin_update_oidc_client(
|
||||||
|
client_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Update an OIDC client's name and redirect URIs (master admin only)."""
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
if not master_admin(ctx):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Only master admin can manage OIDC clients",
|
||||||
|
mode="forbidden",
|
||||||
|
)
|
||||||
|
|
||||||
|
name = payload.get("name", "").strip() if "name" in payload else None
|
||||||
|
redirect_uris = payload.get("redirect_uris") if "redirect_uris" in payload else None
|
||||||
|
secret_hash_hex = (
|
||||||
|
payload.get("secret_hash", "").strip() if "secret_hash" in payload else None
|
||||||
|
)
|
||||||
|
backchannel_logout_uri = (
|
||||||
|
payload.get("backchannel_logout_uri")
|
||||||
|
if "backchannel_logout_uri" in payload
|
||||||
|
else _UNSET
|
||||||
|
)
|
||||||
|
if isinstance(backchannel_logout_uri, str):
|
||||||
|
backchannel_logout_uri = backchannel_logout_uri.strip() or None
|
||||||
|
|
||||||
|
if name is not None and not name:
|
||||||
|
raise ValueError("Client name cannot be empty")
|
||||||
|
|
||||||
|
if redirect_uris is not None:
|
||||||
|
if not isinstance(redirect_uris, list):
|
||||||
|
raise ValueError("redirect_uris must be a list")
|
||||||
|
# Validate redirect URIs
|
||||||
|
for uri in redirect_uris:
|
||||||
|
if not isinstance(uri, str) or not uri.startswith("http"):
|
||||||
|
raise ValueError(f"Invalid redirect URI: {uri}")
|
||||||
|
|
||||||
|
if (
|
||||||
|
backchannel_logout_uri is not _UNSET
|
||||||
|
and backchannel_logout_uri
|
||||||
|
and not backchannel_logout_uri.startswith("http")
|
||||||
|
):
|
||||||
|
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
|
||||||
|
|
||||||
|
secret_hash = None
|
||||||
|
if secret_hash_hex:
|
||||||
|
try:
|
||||||
|
secret_hash = bytes.fromhex(secret_hash_hex)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
|
||||||
|
if len(secret_hash) != 32:
|
||||||
|
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.update_oid_client(
|
||||||
|
client_uuid,
|
||||||
|
name=name,
|
||||||
|
redirect_uris=redirect_uris,
|
||||||
|
secret_hash=secret_hash,
|
||||||
|
backchannel_logout_uri=backchannel_logout_uri,
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/{client_uuid}/reset-secret")
|
||||||
|
async def admin_reset_oidc_client_secret(
|
||||||
|
client_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Reset an OIDC client's secret (master admin only).
|
||||||
|
|
||||||
|
The new secret is generated client-side; only the SHA-256 hash is sent.
|
||||||
|
"""
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
if not master_admin(ctx):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Only master admin can manage OIDC clients",
|
||||||
|
mode="forbidden",
|
||||||
|
)
|
||||||
|
|
||||||
|
secret_hash_hex = payload.get("secret_hash", "").strip()
|
||||||
|
if not secret_hash_hex:
|
||||||
|
raise ValueError("secret_hash is required")
|
||||||
|
try:
|
||||||
|
secret_hash = bytes.fromhex(secret_hash_hex)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
|
||||||
|
if len(secret_hash) != 32:
|
||||||
|
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.reset_oid_client_secret(client_uuid, secret_hash, ctx=ctx)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{client_uuid}")
|
||||||
|
async def admin_delete_oidc_client(
|
||||||
|
client_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Delete an OIDC client (master admin only)."""
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
if not master_admin(ctx):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Only master admin can manage OIDC clients",
|
||||||
|
mode="forbidden",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete_oid_client(client_uuid, ctx=ctx)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Body, FastAPI, HTTPException, Query, Request
|
||||||
|
|
||||||
|
from paskia import db
|
||||||
|
from paskia.db import Org as OrgDC
|
||||||
|
from paskia.db import Role as RoleDC
|
||||||
|
from paskia.db import User as UserDC
|
||||||
|
from paskia.fastapi import authz
|
||||||
|
from paskia.fastapi.admin.errors import install_error_handlers
|
||||||
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.util import permutil
|
||||||
|
from paskia.util.apistructs import ApiUuidResponse
|
||||||
|
|
||||||
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
install_error_handlers(app)
|
||||||
|
|
||||||
|
|
||||||
|
def master_admin(ctx) -> bool:
|
||||||
|
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def org_admin(ctx, org_uuid: UUID) -> bool:
|
||||||
|
return ctx.org.uuid == org_uuid and any(
|
||||||
|
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
||||||
|
return master_admin(ctx) or org_admin(ctx, org_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/")
|
||||||
|
async def admin_create_org(
|
||||||
|
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
|
||||||
|
):
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name = payload.get("display_name") or "New Organization"
|
||||||
|
permissions = payload.get("permissions") or []
|
||||||
|
org = OrgDC.create(display_name=display_name)
|
||||||
|
db.create_org(org, ctx=ctx)
|
||||||
|
# Grant requested permissions to the new org
|
||||||
|
for perm in permissions:
|
||||||
|
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
|
||||||
|
|
||||||
|
return MsgspecResponse(ApiUuidResponse(uuid=str(org.uuid)))
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/{org_uuid}")
|
||||||
|
async def admin_update_org_name(
|
||||||
|
org_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Update organization display name only."""
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, org_uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
display_name = payload.get("display_name")
|
||||||
|
if not display_name:
|
||||||
|
raise ValueError("display_name is required")
|
||||||
|
|
||||||
|
db.update_org_name(org_uuid, display_name, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{org_uuid}")
|
||||||
|
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, org_uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
if ctx.org.uuid == org_uuid:
|
||||||
|
raise ValueError("Cannot delete the organization you belong to")
|
||||||
|
|
||||||
|
# Delete organization-specific permissions
|
||||||
|
org_perm_pattern = f"org:{str(org_uuid).lower()}"
|
||||||
|
all_permissions = list(db.data().permissions.values())
|
||||||
|
for perm in all_permissions:
|
||||||
|
perm_scope_lower = perm.scope.lower()
|
||||||
|
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
|
||||||
|
if (
|
||||||
|
f":{org_perm_pattern}:" in perm_scope_lower
|
||||||
|
or perm_scope_lower.startswith(f"{org_perm_pattern}:")
|
||||||
|
or perm_scope_lower.endswith(f":{org_perm_pattern}")
|
||||||
|
or perm_scope_lower == org_perm_pattern
|
||||||
|
):
|
||||||
|
db.delete_permission(perm.uuid, ctx=ctx)
|
||||||
|
|
||||||
|
db.delete_org(org_uuid, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/{org_uuid}/permission")
|
||||||
|
async def admin_add_org_permission(
|
||||||
|
org_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
permission_uuid: UUID = Query(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{org_uuid}/permission")
|
||||||
|
async def admin_remove_org_permission(
|
||||||
|
org_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
permission_uuid: UUID = Query(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||||
|
)
|
||||||
|
|
||||||
|
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
|
||||||
|
|
||||||
|
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
|
||||||
|
perm = db.data().permissions.get(permission_uuid)
|
||||||
|
if perm and perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
|
||||||
|
# Check if any other org grants auth:admin that we're a member of
|
||||||
|
# (we only know our current org, so this effectively means we can't remove it from our own org)
|
||||||
|
raise ValueError(
|
||||||
|
"Cannot remove auth:admin from your own organization. "
|
||||||
|
"This would lock you out of admin access."
|
||||||
|
)
|
||||||
|
|
||||||
|
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/{org_uuid}/roles")
|
||||||
|
async def admin_create_role(
|
||||||
|
org_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, org_uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name = payload.get("display_name") or "New Role"
|
||||||
|
perms = payload.get("permissions") or []
|
||||||
|
if org_uuid not in db.data().orgs:
|
||||||
|
raise HTTPException(status_code=404, detail="Organization not found")
|
||||||
|
org = db.data().orgs[org_uuid]
|
||||||
|
grantable = {p.uuid for p in org.permissions}
|
||||||
|
|
||||||
|
# Normalize permission IDs to UUIDs
|
||||||
|
permission_uuids: set[UUID] = set()
|
||||||
|
for pid in perms:
|
||||||
|
perm = db.data().permissions.get(UUID(pid))
|
||||||
|
if not perm:
|
||||||
|
raise ValueError(f"Permission {pid} not found")
|
||||||
|
if perm.uuid not in grantable:
|
||||||
|
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||||
|
permission_uuids.add(perm.uuid)
|
||||||
|
|
||||||
|
role = RoleDC.create(
|
||||||
|
org=org_uuid,
|
||||||
|
display_name=display_name,
|
||||||
|
permissions=permission_uuids,
|
||||||
|
)
|
||||||
|
db.create_role(role, ctx=ctx)
|
||||||
|
return MsgspecResponse(ApiUuidResponse(uuid=str(role.uuid)))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/{org_uuid}/users")
|
||||||
|
async def admin_create_user(
|
||||||
|
org_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, org_uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
display_name = payload.get("display_name")
|
||||||
|
role_name = payload.get("role")
|
||||||
|
if not display_name or not role_name:
|
||||||
|
raise ValueError("display_name and role are required")
|
||||||
|
|
||||||
|
org = db.data().orgs[org_uuid]
|
||||||
|
role_obj = next(
|
||||||
|
(r for r in org.roles if r.display_name == role_name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not role_obj:
|
||||||
|
raise ValueError("Role not found in organization")
|
||||||
|
user = UserDC.create(
|
||||||
|
display_name=display_name,
|
||||||
|
role=role_obj.uuid,
|
||||||
|
)
|
||||||
|
db.create_user(user, ctx=ctx)
|
||||||
|
return MsgspecResponse(ApiUuidResponse(uuid=str(user.uuid)))
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Body, FastAPI, Query, Request
|
||||||
|
|
||||||
|
from paskia import db
|
||||||
|
from paskia.db import Permission as PermDC
|
||||||
|
from paskia.fastapi import authz
|
||||||
|
from paskia.fastapi.admin.errors import install_error_handlers
|
||||||
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.globals import passkey
|
||||||
|
from paskia.util import hostutil, permutil, querysafe
|
||||||
|
|
||||||
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
install_error_handlers(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_permission_domain(domain: str | None) -> None:
|
||||||
|
"""Validate that domain is rp_id, a subdomain of it, or an OIDC client UUID."""
|
||||||
|
if domain is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Allow OIDC client UUIDs (used for groups claim)
|
||||||
|
try:
|
||||||
|
client_uuid = UUID(domain)
|
||||||
|
if client_uuid in db.data().oidc.clients:
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
rp_id = passkey.instance.rp_id
|
||||||
|
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
||||||
|
return
|
||||||
|
raise ValueError(
|
||||||
|
f"Domain '{domain}' must be '{rp_id}', its subdomain, or an OIDC client UUID"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_admin_lockout(
|
||||||
|
perm_uuid: str, new_domain: str | None, current_host: str | None
|
||||||
|
) -> None:
|
||||||
|
"""Check if setting domain on auth:admin would lock out the admin.
|
||||||
|
|
||||||
|
Raises ValueError if this change would result in no auth:admin permissions
|
||||||
|
being accessible from the current host.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_host = hostutil.normalize_host(current_host)
|
||||||
|
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||||
|
|
||||||
|
# Get all auth:admin permissions
|
||||||
|
all_perms = list(db.data().permissions.values())
|
||||||
|
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
|
||||||
|
|
||||||
|
# Check if at least one auth:admin would remain accessible
|
||||||
|
for p in admin_perms:
|
||||||
|
# If this is the permission being modified, use the new domain
|
||||||
|
domain = new_domain if str(p.uuid) == perm_uuid else p.domain
|
||||||
|
|
||||||
|
# No domain restriction = accessible from anywhere
|
||||||
|
if domain is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if domain matches current host
|
||||||
|
if domain == normalized_host or domain == host_without_port:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if domain is a subdomain of current host or vice versa
|
||||||
|
if normalized_host and normalized_host.endswith(f".{domain}"):
|
||||||
|
return
|
||||||
|
if host_without_port and host_without_port.endswith(f".{domain}"):
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"Setting domain '{new_domain}' on auth:admin permission would lock you out of "
|
||||||
|
f"admin access from current host '{current_host}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> None:
|
||||||
|
"""Check if deleting an auth:admin permission would lock out the admin.
|
||||||
|
|
||||||
|
Raises ValueError if this deletion would result in no auth:admin permissions
|
||||||
|
being accessible from the current host.
|
||||||
|
"""
|
||||||
|
normalized_host = hostutil.normalize_host(current_host)
|
||||||
|
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||||
|
|
||||||
|
# Get all auth:admin permissions except the one being deleted
|
||||||
|
all_perms = list(db.data().permissions.values())
|
||||||
|
admin_perms = [
|
||||||
|
p for p in all_perms if p.scope == "auth:admin" and str(p.uuid) != perm_uuid
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check if at least one auth:admin would remain accessible
|
||||||
|
for p in admin_perms:
|
||||||
|
domain = p.domain
|
||||||
|
|
||||||
|
# No domain restriction = accessible from anywhere
|
||||||
|
if domain is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if domain matches current host
|
||||||
|
if domain == normalized_host or domain == host_without_port:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if domain is a subdomain of current host or vice versa
|
||||||
|
if normalized_host and normalized_host.endswith(f".{domain}"):
|
||||||
|
return
|
||||||
|
if host_without_port and host_without_port.endswith(f".{domain}"):
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"Deleting this auth:admin permission would lock you out of "
|
||||||
|
f"admin access from current host '{current_host}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/")
|
||||||
|
async def admin_create_permission(
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
|
||||||
|
scope = payload.get("scope") or payload.get(
|
||||||
|
"id"
|
||||||
|
) # Support both for backwards compat
|
||||||
|
display_name = payload.get("display_name")
|
||||||
|
domain = payload.get("domain") or None # Treat empty string as None
|
||||||
|
if not scope or not display_name:
|
||||||
|
raise ValueError("scope and display_name are required")
|
||||||
|
querysafe.assert_safe(scope, field="scope")
|
||||||
|
_validate_permission_domain(domain)
|
||||||
|
db.create_permission(
|
||||||
|
PermDC.create(scope=scope, display_name=display_name, domain=domain),
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/{permission_uuid}")
|
||||||
|
async def admin_update_permission(
|
||||||
|
permission_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
display_name: str | None = Query(None),
|
||||||
|
scope: str | None = Query(None),
|
||||||
|
domain: str | None = Query(None),
|
||||||
|
):
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get existing permission
|
||||||
|
perm = db.data().permissions.get(permission_uuid)
|
||||||
|
|
||||||
|
# Update fields that were provided
|
||||||
|
new_scope = scope if scope is not None else perm.scope
|
||||||
|
new_display_name = display_name if display_name is not None else perm.display_name
|
||||||
|
domain_value = domain if domain else None
|
||||||
|
|
||||||
|
# Sanity check: prevent changing the auth:admin permission scope
|
||||||
|
if perm.scope == "auth:admin" and new_scope != "auth:admin":
|
||||||
|
raise ValueError("Cannot rename the master admin permission")
|
||||||
|
|
||||||
|
if not new_display_name:
|
||||||
|
raise ValueError("display_name is required")
|
||||||
|
querysafe.assert_safe(new_scope, field="scope")
|
||||||
|
_validate_permission_domain(domain_value)
|
||||||
|
|
||||||
|
# Safety check: prevent admin lockout when setting domain on auth:admin
|
||||||
|
if perm.scope == "auth:admin" or new_scope == "auth:admin":
|
||||||
|
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
|
||||||
|
|
||||||
|
db.update_permission(
|
||||||
|
uuid=perm.uuid,
|
||||||
|
scope=new_scope,
|
||||||
|
display_name=new_display_name,
|
||||||
|
domain=domain_value,
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{permission_uuid}")
|
||||||
|
async def admin_delete_permission(
|
||||||
|
permission_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the permission to check its scope
|
||||||
|
perm = db.data().permissions.get(permission_uuid)
|
||||||
|
|
||||||
|
# Sanity check: prevent deleting critical permissions if it would lock out admin
|
||||||
|
if perm.scope == "auth:admin":
|
||||||
|
_check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
|
||||||
|
|
||||||
|
db.delete_permission(permission_uuid, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Body, FastAPI, HTTPException, Request
|
||||||
|
|
||||||
|
from paskia import db
|
||||||
|
from paskia.fastapi import authz
|
||||||
|
from paskia.fastapi.admin.errors import install_error_handlers
|
||||||
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.util import permutil
|
||||||
|
|
||||||
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
install_error_handlers(app)
|
||||||
|
|
||||||
|
|
||||||
|
def master_admin(ctx) -> bool:
|
||||||
|
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def org_admin(ctx, org_uuid: UUID) -> bool:
|
||||||
|
return ctx.org.uuid == org_uuid and any(
|
||||||
|
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
||||||
|
return master_admin(ctx) or org_admin(ctx, org_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/{role_uuid}")
|
||||||
|
async def admin_update_role_name(
|
||||||
|
role_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Update role display name only."""
|
||||||
|
role = db.data().roles.get(role_uuid)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="Role not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, role.org_uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name = payload.get("display_name")
|
||||||
|
if not display_name:
|
||||||
|
raise ValueError("display_name is required")
|
||||||
|
|
||||||
|
db.update_role_name(role_uuid, display_name, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/{role_uuid}/permissions/{permission_uuid}")
|
||||||
|
async def admin_add_role_permission(
|
||||||
|
role_uuid: UUID,
|
||||||
|
permission_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Add a permission to a role (intent-based API)."""
|
||||||
|
role = db.data().roles.get(role_uuid)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="Role not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, role.org_uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify permission exists and org can grant it
|
||||||
|
perm = db.data().permissions.get(permission_uuid)
|
||||||
|
if not perm:
|
||||||
|
raise HTTPException(status_code=404, detail="Permission not found")
|
||||||
|
if role.org_uuid not in perm.orgs:
|
||||||
|
raise ValueError("Permission not grantable by organization")
|
||||||
|
|
||||||
|
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{role_uuid}/permissions/{permission_uuid}")
|
||||||
|
async def admin_remove_role_permission(
|
||||||
|
role_uuid: UUID,
|
||||||
|
permission_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Remove a permission from a role (intent-based API)."""
|
||||||
|
role = db.data().roles.get(role_uuid)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="Role not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, role.org_uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sanity check: prevent admin from removing their own access
|
||||||
|
perm = db.data().permissions.get(permission_uuid)
|
||||||
|
if ctx.org.uuid == role.org_uuid and ctx.role.uuid == role_uuid:
|
||||||
|
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||||
|
# Check if removing this permission would leave no admin access
|
||||||
|
remaining_perms = role.permission_set - {permission_uuid}
|
||||||
|
has_admin = False
|
||||||
|
for rp_uuid in remaining_perms:
|
||||||
|
rp = db.data().permissions.get(rp_uuid)
|
||||||
|
if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
|
||||||
|
has_admin = True
|
||||||
|
break
|
||||||
|
if not has_admin:
|
||||||
|
raise ValueError("Cannot remove your own admin permissions")
|
||||||
|
|
||||||
|
db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{role_uuid}")
|
||||||
|
async def admin_delete_role(
|
||||||
|
role_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
role = db.data().roles.get(role_uuid)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="Role not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, role.org_uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sanity check: prevent admin from deleting their own role
|
||||||
|
if ctx.role.uuid == role_uuid:
|
||||||
|
raise ValueError("Cannot delete your own role")
|
||||||
|
|
||||||
|
db.delete_role(role_uuid, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from fastapi import Body, FastAPI, HTTPException, Request
|
||||||
|
|
||||||
|
from paskia import db
|
||||||
|
from paskia.db.structs import Config
|
||||||
|
from paskia.fastapi import authz
|
||||||
|
from paskia.fastapi.admin.errors import install_error_handlers
|
||||||
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.globals import passkey
|
||||||
|
from paskia.sansio import Passkey
|
||||||
|
from paskia.util import hostutil
|
||||||
|
from paskia.util.runtime import update_runtime_config
|
||||||
|
|
||||||
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
install_error_handlers(app)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def admin_get_server_config(
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Get current server configuration (master admin only)."""
|
||||||
|
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||||
|
pk = passkey.instance
|
||||||
|
config = db.data().config
|
||||||
|
return {
|
||||||
|
"rp_name": pk.rp_name,
|
||||||
|
"auth_host": config.auth_host or "",
|
||||||
|
"origins": list(pk.allowed_origins) if pk.allowed_origins else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/")
|
||||||
|
async def admin_update_server_config(
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Update server configuration (master admin only).
|
||||||
|
|
||||||
|
Updates rp_name, auth_host, and origins in both the runtime Passkey
|
||||||
|
instance and the persisted database config.
|
||||||
|
"""
|
||||||
|
await authz.verify(
|
||||||
|
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||||
|
)
|
||||||
|
config = db.data().config
|
||||||
|
pk = passkey.instance
|
||||||
|
|
||||||
|
rp_name = payload.get("rp_name", "").strip() or None
|
||||||
|
auth_host = payload.get("auth_host", "").strip() or None
|
||||||
|
raw_origins = payload.get("origins", [])
|
||||||
|
origins = [
|
||||||
|
hostutil.normalize_origin(o.strip()) for o in raw_origins if o.strip()
|
||||||
|
] or None
|
||||||
|
|
||||||
|
# Normalize auth_host and origins (matching CLI startup behavior)
|
||||||
|
if auth_host:
|
||||||
|
try:
|
||||||
|
hostutil.validate_auth_host(auth_host, config.rp_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
auth_host, origins = hostutil.normalize_auth_host_and_origins(auth_host, origins)
|
||||||
|
|
||||||
|
# Validate origins against the current rp_id
|
||||||
|
if origins:
|
||||||
|
for o in origins:
|
||||||
|
Passkey(rp_id=config.rp_id, origins=[o]) # validates or raises
|
||||||
|
|
||||||
|
# Update runtime Passkey instance
|
||||||
|
pk.rp_name = rp_name or config.rp_id
|
||||||
|
pk.allowed_origins = set(origins) if origins else None
|
||||||
|
|
||||||
|
# Persist to database
|
||||||
|
new_config = Config(
|
||||||
|
rp_id=config.rp_id,
|
||||||
|
rp_name=rp_name,
|
||||||
|
auth_host=auth_host,
|
||||||
|
origins=origins,
|
||||||
|
listen=config.listen,
|
||||||
|
)
|
||||||
|
db.update_config(new_config)
|
||||||
|
update_runtime_config(new_config)
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Body, FastAPI, HTTPException, Request
|
||||||
|
|
||||||
|
from paskia import aaguid as aaguid_mod
|
||||||
|
from paskia import db
|
||||||
|
from paskia.authsession import reset_expires
|
||||||
|
from paskia.fastapi import authz
|
||||||
|
from paskia.fastapi.admin.errors import install_error_handlers
|
||||||
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.util import hostutil, permutil
|
||||||
|
from paskia.util.apistructs import (
|
||||||
|
ApiAaguidInfo,
|
||||||
|
ApiCreateLinkResponse,
|
||||||
|
ApiOrg,
|
||||||
|
ApiRole,
|
||||||
|
ApiUser,
|
||||||
|
ApiUserDetail,
|
||||||
|
ApiUserSession,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
install_error_handlers(app)
|
||||||
|
|
||||||
|
|
||||||
|
def master_admin(ctx) -> bool:
|
||||||
|
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def org_admin(ctx, org_uuid: UUID) -> bool:
|
||||||
|
return ctx.org.uuid == org_uuid and any(
|
||||||
|
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
||||||
|
return master_admin(ctx) or org_admin(ctx, org_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/{user_uuid}/role")
|
||||||
|
async def admin_update_user_role(
|
||||||
|
user_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user = db.data().users[user_uuid]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
role_uuid_str = payload.get("role_uuid")
|
||||||
|
if not role_uuid_str:
|
||||||
|
raise ValueError("role_uuid is required")
|
||||||
|
try:
|
||||||
|
new_role_uuid = UUID(role_uuid_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise ValueError("Invalid role UUID")
|
||||||
|
new_role = db.data().roles.get(new_role_uuid)
|
||||||
|
if not new_role or new_role.org_uuid != user.org.uuid:
|
||||||
|
raise ValueError("Role not found in organization")
|
||||||
|
|
||||||
|
# Sanity check: prevent admin from removing their own access
|
||||||
|
if ctx.user.uuid == user_uuid:
|
||||||
|
# Check if any permission in the new role is an admin permission
|
||||||
|
has_admin_access = False
|
||||||
|
for perm_uuid in new_role.permissions:
|
||||||
|
perm = db.data().permissions.get(perm_uuid)
|
||||||
|
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||||
|
has_admin_access = True
|
||||||
|
break
|
||||||
|
if not has_admin_access:
|
||||||
|
raise ValueError(
|
||||||
|
"Cannot change your own role to one without admin permissions"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.update_user_role(user_uuid, new_role_uuid, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/{user_uuid}/create-link")
|
||||||
|
async def admin_create_user_registration_link(
|
||||||
|
user_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user = db.data().users[user_uuid]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user has existing credentials
|
||||||
|
has_credentials = db.data().users[user_uuid].credential_ids
|
||||||
|
token_type = "user registration" if not has_credentials else "account recovery"
|
||||||
|
|
||||||
|
expiry = reset_expires()
|
||||||
|
token = db.create_reset_token(
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
expiry=expiry,
|
||||||
|
token_type=token_type,
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
url = hostutil.reset_link_url(token)
|
||||||
|
return MsgspecResponse(
|
||||||
|
ApiCreateLinkResponse(
|
||||||
|
url=url,
|
||||||
|
expires=expiry,
|
||||||
|
token_type=token_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/{user_uuid}")
|
||||||
|
async def admin_get_user_detail(
|
||||||
|
user_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user = db.data().users[user_uuid]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
normalized_host = hostutil.normalize_host(request.headers.get("host"))
|
||||||
|
|
||||||
|
sessions = {
|
||||||
|
s.key: ApiUserSession.from_db(
|
||||||
|
s,
|
||||||
|
current_key=ctx.session.key,
|
||||||
|
normalized_host=normalized_host,
|
||||||
|
)
|
||||||
|
for s in user.sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
return MsgspecResponse(
|
||||||
|
ApiUserDetail(
|
||||||
|
user=ApiUser.from_db(user),
|
||||||
|
credentials={c.uuid: c for c in user.credentials},
|
||||||
|
aaguid_info={
|
||||||
|
k: ApiAaguidInfo(**v)
|
||||||
|
for k, v in aaguid_mod.filter(
|
||||||
|
c.aaguid for c in user.credentials
|
||||||
|
).items()
|
||||||
|
},
|
||||||
|
sessions=sessions,
|
||||||
|
org=ApiOrg.from_db(user.org),
|
||||||
|
role=ApiRole.from_db(user.role),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/{user_uuid}/info")
|
||||||
|
async def admin_update_user_info(
|
||||||
|
user_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Update user profile info (display_name, email, preferred_username, telephone).
|
||||||
|
|
||||||
|
Pass only the fields you want to update. Use null to clear optional fields.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = db.data().users[user_uuid]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
if "display_name" in payload:
|
||||||
|
name = (payload["display_name"] or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="display_name cannot be empty")
|
||||||
|
if len(name) > 64:
|
||||||
|
raise HTTPException(status_code=400, detail="display_name too long")
|
||||||
|
kwargs["display_name"] = name
|
||||||
|
if "email" in payload:
|
||||||
|
kwargs["email"] = payload["email"]
|
||||||
|
if "preferred_username" in payload:
|
||||||
|
kwargs["preferred_username"] = payload["preferred_username"]
|
||||||
|
if "telephone" in payload:
|
||||||
|
kwargs["telephone"] = payload["telephone"]
|
||||||
|
|
||||||
|
if not kwargs:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
db.update_user_info(user_uuid, **kwargs, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{user_uuid}")
|
||||||
|
async def admin_delete_user(
|
||||||
|
user_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Delete a user and all their credentials/sessions."""
|
||||||
|
try:
|
||||||
|
user = db.data().users[user_uuid]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
# Prevent admin from deleting themselves
|
||||||
|
if ctx.user.uuid == user_uuid:
|
||||||
|
raise ValueError("Cannot delete your own account")
|
||||||
|
db.delete_user(user_uuid, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{user_uuid}/credentials/{credential_uuid}")
|
||||||
|
async def admin_delete_user_credential(
|
||||||
|
user_uuid: UUID,
|
||||||
|
credential_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user = db.data().users[user_uuid]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{user_uuid}/sessions/{session_id}")
|
||||||
|
async def admin_delete_user_session(
|
||||||
|
user_uuid: UUID,
|
||||||
|
session_id: str,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user = db.data().users[user_uuid]
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:admin"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
session_key = session_id
|
||||||
|
|
||||||
|
target_session = db.data().sessions.get(session_key)
|
||||||
|
if not target_session or target_session.user_uuid != user_uuid:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
db.delete_session(session_key, ctx=ctx, action="admin:delete_session")
|
||||||
|
|
||||||
|
# Check if admin terminated their own session
|
||||||
|
current_terminated = session_key == ctx.session.key
|
||||||
|
return {"status": "ok", "current_session_terminated": current_terminated}
|
||||||
+84
-64
@@ -13,15 +13,17 @@ from fastapi import (
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
from paskia import db
|
from paskia import authcode, db
|
||||||
from paskia.authsession import EXPIRES, expires, get_reset
|
from paskia._version import __version__
|
||||||
|
from paskia.authsession import EXPIRES, get_reset, session_ctx
|
||||||
from paskia.fastapi import authz, session, user
|
from paskia.fastapi import authz, session, user
|
||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||||
from paskia.globals import passkey as global_passkey
|
from paskia.globals import passkey as global_passkey
|
||||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
from paskia.util import hostutil, htmlutil, passphrase, userinfo
|
||||||
|
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=True)
|
bearer_auth = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
@@ -87,23 +89,23 @@ async def validate_token(
|
|||||||
raise
|
raise
|
||||||
renewed = False
|
renewed = False
|
||||||
if auth:
|
if auth:
|
||||||
consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC))
|
consumed = datetime.now(UTC) - ctx.session.validated
|
||||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||||
db.update_session(
|
db.update_session(
|
||||||
auth,
|
ctx.session.key,
|
||||||
ip=request.client.host if request.client else "",
|
ip=get_client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent") or "",
|
user_agent=request.headers.get("user-agent") or "",
|
||||||
expiry=expires(),
|
validated=datetime.now(UTC),
|
||||||
ctx=ctx,
|
ctx=ctx,
|
||||||
)
|
)
|
||||||
session.set_session_cookie(response, auth)
|
session.set_session_cookie(response, auth)
|
||||||
renewed = True
|
renewed = True
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
{
|
ApiValidateResponse(
|
||||||
"valid": True,
|
valid=True,
|
||||||
"renewed": renewed,
|
renewed=renewed,
|
||||||
"ctx": userinfo.build_session_context(ctx),
|
ctx=userinfo.build_session_context(ctx),
|
||||||
}
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ async def forward_authentication(
|
|||||||
- If Accept header contains "text/html": HTML page for authentication
|
- If Accept header contains "text/html": HTML page for authentication
|
||||||
with data attributes for mode and other metadata.
|
with data attributes for mode and other metadata.
|
||||||
- Otherwise: JSON response with error details and an `iframe` field
|
- Otherwise: JSON response with error details and an `iframe` field
|
||||||
pointing to /auth/restricted/?mode=... for iframe-based authentication.
|
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
@@ -150,11 +152,7 @@ async def forward_authentication(
|
|||||||
"Remote-Role": str(ctx.role.uuid),
|
"Remote-Role": str(ctx.role.uuid),
|
||||||
"Remote-Role-Name": ctx.role.display_name,
|
"Remote-Role-Name": ctx.role.display_name,
|
||||||
"Remote-Session-Expires": (
|
"Remote-Session-Expires": (
|
||||||
ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
(ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z")
|
||||||
if ctx.session.expiry.tzinfo
|
|
||||||
else ctx.session.expiry.replace(tzinfo=UTC)
|
|
||||||
.isoformat()
|
|
||||||
.replace("+00:00", "Z")
|
|
||||||
),
|
),
|
||||||
"Remote-Credential": str(ctx.session.credential),
|
"Remote-Credential": str(ctx.session.credential),
|
||||||
}
|
}
|
||||||
@@ -163,42 +161,36 @@ async def forward_authentication(
|
|||||||
# Clear cookie only if session is invalid (not for reauth)
|
# Clear cookie only if session is invalid (not for reauth)
|
||||||
if e.clear_session:
|
if e.clear_session:
|
||||||
session.clear_session_cookie(response)
|
session.clear_session_cookie(response)
|
||||||
|
# Browser request? - return full-page HTML with metadata patched into data attrs
|
||||||
# Check Accept header to decide response format
|
if "text/html" in request.headers.get("accept", ""):
|
||||||
accept = request.headers.get("accept", "")
|
return await htmlutil.patched_html_response(
|
||||||
wants_html = "text/html" in accept
|
request, "/int/forward/", e.status_code, mode=e.mode, **e.metadata
|
||||||
|
|
||||||
if wants_html:
|
|
||||||
# Browser request - return full-page HTML with metadata
|
|
||||||
data_attrs = {"mode": e.mode, **e.metadata}
|
|
||||||
html = (await vitedev.read("/int/forward/index.html"))[0]
|
|
||||||
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
|
||||||
return Response(
|
|
||||||
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# API request - return JSON with iframe srcdoc HTML
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=e.status_code,
|
|
||||||
content=await authz.auth_error_content(e),
|
|
||||||
)
|
)
|
||||||
|
# API request - return JSON with iframe srcdoc HTML
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=e.status_code, content=await authz.auth_error_content(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/settings")
|
@app.get("/settings")
|
||||||
async def get_settings():
|
async def get_settings():
|
||||||
pk = global_passkey.instance
|
pk = global_passkey.instance
|
||||||
base_path = hostutil.ui_base_path()
|
base_path = hostutil.ui_base_path()
|
||||||
return {
|
return MsgspecResponse(
|
||||||
"rp_id": pk.rp_id,
|
ApiSettings(
|
||||||
"rp_name": pk.rp_name,
|
rp_id=pk.rp_id,
|
||||||
"ui_base_path": base_path,
|
rp_name=pk.rp_name,
|
||||||
"auth_host": hostutil.dedicated_auth_host(),
|
ui_base_path=base_path,
|
||||||
"auth_site_url": hostutil.auth_site_url(),
|
auth_host=hostutil.dedicated_auth_host(),
|
||||||
"session_cookie": AUTH_COOKIE_NAME,
|
auth_site_url=hostutil.auth_site_url(),
|
||||||
}
|
session_cookie=AUTH_COOKIE_NAME,
|
||||||
|
version=__version__,
|
||||||
|
),
|
||||||
|
headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/user-info")
|
@app.get("/user-info")
|
||||||
async def api_user_info(
|
async def api_user_info(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -211,16 +203,21 @@ async def api_user_info(
|
|||||||
detail="Authentication required",
|
detail="Authentication required",
|
||||||
mode="login",
|
mode="login",
|
||||||
)
|
)
|
||||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
ctx = session_ctx(auth, request.headers.get("host"))
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise HTTPException(401, "Session expired")
|
raise authz.AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Session expired",
|
||||||
|
mode="login",
|
||||||
|
clear_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
await userinfo.build_user_info(
|
await userinfo.build_user_info(
|
||||||
user_uuid=ctx.user.uuid,
|
user_uuid=ctx.user.uuid,
|
||||||
auth=auth,
|
session_key=ctx.session.key,
|
||||||
session_record=ctx.session,
|
|
||||||
request_host=request.headers.get("host"),
|
request_host=request.headers.get("host"),
|
||||||
|
ctx=ctx,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -228,6 +225,8 @@ async def api_user_info(
|
|||||||
@app.get("/token-info")
|
@app.get("/token-info")
|
||||||
async def token_info(credentials=Depends(bearer_auth)):
|
async def token_info(credentials=Depends(bearer_auth)):
|
||||||
"""Get reset/device-add token info. Pass token via Bearer header."""
|
"""Get reset/device-add token info. Pass token via Bearer header."""
|
||||||
|
if not credentials or not credentials.credentials:
|
||||||
|
raise HTTPException(401, "Bearer token required")
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
if not passphrase.is_well_formed(token):
|
if not passphrase.is_well_formed(token):
|
||||||
raise HTTPException(400, "Invalid token format")
|
raise HTTPException(400, "Invalid token format")
|
||||||
@@ -237,10 +236,13 @@ async def token_info(credentials=Depends(bearer_auth)):
|
|||||||
raise HTTPException(401, str(e))
|
raise HTTPException(401, str(e))
|
||||||
|
|
||||||
u = reset_token.user
|
u = reset_token.user
|
||||||
return {
|
return MsgspecResponse(
|
||||||
"token_type": reset_token.token_type,
|
ApiTokenInfo(
|
||||||
"display_name": u.display_name,
|
token_type=reset_token.token_type,
|
||||||
}
|
display_name=u.display_name,
|
||||||
|
theme=u.theme,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/logout")
|
@app.post("/logout")
|
||||||
@@ -248,11 +250,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|||||||
if not auth:
|
if not auth:
|
||||||
return {"message": "Already logged out"}
|
return {"message": "Already logged out"}
|
||||||
host = request.headers.get("host")
|
host = request.headers.get("host")
|
||||||
ctx = db.data().session_ctx(auth, host)
|
ctx = session_ctx(auth, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
return {"message": "Already logged out"}
|
return {"message": "Already logged out"}
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
db.delete_session(auth, ctx=ctx, action="logout")
|
db.delete_session(ctx.session.key, ctx=ctx, action="logout")
|
||||||
session.clear_session_cookie(response)
|
session.clear_session_cookie(response)
|
||||||
return {"message": "Logged out successfully"}
|
return {"message": "Logged out successfully"}
|
||||||
|
|
||||||
@@ -261,11 +263,29 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|||||||
async def api_set_session(
|
async def api_set_session(
|
||||||
request: Request, response: Response, auth=Depends(bearer_auth)
|
request: Request, response: Response, auth=Depends(bearer_auth)
|
||||||
):
|
):
|
||||||
ctx = db.data().session_ctx(auth.credentials, request.headers.get("host"))
|
"""Exchange an auth code for setting the session cookie.
|
||||||
|
|
||||||
|
Called by frontend after WebSocket authentication.
|
||||||
|
The code is ephemeral (60s TTL) and can only be used once.
|
||||||
|
"""
|
||||||
|
if not auth or not auth.credentials:
|
||||||
|
raise HTTPException(400, "Bearer token required")
|
||||||
|
|
||||||
|
# Verify host is provided
|
||||||
|
host = hostutil.normalize_host(request.headers.get("host", ""))
|
||||||
|
if not host:
|
||||||
|
raise HTTPException(400, "Host header required")
|
||||||
|
|
||||||
|
a = authcode.consume_cookie(auth.credentials)
|
||||||
|
if not a:
|
||||||
|
raise HTTPException(401, "Code expired or already used")
|
||||||
|
|
||||||
|
secret = a.session_key
|
||||||
|
|
||||||
|
# Verify the session exists
|
||||||
|
ctx = session_ctx(secret, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise HTTPException(401, "Session expired")
|
raise HTTPException(401, f"Session not found on {host}")
|
||||||
session.set_session_cookie(response, auth.credentials)
|
|
||||||
return {
|
session.set_session_cookie(response, secret)
|
||||||
"message": "Session cookie set successfully",
|
return {"status": "ok", "user": str(ctx.user.uuid)}
|
||||||
"user": str(ctx.user.uuid),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ async def auth_error_content(exc: AuthException) -> dict:
|
|||||||
# Build hash fragment from mode and metadata
|
# Build hash fragment from mode and metadata
|
||||||
params = {"mode": exc.mode, **exc.metadata}
|
params = {"mode": exc.mode, **exc.metadata}
|
||||||
fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||||
iframe_url = f"/auth/restricted/#{fragment}"
|
iframe_url = f"/auth/restricted/iframe#{fragment}"
|
||||||
return {
|
return {
|
||||||
"detail": exc.detail,
|
"detail": exc.detail,
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -80,6 +80,9 @@ async def verify(
|
|||||||
mode="login",
|
mode="login",
|
||||||
clear_session=True,
|
clear_session=True,
|
||||||
)
|
)
|
||||||
|
# User's theme preference for iframe (only if explicitly set)
|
||||||
|
user_theme = ctx.user.theme if ctx.user.theme else None
|
||||||
|
|
||||||
# Check max_age requirement if specified
|
# Check max_age requirement if specified
|
||||||
if max_age:
|
if max_age:
|
||||||
try:
|
try:
|
||||||
@@ -88,6 +91,7 @@ async def verify(
|
|||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Additional authentication required",
|
detail="Additional authentication required",
|
||||||
mode="reauth",
|
mode="reauth",
|
||||||
|
theme=user_theme,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Invalid max_age format - log but don't fail the request
|
# Invalid max_age format - log but don't fail the request
|
||||||
@@ -104,7 +108,10 @@ async def verify(
|
|||||||
ctx, perm, missing, require_all=(match == permutil.has_all)
|
ctx, perm, missing, require_all=(match == permutil.has_all)
|
||||||
)
|
)
|
||||||
raise AuthException(
|
raise AuthException(
|
||||||
status_code=403, mode="forbidden", detail="Permission required"
|
status_code=403,
|
||||||
|
mode="forbidden",
|
||||||
|
detail="Permission required",
|
||||||
|
theme=user_theme,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
|
# Vue Frontend static files
|
||||||
|
frontend = Frontend(
|
||||||
|
Path(__file__).parent.parent / "frontend-build",
|
||||||
|
cached=["/auth/assets/"],
|
||||||
|
favicon="/paskia.webp",
|
||||||
|
)
|
||||||
+42
-42
@@ -38,10 +38,33 @@ _AUTHZ_GRANTED = "\033[0;32m" # Granted scope (green)
|
|||||||
|
|
||||||
|
|
||||||
def format_ipv6_network(ip: str) -> str:
|
def format_ipv6_network(ip: str) -> str:
|
||||||
"""Format IPv6 address to show only network part (first 64 bits)."""
|
"""Format IPv6 address to show only network part (first 64 bits).
|
||||||
|
|
||||||
|
Special addresses are returned as-is for clarity:
|
||||||
|
- ::1 (loopback)
|
||||||
|
- :: (unspecified)
|
||||||
|
- ::ffff:x.x.x.x (IPv4-mapped, returns just the IPv4 part)
|
||||||
|
- fe80:: (link-local, returned as-is since interface-specific)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Strip brackets that some proxies add around IPv6
|
||||||
|
ip = ip.strip("[]")
|
||||||
|
# Strip zone ID (e.g., fe80::1%eth0)
|
||||||
|
if "%" in ip:
|
||||||
|
ip = ip.split("%")[0]
|
||||||
addr = IPv6Address(ip)
|
addr = IPv6Address(ip)
|
||||||
# Get the integer representation and mask to first 64 bits
|
|
||||||
|
# Special cases - return as-is or with minimal processing
|
||||||
|
if addr.is_loopback: # ::1
|
||||||
|
return "::1"
|
||||||
|
if addr.is_unspecified: # ::
|
||||||
|
return "::"
|
||||||
|
if addr.ipv4_mapped: # ::ffff:x.x.x.x
|
||||||
|
return str(addr.ipv4_mapped)
|
||||||
|
if addr.is_link_local: # fe80::/10 - interface-specific, keep full
|
||||||
|
return str(addr)
|
||||||
|
|
||||||
|
# Regular addresses: truncate to /64 network prefix
|
||||||
network_int = int(addr) >> 64
|
network_int = int(addr) >> 64
|
||||||
# Format as IPv6 with trailing ::
|
# Format as IPv6 with trailing ::
|
||||||
# Split into 4 groups of 16 bits
|
# Split into 4 groups of 16 bits
|
||||||
@@ -61,7 +84,9 @@ def format_client_ip(ip: str) -> str:
|
|||||||
"""Format client IP, compressing IPv6 to network part only."""
|
"""Format client IP, compressing IPv6 to network part only."""
|
||||||
if not ip or ip == "-":
|
if not ip or ip == "-":
|
||||||
return "-"
|
return "-"
|
||||||
if ":" in ip:
|
# Strip brackets for detection (some proxies add them)
|
||||||
|
stripped = ip.strip("[]")
|
||||||
|
if ":" in stripped:
|
||||||
return format_ipv6_network(ip)
|
return format_ipv6_network(ip)
|
||||||
return ip
|
return ip
|
||||||
|
|
||||||
@@ -90,25 +115,16 @@ def format_access_log(
|
|||||||
client: str, status: int, method: str, host: str, path: str, duration_ms: float
|
client: str, status: int, method: str, host: str, path: str, duration_ms: float
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Format access log line with colors and aligned fields."""
|
"""Format access log line with colors and aligned fields."""
|
||||||
use_color = sys.stderr.isatty()
|
|
||||||
|
|
||||||
# Format components with fixed widths for alignment
|
# Format components with fixed widths for alignment
|
||||||
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
|
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
|
||||||
timing = f"{duration_ms:.0f}ms"
|
timing = f"{duration_ms:.0f}ms"
|
||||||
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
|
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
|
||||||
|
|
||||||
if use_color:
|
status_str = f"{status_color(status)}{status}{_RESET}"
|
||||||
status_str = f"{status_color(status)}{status}{_RESET}"
|
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
method_str = f"{method_color(method)}{method_padded}{_RESET}"
|
||||||
method_str = f"{method_color(method)}{method_padded}{_RESET}"
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
host_str = f"{_HOST}{host}{_RESET}"
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
path_str = f"{_PATH}{path}{_RESET}"
|
|
||||||
else:
|
|
||||||
status_str = str(status)
|
|
||||||
timing_str = timing
|
|
||||||
method_str = method_padded
|
|
||||||
host_str = host
|
|
||||||
path_str = path
|
|
||||||
|
|
||||||
# Format: "IP STATUS METHOD host path TIMING"
|
# Format: "IP STATUS METHOD host path TIMING"
|
||||||
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
||||||
@@ -128,7 +144,6 @@ def _next_ws_id() -> int:
|
|||||||
|
|
||||||
def log_ws_open(ws) -> int:
|
def log_ws_open(ws) -> int:
|
||||||
"""Log WebSocket connection open. Returns connection ID for use in close."""
|
"""Log WebSocket connection open. Returns connection ID for use in close."""
|
||||||
use_color = sys.stderr.isatty()
|
|
||||||
ws_id = _next_ws_id()
|
ws_id = _next_ws_id()
|
||||||
|
|
||||||
client = ws.client.host if ws.client else "-"
|
client = ws.client.host if ws.client else "-"
|
||||||
@@ -144,19 +159,11 @@ def log_ws_open(ws) -> int:
|
|||||||
origin_host = origin.split("://", 1)[-1] if origin else None
|
origin_host = origin.split("://", 1)[-1] if origin else None
|
||||||
show_origin = origin_host and origin_host != host
|
show_origin = origin_host and origin_host != host
|
||||||
|
|
||||||
if use_color:
|
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
||||||
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
|
||||||
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
host_str = f"{_HOST}{host}{_RESET}"
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
path_str = f"{_PATH}{path}{_RESET}"
|
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
||||||
origin_str = (
|
|
||||||
f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
prefix = f"WS+ {id_str}"
|
|
||||||
host_str = host
|
|
||||||
path_str = path
|
|
||||||
origin_str = f" from {origin_host}" if show_origin else ""
|
|
||||||
|
|
||||||
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
||||||
return ws_id
|
return ws_id
|
||||||
@@ -184,8 +191,6 @@ WS_CLOSE_CODES = {
|
|||||||
|
|
||||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||||
"""Log WebSocket connection close with duration and status."""
|
"""Log WebSocket connection close with duration and status."""
|
||||||
use_color = sys.stderr.isatty()
|
|
||||||
|
|
||||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
||||||
timing = f"{duration * 1000:.0f}ms"
|
timing = f"{duration * 1000:.0f}ms"
|
||||||
|
|
||||||
@@ -195,15 +200,10 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
|||||||
else:
|
else:
|
||||||
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
||||||
|
|
||||||
if use_color:
|
# 🔌 aligned with status, ID aligned with method
|
||||||
# 🔌 aligned with status, ID aligned with method
|
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
|
||||||
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
|
status_str = f"{_WS_STATUS}{status}{_RESET}"
|
||||||
status_str = f"{_WS_STATUS}{status}{_RESET}"
|
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
|
||||||
else:
|
|
||||||
prefix = f"WS- {id_str}"
|
|
||||||
status_str = status
|
|
||||||
timing_str = timing
|
|
||||||
|
|
||||||
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
||||||
|
|
||||||
|
|||||||
+74
-28
@@ -1,20 +1,26 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import msgspec
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from fastapi_vue import Frontend
|
|
||||||
|
|
||||||
from paskia import globals
|
from paskia import authcode, db, globals
|
||||||
|
from paskia.__main__ import DEVMODE
|
||||||
|
from paskia.bootstrap import bootstrap_if_needed
|
||||||
from paskia.db import start_background, stop_background
|
from paskia.db import start_background, stop_background
|
||||||
|
from paskia.db.background import flush
|
||||||
from paskia.db.logging import configure_db_logging
|
from paskia.db.logging import configure_db_logging
|
||||||
from paskia.fastapi import admin, api, auth_host, ws
|
from paskia.fastapi import admin, api, auth_host, oid, ws
|
||||||
|
|
||||||
|
# Import frontend instance
|
||||||
|
from paskia.fastapi.front import frontend
|
||||||
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import hostutil, passphrase, vitedev
|
from paskia.util import hostutil, passphrase, vitedev
|
||||||
|
from paskia.util.runtime import RuntimeConfig
|
||||||
|
|
||||||
# Configure custom logging
|
# Configure custom logging
|
||||||
configure_access_logging()
|
configure_access_logging()
|
||||||
@@ -22,13 +28,6 @@ configure_db_logging()
|
|||||||
|
|
||||||
_access_logger = logging.getLogger("paskia.access")
|
_access_logger = logging.getLogger("paskia.access")
|
||||||
|
|
||||||
# Vue Frontend static files
|
|
||||||
frontend = Frontend(
|
|
||||||
Path(__file__).parent.parent / "frontend-build",
|
|
||||||
cached=["/auth/assets/"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Path to examples/index.html when running from source tree
|
# Path to examples/index.html when running from source tree
|
||||||
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||||
|
|
||||||
@@ -41,14 +40,13 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||||
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
||||||
"""
|
"""
|
||||||
config = json.loads(os.environ["PASKIA_CONFIG"])
|
runtime = msgspec.json.decode(os.environ["PASKIA_CONFIG"], type=RuntimeConfig)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
|
||||||
await globals.init(
|
await globals.init(
|
||||||
rp_id=config["rp_id"],
|
rp_id=runtime.config.rp_id,
|
||||||
rp_name=config["rp_name"],
|
rp_name=runtime.config.rp_name,
|
||||||
origins=config["origins"],
|
origins=runtime.config.origins,
|
||||||
bootstrap=False,
|
bootstrap=False,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -56,15 +54,22 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
# Re-raise to fail fast
|
# Re-raise to fail fast
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# Bootstrap and persist config now that the full DB is loaded
|
||||||
|
await bootstrap_if_needed(config=runtime.config)
|
||||||
|
if runtime.save:
|
||||||
|
db.update_config(runtime.config)
|
||||||
|
await flush()
|
||||||
|
|
||||||
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
||||||
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
||||||
if frontend.devmode:
|
if app.debug:
|
||||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||||
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
||||||
await frontend.load()
|
await frontend.load()
|
||||||
await start_background()
|
await start_background()
|
||||||
yield
|
yield
|
||||||
await stop_background()
|
await stop_background()
|
||||||
|
await authcode.stop()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -73,6 +78,7 @@ app = FastAPI(
|
|||||||
docs_url=None,
|
docs_url=None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
|
debug=DEVMODE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Custom access logging (uvicorn's access_log is disabled)
|
# Custom access logging (uvicorn's access_log is disabled)
|
||||||
@@ -84,12 +90,52 @@ app.middleware("http")(auth_host.redirect_middleware)
|
|||||||
app.mount("/auth/api/admin/", admin.app)
|
app.mount("/auth/api/admin/", admin.app)
|
||||||
app.mount("/auth/api/", api.app)
|
app.mount("/auth/api/", api.app)
|
||||||
app.mount("/auth/ws/", ws.app)
|
app.mount("/auth/ws/", ws.app)
|
||||||
|
app.mount("/auth/oidc/", oid.app)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/restricted/")
|
# OIDC Well-Known endpoints (must be at site root)
|
||||||
async def restricted_view():
|
@app.get("/.well-known/openid-configuration")
|
||||||
"""Serve the restricted/authentication UI for iframe embedding."""
|
async def openid_configuration(request: Request):
|
||||||
return Response(*await vitedev.read("/auth/restricted/index.html"))
|
"""OpenID Connect Discovery document."""
|
||||||
|
# Build issuer URL from request
|
||||||
|
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||||
|
host = request.headers.get("host", request.url.netloc)
|
||||||
|
issuer = f"{scheme}://{host}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issuer": issuer,
|
||||||
|
"authorization_endpoint": f"{issuer}/auth/restricted/oidc",
|
||||||
|
"token_endpoint": f"{issuer}/auth/oidc/token",
|
||||||
|
"userinfo_endpoint": f"{issuer}/auth/oidc/userinfo",
|
||||||
|
"jwks_uri": f"{issuer}/auth/oidc/keys",
|
||||||
|
"backchannel_logout_supported": True,
|
||||||
|
"backchannel_logout_session_supported": True,
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||||
|
"subject_types_supported": ["public"],
|
||||||
|
"id_token_signing_alg_values_supported": ["EdDSA"],
|
||||||
|
"scopes_supported": ["openid", "profile", "email"],
|
||||||
|
"token_endpoint_auth_methods_supported": [
|
||||||
|
"client_secret_post",
|
||||||
|
"client_secret_basic",
|
||||||
|
],
|
||||||
|
"code_challenge_methods_supported": ["S256"],
|
||||||
|
"claims_supported": [
|
||||||
|
"sub",
|
||||||
|
"name",
|
||||||
|
"preferred_username",
|
||||||
|
"email",
|
||||||
|
"groups",
|
||||||
|
"sid",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/restricted/iframe")
|
||||||
|
@app.get("/auth/restricted/oidc")
|
||||||
|
async def restricted_view(request: Request):
|
||||||
|
"""Serve the restricted/authentication UI for iframe or OpenID Connect."""
|
||||||
|
return await vitedev.handle(request, frontend, "/auth/restricted/")
|
||||||
|
|
||||||
|
|
||||||
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
||||||
@@ -104,7 +150,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
|||||||
The frontend handles mode detection (host mode vs full profile) based on settings.
|
The frontend handles mode detection (host mode vs full profile) based on settings.
|
||||||
Access control is handled via APIs.
|
Access control is handled via APIs.
|
||||||
"""
|
"""
|
||||||
return Response(*await vitedev.read("/auth/index.html"))
|
return await vitedev.handle(request, frontend, "/auth/")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin", include_in_schema=False)
|
@app.get("/admin", include_in_schema=False)
|
||||||
@@ -135,10 +181,14 @@ async def examples_page():
|
|||||||
return FileResponse(index_file, media_type="text/html")
|
return FileResponse(index_file, media_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
# Frontend static files - must be before /{token} catch-all routes
|
||||||
|
frontend.route(app, "/")
|
||||||
|
|
||||||
|
|
||||||
# Note: this catch-all handler must be the last route defined
|
# Note: this catch-all handler must be the last route defined
|
||||||
@app.get("/{token}")
|
@app.get("/{token}")
|
||||||
@app.get("/auth/{token}")
|
@app.get("/auth/{token}")
|
||||||
async def token_link(token: str):
|
async def token_link(request: Request, token: str):
|
||||||
"""Serve the reset app for reset tokens (password reset / device addition).
|
"""Serve the reset app for reset tokens (password reset / device addition).
|
||||||
|
|
||||||
The frontend will validate the token via /auth/api/token-info.
|
The frontend will validate the token via /auth/api/token-info.
|
||||||
@@ -146,8 +196,4 @@ async def token_link(token: str):
|
|||||||
if not passphrase.is_well_formed(token):
|
if not passphrase.is_well_formed(token):
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
return Response(*await vitedev.read("/int/reset/index.html"))
|
return await vitedev.handle(request, frontend, "/int/reset/")
|
||||||
|
|
||||||
|
|
||||||
# Final catch-all route for frontend files (keep at end of file)
|
|
||||||
frontend.route(app, "/")
|
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
"""
|
||||||
|
OIDC Provider endpoints.
|
||||||
|
|
||||||
|
Implements OpenID Connect 1.0 Authorization Code flow:
|
||||||
|
- POST /token - Token endpoint (code exchange)
|
||||||
|
- GET /userinfo - UserInfo endpoint (bearer token)
|
||||||
|
|
||||||
|
Authorization is handled by /auth/restricted/oidc which passes OIDC params to
|
||||||
|
the /auth/ws/authenticate WebSocket.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import base64url
|
||||||
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
|
from paskia import authcode, db
|
||||||
|
from paskia.db.structs import Session
|
||||||
|
from paskia.util import oidjwt
|
||||||
|
from paskia.util.crypto import hash_secret
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/keys")
|
||||||
|
async def keys():
|
||||||
|
"""JSON Web Key Set for token verification."""
|
||||||
|
return oidjwt.get_jwks()
|
||||||
|
|
||||||
|
|
||||||
|
def _oidc_session_by_token(
|
||||||
|
token: str, client_uuid: UUID | None = None
|
||||||
|
) -> Session | None:
|
||||||
|
"""Look up an OIDC session by token (refresh token value)."""
|
||||||
|
key = hash_secret("oidc", token)
|
||||||
|
s = db.data().sessions.get(key)
|
||||||
|
if not s or s.client_uuid is None:
|
||||||
|
return None
|
||||||
|
if client_uuid is not None and s.client_uuid != client_uuid:
|
||||||
|
return None
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _oidc_session_by_sid(sid: bytes, client_uuid: UUID | None = None) -> Session | None:
|
||||||
|
"""Look up an OIDC session by sid (for backchannel logout)."""
|
||||||
|
for s in db.data().sessions.values():
|
||||||
|
if s.client_uuid is None:
|
||||||
|
continue
|
||||||
|
if client_uuid is not None and s.client_uuid != client_uuid:
|
||||||
|
continue
|
||||||
|
if base64url.dec(s.key) == sid:
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_issuer(request: Request) -> str:
|
||||||
|
"""Build issuer URL from request."""
|
||||||
|
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||||
|
host = request.headers.get("host", request.url.netloc)
|
||||||
|
return f"{scheme}://{host}"
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_pkce(code_verifier: str, code_challenge: str) -> bool:
|
||||||
|
"""Verify PKCE code_verifier against stored code_challenge (S256 only)."""
|
||||||
|
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||||
|
computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||||
|
return computed == code_challenge
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_client_credentials(
|
||||||
|
request: Request,
|
||||||
|
client_id: str | None,
|
||||||
|
client_secret: str | None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Extract client credentials from request (Basic auth or body params)."""
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if auth_header.lower().startswith("basic "):
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
|
||||||
|
client_id, client_secret = decoded.split(":", 1)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(401, "Invalid Authorization header")
|
||||||
|
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
raise HTTPException(401, "Missing client credentials")
|
||||||
|
|
||||||
|
return client_id, client_secret
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/token")
|
||||||
|
async def token(
|
||||||
|
request: Request,
|
||||||
|
grant_type: str = Form(...),
|
||||||
|
code: str | None = Form(None),
|
||||||
|
redirect_uri: str | None = Form(None),
|
||||||
|
client_id: str | None = Form(None),
|
||||||
|
client_secret: str | None = Form(None),
|
||||||
|
code_verifier: str | None = Form(None),
|
||||||
|
refresh_token: str | None = Form(None),
|
||||||
|
):
|
||||||
|
"""OIDC Token endpoint.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- grant_type=authorization_code: Exchange code for tokens
|
||||||
|
- grant_type=refresh_token: Refresh access token using sid
|
||||||
|
|
||||||
|
Supports client_secret_post and client_secret_basic authentication.
|
||||||
|
Per RFC 6749 Section 4.1.3: MUST use POST with application/x-www-form-urlencoded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# RFC 6749: Token endpoint MUST NOT accept query parameters
|
||||||
|
if request.url.query:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Query parameters not allowed",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# RFC 6749: MUST use application/x-www-form-urlencoded
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
if "application/x-www-form-urlencoded" not in content_type:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Content-Type must be application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get client credentials (required for all grant types)
|
||||||
|
client_id, client_secret = _parse_client_credentials(
|
||||||
|
request, client_id, client_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate client
|
||||||
|
try:
|
||||||
|
client_uuid = UUID(client_id)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse({"error": "invalid_client"}, status_code=401)
|
||||||
|
|
||||||
|
client = db.data().oidc.clients.get(client_uuid)
|
||||||
|
if not client or not client.verify_secret(client_secret):
|
||||||
|
return JSONResponse({"error": "invalid_client"}, status_code=401)
|
||||||
|
|
||||||
|
if grant_type == "authorization_code":
|
||||||
|
return await _handle_authorization_code(
|
||||||
|
request, client, client_id, code, redirect_uri, code_verifier
|
||||||
|
)
|
||||||
|
elif grant_type == "refresh_token":
|
||||||
|
return await _handle_refresh_token(request, client, client_id, refresh_token)
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "unsupported_grant_type"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_authorization_code(
|
||||||
|
request: Request,
|
||||||
|
client,
|
||||||
|
client_id: str,
|
||||||
|
code: str | None,
|
||||||
|
redirect_uri: str | None,
|
||||||
|
code_verifier: str | None,
|
||||||
|
):
|
||||||
|
"""Handle grant_type=authorization_code."""
|
||||||
|
if not code:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_request", "error_description": "Missing code"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Consume auth code (atomic delete + return)
|
||||||
|
oidc_code = authcode.consume_oidc(code)
|
||||||
|
if not oidc_code:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_grant", "error_description": "Code expired or invalid"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up the OIDC session by token
|
||||||
|
session = _oidc_session_by_token(oidc_code.session_key, client.uuid)
|
||||||
|
if not session:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Session not found or not OIDC",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify redirect_uri matches
|
||||||
|
if redirect_uri and redirect_uri != oidc_code.redirect_uri:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_grant", "error_description": "redirect_uri mismatch"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify PKCE if code_challenge was provided at authorization time
|
||||||
|
if oidc_code.code_challenge:
|
||||||
|
if not code_verifier:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Missing code_verifier",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
if not _verify_pkce(code_verifier, oidc_code.code_challenge):
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Invalid code_verifier",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user from session
|
||||||
|
user = db.data().users.get(session.user_uuid)
|
||||||
|
if not user:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_grant", "error_description": "User not found"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Derive sid from session key
|
||||||
|
sid = session.key
|
||||||
|
|
||||||
|
return _build_token_response(
|
||||||
|
request,
|
||||||
|
user,
|
||||||
|
client_id,
|
||||||
|
oidc_code.session_key,
|
||||||
|
sid,
|
||||||
|
oidc_code.nonce,
|
||||||
|
oidc_code.scope,
|
||||||
|
credential_uuid=session.credential_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_refresh_token(
|
||||||
|
request: Request,
|
||||||
|
client,
|
||||||
|
client_id: str,
|
||||||
|
refresh_token_value: str | None,
|
||||||
|
):
|
||||||
|
"""Handle grant_type=refresh_token.
|
||||||
|
|
||||||
|
The refresh_token is the session secret. On refresh:
|
||||||
|
- Validates session exists and belongs to client
|
||||||
|
- Extends session expiry (24h sliding window)
|
||||||
|
- Issues new access_token and id_token
|
||||||
|
|
||||||
|
Note: ip and user_agent are NOT updated because the refresh request
|
||||||
|
comes from the OIDC client's backend, not the end user's browser.
|
||||||
|
"""
|
||||||
|
if not refresh_token_value:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_request", "error_description": "Missing refresh_token"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up session by refresh token
|
||||||
|
session = _oidc_session_by_token(refresh_token_value, client.uuid)
|
||||||
|
if not session:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Invalid or expired refresh_token",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
user = db.data().users.get(session.user_uuid)
|
||||||
|
if not user:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_grant", "error_description": "User not found"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh the session - extend expiry only
|
||||||
|
# Don't update ip/user_agent: the refresh request comes from the OIDC
|
||||||
|
# client's backend, not the end user's browser.
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
db.update_session(
|
||||||
|
session.key,
|
||||||
|
validated=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger.info("OIDC session refreshed: %s", session.key)
|
||||||
|
|
||||||
|
# Base64url encode session's derived sid for JWT claim
|
||||||
|
sid_str = session.key
|
||||||
|
|
||||||
|
return _build_token_response(
|
||||||
|
request,
|
||||||
|
user,
|
||||||
|
client_id,
|
||||||
|
refresh_token_value,
|
||||||
|
sid_str,
|
||||||
|
nonce=None,
|
||||||
|
scope="openid",
|
||||||
|
credential_uuid=session.credential_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_token_response(
|
||||||
|
request: Request,
|
||||||
|
user,
|
||||||
|
client_id: str,
|
||||||
|
secret: str,
|
||||||
|
sid: str,
|
||||||
|
nonce: str | None,
|
||||||
|
scope: str,
|
||||||
|
credential_uuid: UUID | None = None,
|
||||||
|
):
|
||||||
|
"""Build the token response with access_token, id_token, and refresh_token."""
|
||||||
|
issuer = _get_issuer(request)
|
||||||
|
|
||||||
|
# Get user's permissions scoped to this OIDC client (domain == client UUID)
|
||||||
|
role = user.role
|
||||||
|
org = role.org
|
||||||
|
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||||
|
groups = []
|
||||||
|
for perm_uuid in role.permission_set:
|
||||||
|
if perm_uuid not in org_perm_uuids:
|
||||||
|
continue
|
||||||
|
p = db.data().permissions.get(perm_uuid)
|
||||||
|
if p and p.domain == client_id:
|
||||||
|
groups.append(p.scope)
|
||||||
|
|
||||||
|
# Get credential's last_used as auth_time
|
||||||
|
auth_time = None
|
||||||
|
if credential_uuid:
|
||||||
|
try:
|
||||||
|
credential = db.data().credentials[credential_uuid]
|
||||||
|
if credential.last_used:
|
||||||
|
auth_time = credential.last_used
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create ID token
|
||||||
|
id_token = oidjwt.create_id_token(
|
||||||
|
issuer=issuer,
|
||||||
|
subject=user.uuid,
|
||||||
|
audience=client_id,
|
||||||
|
nonce=nonce,
|
||||||
|
sid=sid,
|
||||||
|
name=user.display_name,
|
||||||
|
preferred_username=user.preferred_username,
|
||||||
|
email=user.email,
|
||||||
|
groups=groups or None,
|
||||||
|
auth_time=auth_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
|
access_token = oidjwt.create_access_token(
|
||||||
|
issuer=issuer,
|
||||||
|
subject=user.uuid,
|
||||||
|
audience=client_id,
|
||||||
|
scope=scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"refresh_token": secret,
|
||||||
|
"id_token": id_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
bearer_auth = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/userinfo")
|
||||||
|
async def userinfo(
|
||||||
|
request: Request,
|
||||||
|
credentials=Depends(bearer_auth),
|
||||||
|
):
|
||||||
|
"""OIDC UserInfo endpoint.
|
||||||
|
|
||||||
|
Returns claims about the authenticated user.
|
||||||
|
Requires Bearer token from /token endpoint.
|
||||||
|
"""
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(401, "Bearer token required")
|
||||||
|
|
||||||
|
issuer = _get_issuer(request)
|
||||||
|
payload = oidjwt.decode_access_token(credentials.credentials, issuer)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(401, "Invalid or expired token")
|
||||||
|
|
||||||
|
# Verify audience is a valid client
|
||||||
|
aud = payload.get("aud")
|
||||||
|
if not aud:
|
||||||
|
raise HTTPException(401, "Invalid token (missing aud claim)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client_uuid = UUID(aud)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(401, "Invalid token (invalid aud format)")
|
||||||
|
|
||||||
|
if not db.data().oidc.clients.get(client_uuid):
|
||||||
|
raise HTTPException(401, "Invalid token (unknown client)")
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
try:
|
||||||
|
user_uuid = UUID(payload["sub"])
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
raise HTTPException(401, "Invalid token")
|
||||||
|
|
||||||
|
user = db.data().users.get(user_uuid)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "User not found")
|
||||||
|
|
||||||
|
# Get user's permissions scoped to this OIDC client (domain == client UUID)
|
||||||
|
role = user.role
|
||||||
|
org = role.org
|
||||||
|
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||||
|
groups = []
|
||||||
|
for perm_uuid in role.permission_set:
|
||||||
|
if perm_uuid not in org_perm_uuids:
|
||||||
|
continue
|
||||||
|
p = db.data().permissions.get(perm_uuid)
|
||||||
|
if p and p.domain == aud:
|
||||||
|
groups.append(p.scope)
|
||||||
|
|
||||||
|
# Build userinfo response based on scope
|
||||||
|
scope = payload.get("scope", "openid").split()
|
||||||
|
response = {"sub": str(user.uuid)}
|
||||||
|
|
||||||
|
if "profile" in scope:
|
||||||
|
response["name"] = user.display_name
|
||||||
|
if user.preferred_username:
|
||||||
|
response["preferred_username"] = user.preferred_username
|
||||||
|
|
||||||
|
if "email" in scope and user.email:
|
||||||
|
response["email"] = user.email
|
||||||
|
|
||||||
|
# Include client-scoped permissions as groups
|
||||||
|
if groups:
|
||||||
|
response["groups"] = groups
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/backchannel-logout")
|
||||||
|
async def backchannel_logout(
|
||||||
|
request: Request,
|
||||||
|
logout_token: str = Form(...),
|
||||||
|
):
|
||||||
|
"""OIDC Back-Channel Logout endpoint.
|
||||||
|
|
||||||
|
Receives a logout_token JWT from the RP and invalidates the session.
|
||||||
|
The logout_token must contain either 'sid' (session ID) or 'sub' (user ID).
|
||||||
|
Per OIDC Back-Channel Logout 1.0: uses application/x-www-form-urlencoded.
|
||||||
|
"""
|
||||||
|
# Validate content type
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
if "application/x-www-form-urlencoded" not in content_type:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Content-Type must be application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decode and verify the logout token
|
||||||
|
issuer = _get_issuer(request)
|
||||||
|
payload = oidjwt.decode_access_token(logout_token, issuer)
|
||||||
|
if not payload:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_request", "error_description": "Invalid logout_token"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate required claims
|
||||||
|
sid = payload.get("sid")
|
||||||
|
sub = payload.get("sub")
|
||||||
|
|
||||||
|
# Verify audience is a valid client (if present)
|
||||||
|
aud = payload.get("aud")
|
||||||
|
client_uuid = None
|
||||||
|
if aud:
|
||||||
|
try:
|
||||||
|
client_uuid = UUID(aud)
|
||||||
|
if not db.data().oidc.clients.get(client_uuid):
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Unknown client in logout_token",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Invalid client format in logout_token",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not sid and not sub:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "logout_token must contain sid or sub",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete session(s)
|
||||||
|
deleted = 0
|
||||||
|
if sid:
|
||||||
|
# Decode sid from base64url to bytes
|
||||||
|
try:
|
||||||
|
sid_bytes = base64url.dec(sid)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_request", "error_description": "Invalid sid format"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
# Delete specific session by sid
|
||||||
|
session = _oidc_session_by_sid(sid_bytes, client_uuid)
|
||||||
|
if session:
|
||||||
|
db.delete_session(session.key)
|
||||||
|
deleted = 1
|
||||||
|
_logger.info("Back-channel logout: deleted session %s", sid)
|
||||||
|
elif sub:
|
||||||
|
# Delete all OIDC sessions for this user/client
|
||||||
|
try:
|
||||||
|
user_uuid = UUID(sub)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_request", "error_description": "Invalid sub claim"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
# Find and delete matching sessions
|
||||||
|
sessions_to_delete = [
|
||||||
|
s
|
||||||
|
for s in db.data().sessions.values()
|
||||||
|
if s.user_uuid == user_uuid
|
||||||
|
and s.client_uuid is not None
|
||||||
|
and (client_uuid is None or s.client_uuid == client_uuid)
|
||||||
|
]
|
||||||
|
for session in sessions_to_delete:
|
||||||
|
db.delete_session(session.key)
|
||||||
|
deleted += 1
|
||||||
|
if deleted:
|
||||||
|
_logger.info(
|
||||||
|
"Back-channel logout: deleted %d sessions for user %s", deleted, sub
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return 200 OK even if no sessions were found (per spec)
|
||||||
|
return JSONResponse({"deleted": deleted})
|
||||||
+16
-10
@@ -10,17 +10,19 @@ Endpoints:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import UTC, datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import base64url
|
import base64url
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from paskia import db, remoteauth
|
from paskia import authcode, db, remoteauth
|
||||||
|
from paskia.authcode import CookieCode
|
||||||
from paskia.authsession import expires
|
from paskia.authsession import expires
|
||||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||||
from paskia.fastapi.wschat import authenticate_and_login
|
from paskia.fastapi.wschat import authenticate_and_login
|
||||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||||
from paskia.util import passphrase, pow, useragent
|
from paskia.util import pow, useragent
|
||||||
|
|
||||||
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
@@ -183,7 +185,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
|||||||
"user": str(result_data["user_uuid"]),
|
"user": str(result_data["user_uuid"]),
|
||||||
}
|
}
|
||||||
if result_data.get("session_token"):
|
if result_data.get("session_token"):
|
||||||
response["session_token"] = result_data["session_token"]
|
response["exchange_code"] = result_data["session_token"]
|
||||||
if result_data.get("reset_token"):
|
if result_data.get("reset_token"):
|
||||||
response["reset_token"] = result_data["reset_token"]
|
response["reset_token"] = result_data["reset_token"]
|
||||||
await ws.send_json(response)
|
await ws.send_json(response)
|
||||||
@@ -310,29 +312,33 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
|||||||
|
|
||||||
# Handle authenticate request (no PoW needed - already validated during lookup)
|
# Handle authenticate request (no PoW needed - already validated during lookup)
|
||||||
if msg.get("authenticate") and request is not None:
|
if msg.get("authenticate") and request is not None:
|
||||||
ctx = await authenticate_and_login(ws, auth)
|
ctx, secret = await authenticate_and_login(ws, auth)
|
||||||
|
|
||||||
session_token = ctx.session.key
|
|
||||||
reset_token = None
|
reset_token = None
|
||||||
|
|
||||||
if request.action == "register":
|
if request.action == "register":
|
||||||
# For registration, create a reset token for device addition
|
# For registration, create a reset token for device addition
|
||||||
token_str = passphrase.generate()
|
|
||||||
expiry = expires()
|
expiry = expires()
|
||||||
db.create_reset_token(
|
reset_token = db.create_reset_token(
|
||||||
user_uuid=ctx.user.uuid,
|
user_uuid=ctx.user.uuid,
|
||||||
passphrase=token_str,
|
|
||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
token_type="device addition",
|
token_type="device addition",
|
||||||
user=str(ctx.user.uuid),
|
user=str(ctx.user.uuid),
|
||||||
)
|
)
|
||||||
reset_token = token_str
|
|
||||||
|
# Create exchange code for the session (don't expose raw secret)
|
||||||
|
exchange_code = authcode.store_cookie(
|
||||||
|
CookieCode(
|
||||||
|
session_key=secret,
|
||||||
|
created=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Complete the remote auth request (notifies the waiting device)
|
# Complete the remote auth request (notifies the waiting device)
|
||||||
cred = db.data().credentials[ctx.session.credential_uuid]
|
cred = db.data().credentials[ctx.session.credential_uuid]
|
||||||
completed = await remoteauth.instance.complete_request(
|
completed = await remoteauth.instance.complete_request(
|
||||||
token=request.key,
|
token=request.key,
|
||||||
session_token=session_token,
|
session_token=exchange_code,
|
||||||
user_uuid=ctx.user.uuid,
|
user_uuid=ctx.user.uuid,
|
||||||
credential_uuid=cred.uuid,
|
credential_uuid=cred.uuid,
|
||||||
reset_token=reset_token,
|
reset_token=reset_token,
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
"""CLI support for creating user credential reset links.
|
|
||||||
|
|
||||||
Usage (via main CLI):
|
|
||||||
paskia reset [query]
|
|
||||||
|
|
||||||
If query is omitted, the master admin (first Administration role user in
|
|
||||||
an organization granting auth:admin) is targeted. Otherwise query is
|
|
||||||
matched as either an exact UUID or a case-insensitive substring of the
|
|
||||||
display name. If multiple users match, they are listed and the command
|
|
||||||
aborts. A new one-time reset link is always created.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from paskia import authsession as _authsession
|
|
||||||
from paskia import db
|
|
||||||
from paskia.util import hostutil, passphrase
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_targets(query: str | None):
|
|
||||||
if query:
|
|
||||||
# Try UUID
|
|
||||||
targets: list[tuple] = []
|
|
||||||
try:
|
|
||||||
q_uuid = UUID(query)
|
|
||||||
p = next(
|
|
||||||
(p for p in db.data().permissions.values() if p.scope == "auth:admin"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if p:
|
|
||||||
for org_uuid in p.orgs:
|
|
||||||
users = db.get_organization_users(org_uuid)
|
|
||||||
for u, role_name in users:
|
|
||||||
if u.uuid == q_uuid:
|
|
||||||
return [(u, role_name)]
|
|
||||||
# UUID not found among admin orgs -> fall back to substring search (rare case)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
# Substring search
|
|
||||||
needle = query.lower()
|
|
||||||
p = next(
|
|
||||||
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
|
||||||
)
|
|
||||||
if p:
|
|
||||||
for org_uuid in p.orgs:
|
|
||||||
users = db.get_organization_users(org_uuid)
|
|
||||||
for u, role_name in users:
|
|
||||||
if needle in (u.display_name or "").lower():
|
|
||||||
targets.append((u, role_name))
|
|
||||||
# De-duplicate
|
|
||||||
seen = set()
|
|
||||||
deduped = []
|
|
||||||
for u, role_name in targets:
|
|
||||||
if u.uuid not in seen:
|
|
||||||
seen.add(u.uuid)
|
|
||||||
deduped.append((u, role_name))
|
|
||||||
return deduped
|
|
||||||
# No query -> master admin
|
|
||||||
p = next(
|
|
||||||
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
|
||||||
)
|
|
||||||
if not p or not p.orgs:
|
|
||||||
return []
|
|
||||||
first_org_uuid = next(iter(p.orgs))
|
|
||||||
users = db.get_organization_users(first_org_uuid)
|
|
||||||
admin_users = [pair for pair in users if pair[1] == "Administration"]
|
|
||||||
return admin_users[:1]
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_reset(user, role_name: str):
|
|
||||||
token = passphrase.generate()
|
|
||||||
expiry = _authsession.reset_expires()
|
|
||||||
db.create_reset_token(
|
|
||||||
passphrase=token,
|
|
||||||
user_uuid=user.uuid,
|
|
||||||
expiry=expiry,
|
|
||||||
token_type="manual reset",
|
|
||||||
)
|
|
||||||
return hostutil.reset_link_url(token), token
|
|
||||||
|
|
||||||
|
|
||||||
async def _main(query: str | None) -> int:
|
|
||||||
try:
|
|
||||||
candidates = await _resolve_targets(query)
|
|
||||||
if not candidates:
|
|
||||||
print("No matching users found")
|
|
||||||
return 1
|
|
||||||
if len(candidates) > 1:
|
|
||||||
print("Multiple matches. Refine your query:")
|
|
||||||
for u, role_name in candidates:
|
|
||||||
print(f" - {u.display_name} ({u.uuid}) role={role_name}")
|
|
||||||
return 2
|
|
||||||
user, role_name = candidates[0]
|
|
||||||
link, token = await _create_reset(user, role_name)
|
|
||||||
print(f"Reset link for {user.display_name} ({user.uuid}):\n{link}\n")
|
|
||||||
return 0
|
|
||||||
except Exception as e: # pragma: no cover
|
|
||||||
print("Failed to create reset link:", e)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def run(query: str | None) -> int:
|
|
||||||
"""Synchronous wrapper for CLI entrypoint."""
|
|
||||||
return asyncio.run(_main(query))
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["run"]
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user