Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2456730f70 | ||
|
|
f74bf3ebe6 | ||
|
|
79074dd4f1 | ||
|
|
00ef0ae2e7 | ||
|
|
6f5287e070 | ||
|
|
051e1bbb41 | ||
|
|
4b156b712c | ||
|
|
df8a7c0026 | ||
|
|
9b28250391 | ||
|
|
b9aec6bb58 | ||
|
|
c79cb497ee | ||
|
|
9f50c8c20d | ||
|
|
816c7a681e | ||
|
|
d31c09084e | ||
|
|
cc938dd306 | ||
|
|
36db1e7e56 | ||
|
|
95c163e37a | ||
|
|
2d0d17c307 | ||
|
|
10980ad39b | ||
|
|
42b54cf645 | ||
|
|
232d0e1ae0 | ||
|
|
e97a2b3291 | ||
|
|
cde709e252 | ||
|
|
72d76df35d | ||
|
|
1a742fc0e7 | ||
|
|
0b29654d6f |
@@ -6,6 +6,7 @@ dist/
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
paskia.sqlite
|
paskia.sqlite
|
||||||
*.paskiadb
|
*.paskiadb
|
||||||
|
*.data
|
||||||
/paskia/frontend-build
|
/paskia/frontend-build
|
||||||
/paskia/_version.py
|
/paskia/_version.py
|
||||||
coverage-html/
|
coverage-html/
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ An easy to install passkey-based authentication service that protects any web ap
|
|||||||
## Authenticate to get to your app, or in your app
|
## Authenticate to get to your app, or in your app
|
||||||
|
|
||||||
- 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](docs/proxy/caddy.md), [Nginx](docs/proxy/nginx.md), and [others](docs/proxy/index.md))
|
||||||
|
|
||||||
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
|
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.
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ paskia [options]
|
|||||||
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
| --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) |
|
| --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.
|
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` folder in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
||||||
|
|
||||||
## Tutorial: From Local Testing to Production
|
## Tutorial: From Local Testing to Production
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ Enter your auth site domain on Admin / Server Options panel or use `--auth-host=
|
|||||||
|
|
||||||
## Further Documentation
|
## Further Documentation
|
||||||
|
|
||||||
- [Caddy configuration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Caddy.md)
|
- [Forward-Auth Guides](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/proxy/index.md) (Caddy, Nginx, ...)
|
||||||
- [Trusted Headers for Backend Apps](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Headers.md)
|
- [Trusted Headers for Backend Apps](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Headers.md)
|
||||||
- [Frontend integration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Integration.md)
|
- [Frontend integration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Integration.md)
|
||||||
- [Paskia API](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/API.md)
|
- [Paskia API](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/API.md)
|
||||||
|
|||||||
+70
-65
@@ -1,96 +1,101 @@
|
|||||||
# Paskia API
|
# Paskia API
|
||||||
|
|
||||||
|
[Integration](Integration.md) · [Proxy guides](proxy/index.md)
|
||||||
|
|
||||||
For integrating Paskia with your app frontend, see [integration](Integration.md).
|
For integrating Paskia with your app frontend, see [integration](Integration.md).
|
||||||
|
|
||||||
## Web Interface
|
## Web Interface
|
||||||
|
|
||||||
| Method | Path | What it is for | Notes |
|
| Method | Path | What it is for | Responses |
|
||||||
|---:|---|---|---|
|
|---:|---|---|---|
|
||||||
| GET | `/auth/` | User profile page | |
|
| GET | /auth/ | User profile page | HTML 200/401 |
|
||||||
| GET | `/auth/admin/` | Admin panel | Requires auth:admin (master) or org admin permissions. |
|
| GET | /auth/admin/ | Admin panel, requires auth:admin or org admin permissions | HTML 200/401/403 |
|
||||||
| GET | `/auth/{token}` | Reset / add credential URL (QR code link) | E.g. `/auth/fun.cotton.fresh.xray.lava` |
|
| GET | /auth/{token} | Reset / add credential URL (QR code link), e.g. /auth/fun.cotton.fresh.xray.lava | HTML 200 |
|
||||||
|
|
||||||
### Public JSON API: `/auth/api/*`
|
### Public JSON API: /auth/api/*
|
||||||
|
|
||||||
| Method | Path | Used for | Notes |
|
| Method | Path | Used for | Responses |
|
||||||
|---:|---|---|---|
|
|---:|---|---|---|
|
||||||
| GET | `/auth/api/settings` | Paskia configuration | Returns RP info + base paths + session cookie name |
|
| GET | /auth/api/settings | Paskia configuration: RP info, base paths, session cookie name | 200 |
|
||||||
| GET | `/auth/api/user-info` | Full user profile | Basic information, credentials, sessions, permissions |
|
| GET | /auth/api/user-info | Full user profile: info, credentials, sessions, permissions | 200/401 |
|
||||||
| 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 on the current host | 200 |
|
||||||
| POST | `/auth/api/validate` | Validate and renew session cookie | Optional query: `perm=` (repeatable), `max_age=` |
|
| POST | [/auth/api/validate](api/validate.md) | Validate and renew the session cookie; query [perm](api/perm.md), [max_age](api/max-age.md) | 200/401/403 |
|
||||||
| GET | `/auth/api/forward` | Validate access (Caddy/Nginx) | 204 on success; 401/403 otherwise (HTML if requested) |
|
| GET | [/auth/api/forward](api/forward.md) | Forward-auth with reverse proxies; see [proxy guides](proxy/index.md), query [perm](api/perm.md), [max_age](api/max-age.md) | 204/401/403 empty, json or html|
|
||||||
|
|
||||||
The `validate` and `forward` endpoints take query arguments `perm=` and `max_age=` for specific requirements on the validation of the current session.
|
### User JSON API: /auth/api/user/*
|
||||||
|
|
||||||
### User JSON API: `/auth/api/user/*`
|
| Method | Path | Used for | Responses |
|
||||||
|
|
||||||
| Method | Path | Used for | Notes |
|
|
||||||
|---:|---|---|---|
|
|---:|---|---|---|
|
||||||
| PUT | `/auth/api/user/display-name` | Update the user’s display name | Body: JSON `{ "display_name": "..." }` |
|
| PATCH | /auth/api/user/display-name | Update the user's display name | 200/401 |
|
||||||
| POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie |
|
| POST | /auth/api/user/logout-all | Terminate all user sessions | 200/401 |
|
||||||
| DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued |
|
| DELETE | /auth/api/user/session/{session_id} | Terminate one session | 200/401 |
|
||||||
| DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication |
|
| DELETE | /auth/api/user/credential/{uuid} | Delete a credential; requires recent authentication | 200/401/403 |
|
||||||
| POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication |
|
| POST | /auth/api/user/create-link | Create a device-add link; requires recent authentication | 200/401/403 |
|
||||||
|
| GET | /auth/api/user/{uuid}/profile.webp | Canonical avatar image URL, public on the auth host | 200/304/404 |
|
||||||
|
| PUT | /auth/api/user/{uuid}/profile.webp | Upload or replace an avatar; square WebP prepared in the browser | 200/401/403 |
|
||||||
|
| DELETE | /auth/api/user/{uuid}/profile.webp | Remove an avatar; allowed for the user or an admin | 200/401/403 |
|
||||||
|
|
||||||
These are used mostly from the user profile panel and modify the current user.
|
These are used mostly from the user profile panel by the user himself, but the profile pictures are public for all to read.
|
||||||
|
|
||||||
### Admin API: `/auth/api/admin/*`
|
### Admin API: /auth/api/admin/*
|
||||||
|
|
||||||
Normally only used via admin panel, requires auth admin permissions and can modify any users, orgs and permissions the session has access to.
|
Normally only used via admin panel, requires auth admin permissions and can modify any users, orgs and permissions the session has access to.
|
||||||
|
|
||||||
E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin `auth:admin` can see everything and create and manage orgs.
|
E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin auth:admin can see everything and create and manage orgs.
|
||||||
|
|
||||||
| Method | Path | Used for | Notes |
|
| Method | Path | Used for | Responses |
|
||||||
|---:|---|---|---|
|
|---:|---|---|---|
|
||||||
| GET | `/auth/api/admin/info` | Admin overview | Returns orgs, permissions, OIDC clients info |
|
| GET | /auth/api/admin/info | Admin overview: orgs, permissions, OIDC clients | 200/401/403 |
|
||||||
| POST | `/auth/api/admin/permissions/` | Create permission | Body: JSON with scope, display_name, domain |
|
| POST | /auth/api/admin/permissions/ | Create permission | 200/401/403 |
|
||||||
| PATCH | `/auth/api/admin/permissions/{uuid}` | Update permission | Query params: display_name, scope, domain |
|
| PATCH | /auth/api/admin/permissions/{uuid} | Update permission | 200/401/403 |
|
||||||
| DELETE | `/auth/api/admin/permissions/{uuid}` | Delete permission | |
|
| DELETE | /auth/api/admin/permissions/{uuid} | Delete permission | 200/401/403 |
|
||||||
| POST | `/auth/api/admin/orgs/` | Create organization | Body: JSON with display_name, permissions |
|
| POST | /auth/api/admin/orgs/ | Create organization | 200/401/403 |
|
||||||
| GET | `/auth/api/admin/orgs/{uuid}` | Get organization details | |
|
| GET | /auth/api/admin/orgs/{uuid} | Get organization details | 200/401/403 |
|
||||||
| PATCH | `/auth/api/admin/orgs/{uuid}` | Update organization | Body: JSON with display_name |
|
| PATCH | /auth/api/admin/orgs/{uuid} | Update organization | 200/401/403 |
|
||||||
| DELETE | `/auth/api/admin/orgs/{uuid}` | Delete organization | |
|
| DELETE | /auth/api/admin/orgs/{uuid} | Delete organization | 200/401/403 |
|
||||||
| POST | `/auth/api/admin/orgs/{uuid}/users` | Create user in org | Body: JSON with display_name, role_uuid |
|
| POST | /auth/api/admin/orgs/{uuid}/users | Create user in org | 200/401/403 |
|
||||||
| POST | `/auth/api/admin/orgs/{uuid}/roles` | Create role in org | Body: JSON with display_name, permissions |
|
| POST | /auth/api/admin/orgs/{uuid}/roles | Create role in org | 200/401/403 |
|
||||||
| POST | `/auth/api/admin/orgs/{uuid}/permission` | Grant permission to org | Query param: permission_uuid |
|
| POST | /auth/api/admin/orgs/{uuid}/permission | Grant permission to org | 200/401/403 |
|
||||||
| DELETE | `/auth/api/admin/orgs/{uuid}/permission` | Revoke permission from org | Query param: permission_uuid |
|
| DELETE | /auth/api/admin/orgs/{uuid}/permission | Revoke permission from org | 200/401/403 |
|
||||||
| PATCH | `/auth/api/admin/roles/{uuid}` | Update role | Body: JSON with display_name |
|
| PATCH | /auth/api/admin/roles/{uuid} | Update role | 200/401/403 |
|
||||||
| POST | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Add permission to role | |
|
| POST | /auth/api/admin/roles/{uuid}/permissions/{uuid} | Add permission to role | 200/401/403 |
|
||||||
| DELETE | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Remove permission from role | |
|
| DELETE | /auth/api/admin/roles/{uuid}/permissions/{uuid} | Remove permission from role | 200/401/403 |
|
||||||
| DELETE | `/auth/api/admin/roles/{uuid}` | Delete role | |
|
| DELETE | /auth/api/admin/roles/{uuid} | Delete role | 200/401/403 |
|
||||||
| PATCH | `/auth/api/admin/users/{uuid}/role` | Update user role | Body: JSON with role_uuid |
|
| PATCH | /auth/api/admin/users/{uuid}/role | Update user role | 200/401/403 |
|
||||||
| PATCH | `/auth/api/admin/users/{uuid}/info` | Update user info | Body: JSON with display_name |
|
| PATCH | /auth/api/admin/users/{uuid}/info | Update user info | 200/401/403 |
|
||||||
| GET | `/auth/api/admin/users/{uuid}` | Get user details | |
|
| GET | /auth/api/admin/users/{uuid} | Get user details | 200/401/403 |
|
||||||
| DELETE | `/auth/api/admin/users/{uuid}` | Delete user | |
|
| DELETE | /auth/api/admin/users/{uuid} | Delete user | 200/401/403 |
|
||||||
| POST | `/auth/api/admin/users/{uuid}/create-link` | Create device add link | |
|
| POST | /auth/api/admin/users/{uuid}/create-link | Create device add link | 200/401/403 |
|
||||||
| DELETE | `/auth/api/admin/users/{uuid}/credentials/{uuid}` | Delete user credential | |
|
| DELETE | /auth/api/admin/users/{uuid}/credentials/{uuid} | Delete user credential | 200/401/403 |
|
||||||
| DELETE | `/auth/api/admin/users/{uuid}/sessions/{key}` | Delete user session | |
|
| DELETE | /auth/api/admin/users/{uuid}/sessions/{key} | Delete user session | 200/401/403 |
|
||||||
| POST | `/auth/api/admin/oidc-clients/` | Create OIDC client | Body: JSON with client_name, redirect_uris |
|
| POST | /auth/api/admin/oidc-clients/ | Create OIDC client | 200/401/403 |
|
||||||
| PATCH | `/auth/api/admin/oidc-clients/{uuid}` | Update OIDC client | Body: JSON with client_name, redirect_uris |
|
| PATCH | /auth/api/admin/oidc-clients/{uuid} | Update OIDC client | 200/401/403 |
|
||||||
| PATCH | `/auth/api/admin/oidc-clients/{uuid}/reset-secret` | Reset client secret | |
|
| PATCH | /auth/api/admin/oidc-clients/{uuid}/reset-secret | Reset client secret | 200/401/403 |
|
||||||
| DELETE | `/auth/api/admin/oidc-clients/{uuid}` | Delete OIDC client | |
|
| DELETE | /auth/api/admin/oidc-clients/{uuid} | Delete OIDC client | 200/401/403 |
|
||||||
| GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins |
|
| GET | /auth/api/admin/server-config/ | Get server config | 200/401/403 |
|
||||||
| PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins |
|
| PATCH | /auth/api/admin/server-config/ | Update server config | 200/401/403 |
|
||||||
|
|
||||||
### WebSockets: `/auth/ws/*`
|
### WebSockets: /auth/ws/*
|
||||||
|
|
||||||
| Path | Used for | Notes |
|
| Path | Used for | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `WS /auth/ws/authenticate` | Passkey authentication | Returns a session token |
|
| WS /auth/ws/authenticate | Passkey authentication | Returns a session token |
|
||||||
| `WS /auth/ws/register` | Register a new credential | Adding another passkey to current user or via reset token |
|
| WS /auth/ws/register | Register a new credential | Adding another passkey to current user or via reset token |
|
||||||
| `WS /auth/ws/remote-auth/request` | Start a cross-device login/registration request | Used from unauthenticated client |
|
| WS /auth/ws/remote-auth/request | Start a cross-device login/registration request | Used from unauthenticated client |
|
||||||
| `WS /auth/ws/remote-auth/permit` | Approve/deny a pairing code | Used to accept the request, if same words are entered |
|
| WS /auth/ws/remote-auth/permit | Approve/deny a pairing code | Used to accept the request, if same words are entered |
|
||||||
|
|
||||||
These are for internal use only, but are documented here because they are the core piece in all passkey operations.
|
These are for internal use only, but are documented here because they are the core piece in all passkey operations.
|
||||||
|
|
||||||
### Auth host mode (`--auth-host`)
|
### Auth host mode (--auth-host)
|
||||||
|
|
||||||
#### On the auth host:
|
#### On the auth host:
|
||||||
- The Web UI is served at site root (e.g. admin UI at `/admin/`), and the `/auth/...` equivalents (e.g. `/auth/admin/`) redirect to the root paths.
|
- The Web UI is served at site root instead of /auth/* (that redirects to root paths)
|
||||||
- All of the API stays under `/auth/api/*`
|
- All of the API stays under /auth/api/*
|
||||||
- Auth WebSockets remain at `/auth/ws/*` but take connections from other hosts to issue sessions for each of those.
|
- Auth WebSockets remain at /auth/ws/* but take connections from other hosts to issue sessions for each of those.
|
||||||
|
|
||||||
#### On non-auth hosts:
|
#### On non-auth hosts:
|
||||||
- `/auth/` shows only minimal profile and allows logging out of the current site
|
- /auth/ shows only minimal profile and allows logging out of the current site, link to full profile on auth host
|
||||||
- `/auth/api/*` is served normally.
|
- /auth/api/* is served normally.
|
||||||
- `/auth/api/user/*`, `/auth/api/admin/*`, and `/auth/ws/*` don't exist.
|
- /auth/api/user/*, /auth/api/admin/*, and /auth/ws/* don't exist.
|
||||||
|
|
||||||
|
The WebSocket connections are directed to auth host, and must have an allowed origin corresponding to the host where the user is logging in, that the session is tied with.
|
||||||
|
|||||||
+12
-10
@@ -1,16 +1,18 @@
|
|||||||
# Paskia Trusted Headers for Backend Apps
|
# Paskia Trusted Headers for Backend Apps
|
||||||
|
|
||||||
|
[Proxy guides](proxy/index.md) · [`/auth/api/forward`](api/forward.md)
|
||||||
|
|
||||||
| HTTP Header | Meaning | Example |
|
| HTTP Header | Meaning | Example |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Remote-User` | Authenticated user UUID | **01c03276-b8f0-**… (string) |
|
| Remote-User | Authenticated user UUID | **01c03276-b8f0-**… (string) |
|
||||||
| `Remote-Name` | User display name | **John Doe** |
|
| Remote-Name | User display name | **John Doe** |
|
||||||
| `Remote-Org` | Organization UUID | Identifier for user's org (string) |
|
| Remote-Org | Organization UUID | Identifier for user's org (string) |
|
||||||
| `Remote-Org-Name` | Organization display name | **The Company Ltd.** |
|
| Remote-Org-Name | Organization display name | **The Company Ltd.** |
|
||||||
| `Remote-Role` | Role UUID | Identifier for user's role (string) |
|
| Remote-Role | Role UUID | Identifier for user's role (string) |
|
||||||
| `Remote-Role-Name` | Role display name | **Employee** |
|
| Remote-Role-Name | Role display name | **Employee** |
|
||||||
| `Remote-Groups` | Permissions the user has, comma separated | **auth:admin,yourapp:reports** |
|
| Remote-Groups | Permissions the user has, comma separated | **auth:admin,yourapp:reports** |
|
||||||
| `Remote-Session-Expires` | Session expiry timestamp (ISO 8601 UTC) | **2030-12-31T23:59:59Z** |
|
| Remote-Session-Expires | Session expiry timestamp (ISO 8601 UTC) | **2030-12-31T23:59:59Z** |
|
||||||
| `Remote-Credential` | Credential UUID | Identifier for the sign-in passkey (string) |
|
| Remote-Credential | Credential UUID | Identifier for the sign-in passkey (string) |
|
||||||
|
|
||||||
Similar headers are also used by other authentication systems like [Authelia](https://www.authelia.com/integration/trusted-header-sso/introduction/) to signal the backend application information about the signed in user.
|
Similar headers are also used by other authentication systems like [Authelia](https://www.authelia.com/integration/trusted-header-sso/introduction/) to signal the backend application information about the signed in user.
|
||||||
|
|
||||||
@@ -18,6 +20,6 @@ When a request is allowed, the auth service adds these headers by the forward-au
|
|||||||
|
|
||||||
Only the UUID values should be used for identification needs, because they never change, even when things are renamed (display names change), and are never reused (created on authentication server). They are UUIDv7 so you can also extract the creation timestamp from them.
|
Only the UUID values should be used for identification needs, because they never change, even when things are renamed (display names change), and are never reused (created on authentication server). They are UUIDv7 so you can also extract the creation timestamp from them.
|
||||||
|
|
||||||
Any `Remote-*` headers from clients are stripped by our [Caddy configuration](Caddy.md) to avoid dealing with any fake headers.
|
Any `Remote-*` headers from clients are stripped by the proxy configuration (see our [Caddy configuration](proxy/caddy.md) and [Forward-Auth Proxy Guides](proxy/index.md)) to avoid dealing with any fake headers.
|
||||||
|
|
||||||
Note: the headers are intended primarily for the backend, while either frontend or backend (passing the session cookie) can request `/auth/api/user-info` for more complete information, and that is the recommended way to do it in the frontend. See [integration](Integration.md) for more.
|
Note: the headers are intended primarily for the backend, while either frontend or backend (passing the session cookie) can request `/auth/api/user-info` for more complete information, and that is the recommended way to do it in the frontend. See [integration](Integration.md) for more.
|
||||||
|
|||||||
+17
-38
@@ -1,6 +1,8 @@
|
|||||||
# Integrating Paskia with your App
|
# Integrating Paskia with your App
|
||||||
|
|
||||||
This guide covers frontend and backend integration with Paskia. For Caddy forward-auth setup, see [Caddy configuration](Caddy.md).
|
[API overview](API.md) · [Proxy guides](proxy/index.md)
|
||||||
|
|
||||||
|
This guide covers frontend and backend integration with Paskia. For forward-auth setup, see the [Forward-Auth Proxy Guides](proxy/index.md); Caddy users can also start from the dedicated [Caddy configuration](proxy/caddy.md).
|
||||||
|
|
||||||
## Frontend Integration
|
## Frontend Integration
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ validator.start() // start polling (pauses on idle)
|
|||||||
validator.stop() // stop polling
|
validator.stop() // stop polling
|
||||||
```
|
```
|
||||||
|
|
||||||
The validator calls `/auth/api/validate` periodically to:
|
The validator calls `/auth/api/validate` (see below) periodically to:
|
||||||
- Renew the session cookie (24h lifetime)
|
- Renew the session cookie (24h lifetime)
|
||||||
- Detect if the user logged out or switched accounts
|
- Detect if the user logged out or switched accounts
|
||||||
- Pause polling when the page is idle, allowing sessions to expire when not used
|
- Pause polling when the page is idle, allowing sessions to expire when not used
|
||||||
@@ -101,7 +103,7 @@ Or link to the built-in profile page: `/auth/`
|
|||||||
|
|
||||||
### Using Forward-Auth Headers
|
### 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.
|
When using forward-auth, your backend receives `Remote-*` headers on authenticated requests. See [Headers](Headers.md) for the full list and [Forward-Auth Proxy Guides](proxy/index.md) for proxy configuration.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Example: Python/FastAPI
|
# Example: Python/FastAPI
|
||||||
@@ -115,47 +117,24 @@ def get_data(request: Request):
|
|||||||
|
|
||||||
### Direct Validation from Backend
|
### Direct Validation from Backend
|
||||||
|
|
||||||
Your backend can validate sessions directly by calling Paskia's validate endpoint:
|
This is useful for:
|
||||||
|
- Apps/APIs not behind proxy Forward-Auth protection
|
||||||
|
- Background jobs that need to verify a stored session
|
||||||
|
- Check extra permissions, get user context or renew session
|
||||||
|
|
||||||
|
Your backend can validate sessions directly by calling Paskia's validate endpoint [`/auth/api/validate`](api/validate.md). It generally expects client headers proxied as is, while on the URL you can specify exact requirements.
|
||||||
|
|
||||||
|
Usually it is sufficient to simply forward the headers the client sent, assuming your proxy already preserved `Host` and set `X-Forwarded-For` (otherwise set them here with original host and IP). `User-Agent` should also be forwarded if available, omitted if not: do not let your backend HTTP client add its own header.
|
||||||
|
|
||||||
|
Be sure to REMOVE connection hop-by-hop headers (these will break WebSockets among other things):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import httpx
|
"Connection", "Keep-Alive", "Proxy-Connection", "TE", "Transfer-Encoding", "Upgrade"
|
||||||
|
|
||||||
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
|
## 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.
|
Your app server needs to proxy `/auth/` paths to Paskia. This can be done by your application but is much easier done by a reverse proxy. The [Forward-Auth Proxy Guides](proxy/index.md) cover Caddy, Nginx, Traefik, Apache APISIX, Envoy and HAProxy.
|
||||||
|
|
||||||
### Caddy
|
### Caddy
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# GET /auth/api/forward
|
||||||
|
|
||||||
|
[API overview](../API.md) · [Proxy guides](../proxy/index.md)
|
||||||
|
|
||||||
|
Forward-auth validation for reverse proxies. The proxy calls this endpoint for every incoming request; Paskia validates the session and either authorizes the request by returning 204 with `Remote-*` headers, or rejects it with 401/403 error responses with an HTML login page if `text/html` was requested (i.e. it's a browser viewing the page), or otherwise JSON with details on the error and a URL to initiate API authentication flow.
|
||||||
|
|
||||||
|
The proxy server follows the 204 response with the original request to protected service, adding those remote headers to original user request, sent to the service. Any error response is sent directly back to client, never connecting to the protected service.
|
||||||
|
|
||||||
|
See [Forward-Auth Proxy Guides](../proxy/index.md) for Caddy, Nginx, Traefik, Apache APISIX, Envoy and HAProxy configuration examples. Caddy users can also start from the dedicated [Caddy configuration guide](../proxy/caddy.md).
|
||||||
|
|
||||||
|
|
||||||
|
## Query parameters
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| perm | Required permissions. See the [perm argument](perm.md). |
|
||||||
|
| max_age | Require recent passkey use. See the [max_age argument](max-age.md). |
|
||||||
|
|
||||||
|
## Request headers
|
||||||
|
|
||||||
|
| Header | Expected value / note | How Paskia uses it |
|
||||||
|
|---|---|---|
|
||||||
|
| Host | Forwarded directly from the client | Verifying the session's bound host |
|
||||||
|
| Cookie | Forwarded directly or just cookie `__Host-paskia` | Session ID |
|
||||||
|
| X-Forwarded-Method | The HTTP method, e.g. POST | Logging of original request |
|
||||||
|
| X-Forwarded-Uri | Request path and query, e.g. /reports?foo=bar | Logging of original request |
|
||||||
|
| Accept | text/html or anything else | Determines whether failures return an HTML page or JSON |
|
||||||
|
|
||||||
|
Connection hop-by-hop headers (Connection, Upgrade, Transfer-Encoding, etc.) must not be forwarded.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
### Success (204)
|
||||||
|
|
||||||
|
No response body. Only [Remote headers](../Headers.md) are set on the response and your proxy should forward them to the backend request. The headers, not a response body, are the whole point of this endpoint: they are how the authenticated identity reaches the protected service, which can trust them because it is only reachable through the proxy.
|
||||||
|
|
||||||
|
### Failure (400 / 401 / 403)
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| 400 | Malformed perm argument (see [perm](perm.md#syntax-errors)). The error detail names `/auth/api/forward` as the origin and never echoes query arguments. |
|
||||||
|
| 401 | Session missing or expired, or max_age not satisfied — the user needs to (re)authenticate. |
|
||||||
|
| 403 | Requested permissions are missing — the forbidden flow allows signing in with another account. |
|
||||||
|
|
||||||
|
Failure responses come in two flavors, chosen by the Accept header, because the audience differs:
|
||||||
|
|
||||||
|
- A browser asking for a page (Accept includes text/html) gets a full authentication page it can show directly — the proxy simply passes the response through and the user can sign in without any application involvement.
|
||||||
|
- Any other request (fetch, img, ...) gets JSON intended for programmatic handling. Besides the error detail, it carries an auth section whose iframe URL points to a ready-made authentication dialog your frontend can embed, so the user can sign in without leaving your app:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Additional authentication required",
|
||||||
|
"auth": {
|
||||||
|
"mode": "reauth",
|
||||||
|
"iframe": "/auth/restricted/iframe#mode=reauth&theme=dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The mode field:
|
||||||
|
- login: no valid session, need to sign in
|
||||||
|
- reauth: additional authentication required (using same passkey)
|
||||||
|
- forbidden: lacking required permissions
|
||||||
|
|
||||||
|
Extra metadata such as the user's theme override may appear as additional fields and iframe fragment parameters.
|
||||||
|
|
||||||
|
Note: we provide a JavaScript package [paskia](https://www.npmjs.com/package/paskia) with helpers for the embedding, fetch 401/403 handling, session renewals and more.
|
||||||
|
|
||||||
|
## Session renewal
|
||||||
|
|
||||||
|
The forward endpoint **does not renew** the session because in the forward-auth mechanism it could not send the client a renewed session cookie. It only validates the current cookie and returns the trusted headers. Use [/auth/api/validate](validate.md) when you need to refresh the session lifetime. Otherwise the user will have to sign in again every 24h even if they are actively using the service.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# The max_age argument
|
||||||
|
|
||||||
|
[API overview](../API.md) · [`/auth/api/validate`](validate.md) · [`/auth/api/forward`](../forward.md)
|
||||||
|
|
||||||
|
The max_age argument is used by endpoints that validate sessions to require a recent passkey use. It is supported by:
|
||||||
|
|
||||||
|
- [POST /auth/api/validate](validate.md)
|
||||||
|
- [GET /auth/api/forward](forward.md)
|
||||||
|
|
||||||
|
## What it checks
|
||||||
|
|
||||||
|
max_age limits how long ago the user last authenticated with their passkey. It is intended for high-risk actions where you want to be sure the user recently proved possession of their credential, not just that they still have a valid session cookie.
|
||||||
|
|
||||||
|
The check compares the elapsed time since the credential was last used against the given limit:
|
||||||
|
|
||||||
|
- If the credential has a last_used timestamp, that time is used.
|
||||||
|
- Otherwise, the session's validated timestamp is used as a fallback.
|
||||||
|
|
||||||
|
If the authentication is older than max_age, the endpoint returns 401 with mode reauth, prompting the user to re-authenticate.
|
||||||
|
|
||||||
|
## Time units
|
||||||
|
|
||||||
|
max_age accepts a number followed by one of these units:
|
||||||
|
|
||||||
|
| Unit | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| s | seconds |
|
||||||
|
| m / min | minutes |
|
||||||
|
| h | hours |
|
||||||
|
| d | days |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?max_age=30s
|
||||||
|
?max_age=5m
|
||||||
|
?max_age=5min
|
||||||
|
?max_age=1h
|
||||||
|
?max_age=1d
|
||||||
|
```
|
||||||
|
|
||||||
|
An invalid format is logged as a warning but does not cause the request to fail; the requirement is simply ignored in that case.
|
||||||
|
|
||||||
|
## Important notes
|
||||||
|
|
||||||
|
- Session renewal by /auth/api/validate does **not** count as fresh authentication. The check is based on the credential's last_used time, not on how recently the session cookie was renewed.
|
||||||
|
- max_age is independent of perm. You can use either or both at the same time.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# The perm argument
|
||||||
|
|
||||||
|
[API overview](../API.md) · [`/auth/api/validate`](validate.md) · [`/auth/api/forward`](../forward.md)
|
||||||
|
|
||||||
|
The perm argument is used by endpoints that validate sessions to require one or more permission scopes. It is supported by:
|
||||||
|
|
||||||
|
- [POST /auth/api/validate](validate.md)
|
||||||
|
- [GET /auth/api/forward](forward.md)
|
||||||
|
|
||||||
|
## Passing the argument
|
||||||
|
|
||||||
|
Repeat the query parameter for each required scope:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:read&perm=myapp:write
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also pass multiple scopes in one parameter by separating them with whitespace (a literal space, i.e. `+` or `%20` in the query string):
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:read%20myapp:write
|
||||||
|
```
|
||||||
|
|
||||||
|
Both forms produce the same result.
|
||||||
|
|
||||||
|
## Semantics
|
||||||
|
|
||||||
|
The perm argument uses **AND** semantics between groups: **every** listed scope group must be satisfied by the effective permissions for the request to succeed. If any required group is not satisfied, the endpoint returns 403.
|
||||||
|
|
||||||
|
Within a single group, use `|` to list alternatives with **OR** semantics — the group is satisfied when **any one** of the alternatives is present:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:read|myapp:write+myapp:login
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires `myapp:login` **and** (`myapp:read` **or** `myapp:write`). No whitespace is allowed around the `|` operator.
|
||||||
|
|
||||||
|
## Syntax errors
|
||||||
|
|
||||||
|
Parsing is strict: anything out of spec is rejected with **400 Bad Request** rather than guessed at. This includes:
|
||||||
|
|
||||||
|
- Empty values or alternatives (`?perm=`, `?perm=a||b`, `?perm=|a`, `?perm=a|`)
|
||||||
|
- Whitespace around `|` (`?perm=a+|+b`)
|
||||||
|
- Characters not allowed in scopes other than the operators (space and `|`); scopes match `^[A-Za-z0-9:._~/-]+$` plus the `*` wildcard. In particular a literal `+` in the decoded value (from a `%2B` in the query string) is rejected — use `+` or `%20` to encode a space, never `%2B`.
|
||||||
|
|
||||||
|
Extra spaces between groups (leading, trailing, or repeated) are tolerated, since they can easily result from URL formatting and carry no ambiguity — they only ever add required permissions, never remove them. The `|` operator is parsed strictly: `?perm=foo&perm=|bar` is an error, never a way to make `foo` optional.
|
||||||
|
|
||||||
|
## Wildcards
|
||||||
|
|
||||||
|
Wildcards work like filenames, with `:` and `/` acting as path separators:
|
||||||
|
|
||||||
|
- `*` matches any sequence of characters **within a single segment** (it never crosses a `:` or `/`)
|
||||||
|
- `**` matches any sequence of characters, **across separators**
|
||||||
|
- Part of a segment can be wildcarded, with required text on either or both sides
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:*
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches myapp:read and myapp:write, but **not** myapp:read:all — use `myapp:**` for that. Partial wildcards like `myapp:re*` or `myapp:r*d` match myapp:read. The same applies to path-based scopes: `myapp:path:/api/*` matches myapp:path:/api/clients but not myapp:path:/api/v2/clients — use `myapp:path:/api/**` to span path segments.
|
||||||
|
|
||||||
|
## Effective permissions
|
||||||
|
|
||||||
|
The permissions available to a session are determined as follows:
|
||||||
|
|
||||||
|
1. **Role permissions** — the role assigned to the user contains a set of permission UUIDs.
|
||||||
|
2. **Org grantable permissions** — only permissions that the user's organization is allowed to grant are effective.
|
||||||
|
3. **Domain filtering** — a permission can be restricted to a specific domain via its domain field. If the request's Host header does not match that domain, the permission is excluded.
|
||||||
|
|
||||||
|
The result is the set of effective permission scopes used for the perm check. Domain-restricted permissions let you grant a scope only for a specific site or subdomain without making it global.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Require a single permission:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:login
|
||||||
|
```
|
||||||
|
|
||||||
|
Require two permissions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:login&perm=myapp:api
|
||||||
|
```
|
||||||
|
|
||||||
|
Require any scope under myapp:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:*
|
||||||
|
```
|
||||||
|
|
||||||
|
Require myapp:login and either myapp:read or myapp:write:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?perm=myapp:login&perm=myapp:read|myapp:write
|
||||||
|
```
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# POST /auth/api/validate
|
||||||
|
|
||||||
|
[API overview](../API.md) · [`/auth/api/forward`](../forward.md) · [max_age](max-age.md) · [perm](perm.md)
|
||||||
|
|
||||||
|
Validate a session and renew its lifetime when needed. This endpoint is normally called by the browser/session validator, but backends may also call it directly when not behind a forward-auth proxy.
|
||||||
|
|
||||||
|
See also the [API overview](../API.md) and the [integration guide](../Integration.md).
|
||||||
|
|
||||||
|
## Query parameters
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| perm | Required permissions. See the [perm argument](perm.md). |
|
||||||
|
| max_age | Require recent passkey use. See the [max_age argument](max-age.md). |
|
||||||
|
|
||||||
|
## Request headers
|
||||||
|
|
||||||
|
| Header | Expected value / note | How Paskia uses it |
|
||||||
|
|---|---|---|
|
||||||
|
| Host | Forwarded directly from the client | Verifying the session's bound host |
|
||||||
|
| Cookie | Forwarded directly or just cookie `__Host-paskia` | Session ID |
|
||||||
|
| X-Forwarded-For | Real client IP | Recorded in session data instead of the backend/proxy IP; requires FORWARDED_ALLOW_IPS to trust the immediate peer |
|
||||||
|
| User-Agent | Forward the original client UA if available; do not let your backend client add its own default | Recorded in session data only when the header is present; omitting it preserves the existing value |
|
||||||
|
|
||||||
|
See [integration documentation for backend validate requests](../Integration.md) for more detailed instructions, in particular for forwarding of client-provided headers.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
The endpoint always responds with JSON.
|
||||||
|
|
||||||
|
### Success (200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"renewed": false,
|
||||||
|
"ctx": {
|
||||||
|
"user": {
|
||||||
|
"uuid": "...",
|
||||||
|
"display_name": "John Smith",
|
||||||
|
"theme": "dark"
|
||||||
|
},
|
||||||
|
"org": {
|
||||||
|
"uuid": "...",
|
||||||
|
"display_name": "The Company Ltd."
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"uuid": "...",
|
||||||
|
"display_name": "Employee"
|
||||||
|
},
|
||||||
|
"permissions": ["auth:admin", "myapp:login"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the response includes a Set-Cookie header, the session has been renewed (renewed is true) and you should forward that cookie to the client so the browser updates its expiry. Not forwarding it means the session lifetime is not extended, so the user may need to re-authenticate sooner. Renewals are throttled, so frequent calls usually return renewed: false with no Set-Cookie.
|
||||||
|
|
||||||
|
### Failure (400 / 401 / 403)
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| 400 | Malformed perm argument (see [perm](perm.md#syntax-errors)). |
|
||||||
|
| 401 | Session missing, expired, or max_age not satisfied. The response body includes auth metadata for the login/reauth iframe. |
|
||||||
|
| 403 | Session is valid but one or more requested permissions are missing. |
|
||||||
|
|
||||||
|
A failure response never contains a refreshed Set-Cookie.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Apache APISIX Forward-Auth
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
This guide uses the Apache APISIX [`forward-auth`](https://apisix.apache.org/docs/apisix/plugins/forward-auth/) plugin to ask Paskia whether each request is allowed.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
APISIX adds the standard `X-Forwarded-*` headers automatically, but we still tell the plugin to forward `Host`, `Cookie`, and `Accept` from the client request. On a `204` response from Paskia we copy the `Remote-*` headers to the backend request.
|
||||||
|
|
||||||
|
## Admin API example
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Route that proxies /auth/ to Paskia without forward-auth.
|
||||||
|
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
|
||||||
|
-H "X-API-KEY: ${admin_key}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"id": "paskia-auth-ui",
|
||||||
|
"uri": "/auth/*",
|
||||||
|
"upstream": {
|
||||||
|
"nodes": { "localhost:4401": 1 },
|
||||||
|
"type": "roundrobin"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Protected route that uses Paskia forward-auth.
|
||||||
|
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
|
||||||
|
-H "X-API-KEY: ${admin_key}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"id": "app-protected",
|
||||||
|
"uri": "/*",
|
||||||
|
"priority": 10,
|
||||||
|
"plugins": {
|
||||||
|
"forward-auth": {
|
||||||
|
"uri": "http://localhost:4401/auth/api/forward?perm=myapp:login",
|
||||||
|
"request_headers": [
|
||||||
|
"Host",
|
||||||
|
"Cookie",
|
||||||
|
"Accept",
|
||||||
|
"X-Forwarded-Method",
|
||||||
|
"X-Forwarded-Uri",
|
||||||
|
"X-Forwarded-Host",
|
||||||
|
"X-Forwarded-Proto",
|
||||||
|
"X-Forwarded-For"
|
||||||
|
],
|
||||||
|
"upstream_headers": [
|
||||||
|
"Remote-User",
|
||||||
|
"Remote-Name",
|
||||||
|
"Remote-Groups",
|
||||||
|
"Remote-Org",
|
||||||
|
"Remote-Org-Name",
|
||||||
|
"Remote-Role",
|
||||||
|
"Remote-Role-Name",
|
||||||
|
"Remote-Session-Expires",
|
||||||
|
"Remote-Credential"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upstream": {
|
||||||
|
"nodes": { "localhost:3000": 1 },
|
||||||
|
"type": "roundrobin"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `paskia-auth-ui` route has a higher priority (`priority` defaults to the same value for both routes; you can also rely on the more specific `/auth/*` URI matching first). Because it does not use the `forward-auth` plugin, users can reach the login/profile pages without already being authenticated.
|
||||||
|
|
||||||
|
## ADC / declarative example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
- name: paskia-auth-ui
|
||||||
|
routes:
|
||||||
|
- name: auth-route
|
||||||
|
uris:
|
||||||
|
- /auth/*
|
||||||
|
upstream:
|
||||||
|
type: roundrobin
|
||||||
|
nodes:
|
||||||
|
- host: localhost
|
||||||
|
port: 4401
|
||||||
|
weight: 1
|
||||||
|
|
||||||
|
- name: app-protected
|
||||||
|
routes:
|
||||||
|
- name: app-route
|
||||||
|
uris:
|
||||||
|
- /*
|
||||||
|
plugins:
|
||||||
|
forward-auth:
|
||||||
|
uri: http://localhost:4401/auth/api/forward?perm=myapp:login
|
||||||
|
request_headers:
|
||||||
|
- Host
|
||||||
|
- Cookie
|
||||||
|
- Accept
|
||||||
|
- X-Forwarded-Method
|
||||||
|
- X-Forwarded-Uri
|
||||||
|
- X-Forwarded-Host
|
||||||
|
- X-Forwarded-Proto
|
||||||
|
- X-Forwarded-For
|
||||||
|
upstream_headers:
|
||||||
|
- Remote-User
|
||||||
|
- Remote-Name
|
||||||
|
- Remote-Groups
|
||||||
|
- Remote-Org
|
||||||
|
- Remote-Org-Name
|
||||||
|
- Remote-Role
|
||||||
|
- Remote-Role-Name
|
||||||
|
- Remote-Session-Expires
|
||||||
|
- Remote-Credential
|
||||||
|
upstream:
|
||||||
|
type: roundrobin
|
||||||
|
nodes:
|
||||||
|
- host: localhost
|
||||||
|
port: 3000
|
||||||
|
weight: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adc sync -f paskia.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## What APISIX sends to Paskia
|
||||||
|
|
||||||
|
APISIX automatically adds these headers to the auth request:
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `X-Forwarded-Method` | Original HTTP method |
|
||||||
|
| `X-Forwarded-Proto` | Request scheme (`http`/`https`) |
|
||||||
|
| `X-Forwarded-Host` | Original host |
|
||||||
|
| `X-Forwarded-Uri` | Original request URI |
|
||||||
|
| `X-Forwarded-For` | Client IP address |
|
||||||
|
|
||||||
|
We list them again in `request_headers` to make sure they are not accidentally filtered out when the list is explicit.
|
||||||
|
|
||||||
|
## Response headers
|
||||||
|
|
||||||
|
`upstream_headers` lists the `Remote-*` headers that APISIX copies from the auth response to the backend request. The plugin does not support a wildcard here, so each header must be named.
|
||||||
|
|
||||||
|
If you want Paskia's failure-response headers (such as `Content-Type` or `Set-Cookie`) to reach the client, list them in `client_headers`. For Paskia this is usually not needed; the response body already contains the JSON auth URL or the HTML login page.
|
||||||
|
|
||||||
|
## Adjusting requirements
|
||||||
|
|
||||||
|
Change the `uri` query string to require different permissions or recent authentication:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
uri: http://localhost:4401/auth/api/forward?perm=myapp:login
|
||||||
|
uri: http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min
|
||||||
|
uri: http://localhost:4401/auth/api/forward
|
||||||
|
```
|
||||||
|
|
||||||
|
The last form requires only authentication. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The auth request is `GET` by default. Since the `forward-auth` plugin does not forward the request body unless `request_method` is set to `POST`, the default `GET` is the right choice for Paskia.
|
||||||
|
- Hop-by-hop headers are handled by APISIX when it builds the auth request, so no extra configuration is needed for `Connection`/`Upgrade`.
|
||||||
|
- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (`--auth-host`), route `auth.example.com` to Paskia instead of `/auth/`.
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
# Paskia Caddy Configuration
|
# Paskia Caddy Configuration
|
||||||
|
|
||||||
[Caddy](https://caddyserver.com/) is a modern web server that makes setting up web services easy. We provide a few Caddy snippets that make the configuration even easier, although the `forward_auth` directive of Caddy can be used directly as well. Place the [auth folder](../caddy/auth) with the snippets `require` and `setup` where your config file is (e.g. `/etc/caddy/auth`)
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
[Caddy](https://caddyserver.com/) is a modern web server that makes setting up web services easy. We provide a few Caddy snippets that make the configuration even easier, although the `forward_auth` directive of Caddy can be used directly as well. Place the [auth folder](../../caddy/auth) with the snippets `require` and `setup` where your config file is (e.g. `/etc/caddy/auth`)
|
||||||
|
|
||||||
What these snippets do
|
What these snippets do
|
||||||
- `setup`: Mount the auth UI at `/auth/` proxying to `:4401`
|
- `setup`: Mount the auth UI at `/auth/` proxying to `:4401`
|
||||||
- `require`: Use `/auth/api/forward` for access control
|
- `require`: Use `/auth/api/forward` for access control
|
||||||
- Render a login page or a permission denied page if needed (without changing URL)
|
- Render a login page or a permission denied page if needed (without changing URL)
|
||||||
|
|
||||||
Your backend may not use authentication at all, or it can make use of the user information passed via `Remote-*` headers by the authentication system, see [trusted headers](Headers.md) for details.
|
Your backend may not use authentication at all, or it can make use of the user information passed via `Remote-*` headers by the authentication system, see [trusted headers](../Headers.md) for details.
|
||||||
|
|
||||||
We assume the normal unprotected **Caddyfile** for your site looks like this:
|
We assume the normal unprotected **Caddyfile** for your site looks like this:
|
||||||
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Envoy External Authorization (ext_authz)
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
This guide uses Envoy's [external authorization filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) (`ext_authz`) to ask Paskia whether each request is allowed.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Envoy's `ext_authz` HTTP filter calls an external HTTP service before forwarding a request to the upstream. The filter needs to know which headers from the original request to send to Paskia, and which headers from Paskia's response to add to the upstream request or to the client response.
|
||||||
|
|
||||||
|
A minimal static configuration looks like this:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
static_resources:
|
||||||
|
listeners:
|
||||||
|
- name: app_listener
|
||||||
|
address:
|
||||||
|
socket_address:
|
||||||
|
address: 0.0.0.0
|
||||||
|
port_value: 8080
|
||||||
|
filter_chains:
|
||||||
|
- filters:
|
||||||
|
- name: envoy.filters.network.http_connection_manager
|
||||||
|
typed_config:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
||||||
|
stat_prefix: ingress_http
|
||||||
|
use_remote_address: true
|
||||||
|
route_config:
|
||||||
|
name: local_route
|
||||||
|
virtual_hosts:
|
||||||
|
- name: app
|
||||||
|
domains: ["*"]
|
||||||
|
routes:
|
||||||
|
# Pass /auth/ straight to Paskia, bypassing ext_authz.
|
||||||
|
- match:
|
||||||
|
prefix: "/auth/"
|
||||||
|
route:
|
||||||
|
cluster: paskia
|
||||||
|
typed_per_filter_config:
|
||||||
|
envoy.filters.http.ext_authz:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
|
||||||
|
disabled: true
|
||||||
|
|
||||||
|
# Protect everything else.
|
||||||
|
- match:
|
||||||
|
prefix: "/"
|
||||||
|
route:
|
||||||
|
cluster: app_backend
|
||||||
|
http_filters:
|
||||||
|
- name: envoy.filters.http.ext_authz
|
||||||
|
typed_config:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
|
||||||
|
transport_api_version: v3
|
||||||
|
http_service:
|
||||||
|
server_uri:
|
||||||
|
uri: localhost:4401
|
||||||
|
cluster: paskia
|
||||||
|
timeout: 0.5s
|
||||||
|
# Send every auth check to /auth/api/forward with the
|
||||||
|
# required permission, regardless of the original path.
|
||||||
|
path_override: "/auth/api/forward?perm=myapp:login"
|
||||||
|
authorization_request:
|
||||||
|
allowed_headers:
|
||||||
|
patterns:
|
||||||
|
- exact: Host
|
||||||
|
- exact: Cookie
|
||||||
|
- exact: Accept
|
||||||
|
# Add the headers Paskia logs / expects.
|
||||||
|
headers_to_add:
|
||||||
|
- key: X-Forwarded-Method
|
||||||
|
value: "%REQ(:METHOD)%"
|
||||||
|
- key: X-Forwarded-Uri
|
||||||
|
value: "%REQ(:PATH)%"
|
||||||
|
- key: X-Forwarded-Proto
|
||||||
|
value: "%REQ(:SCHEME)%"
|
||||||
|
- key: X-Forwarded-For
|
||||||
|
value: "%REQ(X-Forwarded-For)%"
|
||||||
|
authorization_response:
|
||||||
|
# Forward every Remote-* header to the backend.
|
||||||
|
allowed_upstream_headers:
|
||||||
|
patterns:
|
||||||
|
- prefix: Remote-
|
||||||
|
# Forward the response Content-Type to the client on 401/403.
|
||||||
|
allowed_client_headers:
|
||||||
|
patterns:
|
||||||
|
- exact: Content-Type
|
||||||
|
failure_mode_allow: false
|
||||||
|
- name: envoy.filters.http.router
|
||||||
|
typed_config:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
||||||
|
|
||||||
|
clusters:
|
||||||
|
- name: app_backend
|
||||||
|
connect_timeout: 0.25s
|
||||||
|
type: logical_dns
|
||||||
|
lb_policy: round_robin
|
||||||
|
load_assignment:
|
||||||
|
cluster_name: app_backend
|
||||||
|
endpoints:
|
||||||
|
- lb_endpoints:
|
||||||
|
- endpoint:
|
||||||
|
address:
|
||||||
|
socket_address:
|
||||||
|
address: localhost
|
||||||
|
port_value: 3000
|
||||||
|
|
||||||
|
- name: paskia
|
||||||
|
connect_timeout: 0.25s
|
||||||
|
type: logical_dns
|
||||||
|
lb_policy: round_robin
|
||||||
|
load_assignment:
|
||||||
|
cluster_name: paskia
|
||||||
|
endpoints:
|
||||||
|
- lb_endpoints:
|
||||||
|
- endpoint:
|
||||||
|
address:
|
||||||
|
socket_address:
|
||||||
|
address: localhost
|
||||||
|
port_value: 4401
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important configuration details
|
||||||
|
|
||||||
|
- **`path_override`** — the auth request always goes to `/auth/api/forward?perm=myapp:login`, no matter which path the client requested. The original path is sent in `X-Forwarded-Uri` for Paskia to log.
|
||||||
|
- **`authorization_request.allowed_headers`** — Envoy only forwards the headers you explicitly allow. We allow `Host`, `Cookie`, and `Accept`. The `X-Forwarded-*` headers are added via `headers_to_add` so they are based on Envoy's view of the request, not spoofed client values.
|
||||||
|
- **`headers_to_add`** — Envoy supports substitution format strings such as `%REQ(:METHOD)%` and `%REQ(:PATH)%`. These set the headers Paskia uses for logging and host validation.
|
||||||
|
- **`authorization_response.allowed_upstream_headers`** — `prefix: Remote-` tells Envoy to copy every response header starting with `Remote-` to the upstream request. This also removes any client-supplied `Remote-*` headers, so the backend can trust them.
|
||||||
|
- **`authorization_response.allowed_client_headers`** — on a 401/403 response, Envoy forwards only the allowed response headers to the client. Paskia returns HTML or JSON with a `Content-Type` header, so we allow that. (Paskia does not set cookies on the forward-auth response.)
|
||||||
|
- **`typed_per_filter_config`** on the `/auth/` route disables `ext_authz` so users can reach the login/profile pages without already being authenticated.
|
||||||
|
|
||||||
|
## Per-route requirements
|
||||||
|
|
||||||
|
Different routes often need different permissions or `max_age` values. Use `typed_per_filter_config` on each route to override the `http_service.path_override`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
routes:
|
||||||
|
- match:
|
||||||
|
prefix: "/reports"
|
||||||
|
route:
|
||||||
|
cluster: app_backend
|
||||||
|
typed_per_filter_config:
|
||||||
|
envoy.filters.http.ext_authz:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
|
||||||
|
check_settings:
|
||||||
|
http_service:
|
||||||
|
path_override: "/auth/api/forward?perm=myapp:reports&max_age=5min"
|
||||||
|
|
||||||
|
- match:
|
||||||
|
prefix: "/"
|
||||||
|
route:
|
||||||
|
cluster: app_backend
|
||||||
|
typed_per_filter_config:
|
||||||
|
envoy.filters.http.ext_authz:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
|
||||||
|
check_settings:
|
||||||
|
http_service:
|
||||||
|
path_override: "/auth/api/forward?perm=myapp:login"
|
||||||
|
```
|
||||||
|
|
||||||
|
See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for query parameter syntax.
|
||||||
|
|
||||||
|
## WebSocket support for `/auth/`
|
||||||
|
|
||||||
|
If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Envoy's `ext_authz` filter does not send the request body to the auth server by default. For Paskia this is fine.
|
||||||
|
- If Paskia is running behind TLS, use `https://` in `server_uri.uri` and configure the cluster's transport socket.
|
||||||
|
- The `failure_mode_allow: false` setting means that if Paskia cannot be reached, Envoy will reject the request. In testing you may prefer `true`, but use `false` in production so a failed auth service cannot accidentally allow traffic.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# HAProxy Forward-Auth
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
HAProxy does not have a built-in forward-auth primitive, but the community [`haproxy-auth-request`](https://github.com/TimWolla/haproxy-auth-request) Lua script provides an `auth-intercept` action that works very similarly to Nginx's `auth_request`. It makes an internal HTTP request to Paskia and copies the response headers to the backend request.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- HAProxy 2.2 or newer (2.0+ may work but 2.2+ supports all features shown here).
|
||||||
|
- Compiled with `USE_LUA=1`.
|
||||||
|
- The [`haproxy-auth-request`](https://github.com/TimWolla/haproxy-auth-request) Lua script loaded.
|
||||||
|
- The [`haproxy-lua-http`](https://github.com/haproxytech/haproxy-lua-http) dependency in the Lua path.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
global
|
||||||
|
lua-load /usr/share/haproxy/auth-request.lua
|
||||||
|
|
||||||
|
defaults
|
||||||
|
mode http
|
||||||
|
timeout connect 5s
|
||||||
|
timeout client 30s
|
||||||
|
timeout server 30s
|
||||||
|
|
||||||
|
# Backend that runs the Paskia auth check.
|
||||||
|
backend paskia_auth
|
||||||
|
server paskia 127.0.0.1:4401
|
||||||
|
|
||||||
|
# Backend that runs the Paskia UI / WebSocket / API.
|
||||||
|
backend paskia_ui
|
||||||
|
server paskia 127.0.0.1:4401
|
||||||
|
|
||||||
|
# Your protected application.
|
||||||
|
backend app_backend
|
||||||
|
server app 127.0.0.1:3000
|
||||||
|
|
||||||
|
frontend app
|
||||||
|
bind *:80
|
||||||
|
|
||||||
|
# 1. Route /auth/ straight to Paskia, bypassing the auth check.
|
||||||
|
acl is_auth path_beg /auth/
|
||||||
|
use_backend paskia_ui if is_auth
|
||||||
|
|
||||||
|
# 2. Add the headers Paskia logs / expects. These are then copied to the
|
||||||
|
# auth subrequest by auth-intercept.
|
||||||
|
http-request set-header X-Forwarded-Method %[method]
|
||||||
|
http-request set-header X-Forwarded-Uri %[url]
|
||||||
|
|
||||||
|
# 3. Run the auth check. The parameters are:
|
||||||
|
# backend path method
|
||||||
|
# req-headers success-headers failure-headers
|
||||||
|
#
|
||||||
|
# - req-headers: headers copied from client to Paskia.
|
||||||
|
# - success-headers: headers copied from Paskia response to backend request.
|
||||||
|
# - failure-headers: headers copied from Paskia response to client response.
|
||||||
|
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:login GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
|
||||||
|
|
||||||
|
# 4. If the subrequest was not successful, deny the request.
|
||||||
|
http-request deny if ! { var(txn.auth_response_successful) -m bool }
|
||||||
|
|
||||||
|
default_backend app_backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the configuration does
|
||||||
|
|
||||||
|
1. **Route `/auth/` to Paskia** — users must be able to reach the login/profile UI without already being authenticated. HAProxy will proxy WebSocket upgrade headers automatically for this backend when the client requests them.
|
||||||
|
2. **Set `X-Forwarded-Method` and `X-Forwarded-Uri`** — HAProxy adds these headers to the incoming request so the Lua script can copy them to the auth subrequest. `%[method]` returns the HTTP method and `%[url]` returns the path and query string.
|
||||||
|
3. **`lua.auth-intercept`** — sends a `GET` request to `/auth/api/forward?perm=myapp:login` on the `paskia_auth` backend. It copies the listed request headers (including the dynamic ones we just set) to the auth subrequest.
|
||||||
|
4. **On success (`2xx`)** — copies every response header matching `Remote-*` from Paskia to the backend request. This overrides any client-supplied `Remote-*` headers, so the backend can trust them.
|
||||||
|
5. **On failure (`4xx`)** — copies all response headers (`*`) to the client response and uses Paskia's response body, so the browser gets the login HTML or the JSON auth URL.
|
||||||
|
6. **Deny if auth failed** — the final `http-request deny` rule is a safety net. In practice, `auth-intercept` with `*` as the failure-headers already terminates the transaction with Paskia's response.
|
||||||
|
|
||||||
|
## Backend definition for the auth subrequest
|
||||||
|
|
||||||
|
The `paskia_auth` backend can be the same physical server as `paskia_ui`, but using a separate backend is convenient because the Lua script will use the first available server in the backend. The auth subrequest is a plain HTTP request, so no special WebSocket options are needed here.
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
backend paskia_auth
|
||||||
|
server paskia 127.0.0.1:4401
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-route permissions
|
||||||
|
|
||||||
|
You can run different auth checks for different paths by using HAProxy ACLs. Place the more specific rules before the generic one:
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend app
|
||||||
|
bind *:80
|
||||||
|
|
||||||
|
acl is_auth path_beg /auth/
|
||||||
|
use_backend paskia_ui if is_auth
|
||||||
|
|
||||||
|
acl is_reports path_beg /reports
|
||||||
|
http-request set-header X-Forwarded-Method %[method] if is_reports
|
||||||
|
http-request set-header X-Forwarded-Uri %[url] if is_reports
|
||||||
|
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:reports&max_age=5min GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* * if is_reports
|
||||||
|
|
||||||
|
http-request set-header X-Forwarded-Method %[method]
|
||||||
|
http-request set-header X-Forwarded-Uri %[url]
|
||||||
|
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:login GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
|
||||||
|
|
||||||
|
http-request deny if ! { var(txn.auth_response_successful) -m bool }
|
||||||
|
default_backend app_backend
|
||||||
|
```
|
||||||
|
|
||||||
|
See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for query parameter syntax.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The Lua script strips the request body from the auth subrequest, so Paskia's `/auth/api/forward` will only see the headers.
|
||||||
|
- HAProxy variables are limited to alphanumeric characters, dots, and underscores, but the script already normalizes header names for you (e.g. `Remote-User` becomes `req.auth_response_header.remote_user`). The `Remote-*` glob pattern in the success-headers argument handles this automatically.
|
||||||
|
- The auth backend must be reachable without TLS. If you need TLS to Paskia, run a local TCP forwarder or use HAProxy's Lua HTTP support directly (not covered by this script).
|
||||||
|
- If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia backend and start Paskia with `--auth-host auth.example.com` instead of exposing `/auth/` on every site.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Forward-Auth Proxy Guides
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Trusted headers](../Headers.md)
|
||||||
|
|
||||||
|
These guides show how to protect a backend application with Paskia using the forward-auth (also called "external authentication") mechanism. The reverse proxy asks Paskia whether a request is allowed before forwarding it to the protected service.
|
||||||
|
|
||||||
|
For details about the endpoint the proxy calls, see [`/auth/api/forward`](../api/forward.md). For the headers your backend receives on successful requests, see [Trusted Headers](../Headers.md).
|
||||||
|
|
||||||
|
## Available guides
|
||||||
|
|
||||||
|
- [Caddy](caddy.md) — fully supported with ready-to-use snippets (`auth/setup` and `auth/require`).
|
||||||
|
- [Nginx](nginx.md) — using the `auth_request` module.
|
||||||
|
- [Traefik](traefik.md) — using the `ForwardAuth` middleware.
|
||||||
|
- [Apache APISIX](apisix.md) — using the `forward-auth` plugin.
|
||||||
|
- [Envoy](envoy.md) — using the `ext_authz` HTTP filter.
|
||||||
|
- [HAProxy](haproxy.md) — using a Lua auth request.
|
||||||
|
|
||||||
|
## Common requirements
|
||||||
|
|
||||||
|
No matter which proxy you use, the auth subrequest must:
|
||||||
|
|
||||||
|
1. Be sent to `GET /auth/api/forward` on the Paskia backend. By default Paskia listens on `localhost:4401`; set the `AUTH_UPSTREAM` environment variable in our Caddy snippets, or point your proxy at wherever Paskia is running.
|
||||||
|
2. Include the query parameters Paskia needs for access control:
|
||||||
|
- `perm` — required permission scope, repeatable (e.g. `perm=myapp:login`). See [perm argument](../api/perm.md).
|
||||||
|
- `max_age` — how recently the user must have authenticated (e.g. `max_age=5min`). See [max_age argument](../api/max-age.md).
|
||||||
|
3. Forward these request headers from the original client request:
|
||||||
|
- `Host` — the site the user is visiting.
|
||||||
|
- `Cookie` — the session cookie, normally `__Host-paskia`.
|
||||||
|
- `X-Forwarded-Method` — the original HTTP method (e.g. `GET`, `POST`).
|
||||||
|
- `X-Forwarded-Uri` — the original path and query string (e.g. `/reports?foo=bar`).
|
||||||
|
- `Accept` — decides whether a 401/403 response should be HTML (browser) or JSON (API/fetch).
|
||||||
|
4. Strip hop-by-hop headers (`Connection`, `Upgrade`, `Transfer-Encoding`, `Keep-Alive`, `Proxy-Connection`, `TE`) from the auth subrequest. The auth check is a plain HTTP request and must not carry WebSocket/body framing headers.
|
||||||
|
5. On a `204 No Content` response, copy the `Remote-*` response headers to the request that is forwarded to the protected backend. The headers are the whole point of the auth check.
|
||||||
|
6. On a 401/403 response, send Paskia's response back to the client without contacting the protected backend.
|
||||||
|
7. Also proxy the `/auth/` path prefix to Paskia so the login/profile UI, API endpoints, and WebSockets are reachable. Paskia's WebSocket endpoints need `Upgrade` and `Connection` headers passed through for that path.
|
||||||
|
|
||||||
|
## Backend usage
|
||||||
|
|
||||||
|
After the proxy forwards the request, your backend can read the trusted headers. For example, in Python/FastAPI:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user_id = request.headers.get("Remote-User")
|
||||||
|
org_id = request.headers.get("Remote-Org")
|
||||||
|
permissions = request.headers.get("Remote-Groups", "").split(",")
|
||||||
|
```
|
||||||
|
|
||||||
|
Only trust headers that come from the proxy; never trust `Remote-*` headers that arrive directly from the internet. Your proxy configuration should strip any client-supplied `Remote-*` headers before the auth check.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Nginx Forward-Auth
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
This guide uses the [Nginx `auth_request`](http://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to ask Paskia whether each request is allowed before proxying it to your backend.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name app.example.com;
|
||||||
|
|
||||||
|
# 1. Proxy /auth/ to Paskia (HTTP + WebSocket).
|
||||||
|
location /auth/ {
|
||||||
|
proxy_pass http://localhost:4401;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. All other paths are protected.
|
||||||
|
location / {
|
||||||
|
auth_request /auth-internal;
|
||||||
|
|
||||||
|
# 3. Capture the Remote-* headers from the auth response and
|
||||||
|
# pass them to the backend request.
|
||||||
|
auth_request_set $remote_user $upstream_http_remote_user;
|
||||||
|
auth_request_set $remote_name $upstream_http_remote_name;
|
||||||
|
auth_request_set $remote_groups $upstream_http_remote_groups;
|
||||||
|
auth_request_set $remote_org $upstream_http_remote_org;
|
||||||
|
auth_request_set $remote_org_name $upstream_http_remote_org_name;
|
||||||
|
auth_request_set $remote_role $upstream_http_remote_role;
|
||||||
|
auth_request_set $remote_role_name $upstream_http_remote_role_name;
|
||||||
|
auth_request_set $remote_session_exp $upstream_http_remote_session_expires;
|
||||||
|
auth_request_set $remote_credential $upstream_http_remote_credential;
|
||||||
|
|
||||||
|
proxy_set_header Remote-User $remote_user;
|
||||||
|
proxy_set_header Remote-Name $remote_name;
|
||||||
|
proxy_set_header Remote-Groups $remote_groups;
|
||||||
|
proxy_set_header Remote-Org $remote_org;
|
||||||
|
proxy_set_header Remote-Org-Name $remote_org_name;
|
||||||
|
proxy_set_header Remote-Role $remote_role;
|
||||||
|
proxy_set_header Remote-Role-Name $remote_role_name;
|
||||||
|
proxy_set_header Remote-Session-Expires $remote_session_exp;
|
||||||
|
proxy_set_header Remote-Credential $remote_credential;
|
||||||
|
|
||||||
|
# 4. The proxy_set_header lines above override any client-supplied
|
||||||
|
# Remote-* headers, so the backend receives only the values from
|
||||||
|
# Paskia's auth response.
|
||||||
|
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Internal endpoint used by auth_request.
|
||||||
|
location = /auth-internal {
|
||||||
|
internal;
|
||||||
|
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:login;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header X-Forwarded-Method $request_method;
|
||||||
|
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
proxy_set_header Accept $http_accept;
|
||||||
|
# Drop hop-by-hop headers that must not reach the auth subrequest.
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Upgrade "";
|
||||||
|
proxy_set_header Transfer-Encoding "";
|
||||||
|
proxy_set_header Keep-Alive "";
|
||||||
|
proxy_set_header Proxy-Connection "";
|
||||||
|
proxy_set_header TE "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket upgrade map.
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the configuration does
|
||||||
|
|
||||||
|
1. **`/auth/`** is proxied straight to Paskia. The `Upgrade` and `Connection` headers are passed through so WebSocket endpoints such as `/auth/ws/authenticate` work.
|
||||||
|
2. **`/`** is protected by `auth_request /auth-internal`. Nginx makes an internal subrequest to that location before proxying the original request to the app.
|
||||||
|
3. **`auth_request_set`** captures each `Remote-*` header from the Paskia response. Nginx does not have wildcard capture, so every header must be listed explicitly. The captured values are then attached to the backend request with `proxy_set_header`.
|
||||||
|
4. The `proxy_set_header` lines override any `Remote-*` headers the client might have sent, so the backend can trust the headers that come from Paskia.
|
||||||
|
5. **`/auth-internal`** is the actual forward-auth call. It must:
|
||||||
|
- point to `/auth/api/forward`,
|
||||||
|
- not forward the request body (`proxy_pass_request_body off;`),
|
||||||
|
- pass `Host`, `Cookie`, `Accept`, `X-Forwarded-Method`, and `X-Forwarded-Uri`,
|
||||||
|
- strip hop-by-hop headers.
|
||||||
|
|
||||||
|
## Adjusting requirements
|
||||||
|
|
||||||
|
Change the query string on the `proxy_pass` line inside `/auth-internal` to require different permissions or recent authentication:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:login;
|
||||||
|
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min;
|
||||||
|
proxy_pass http://localhost:4401/auth/api/forward?"";
|
||||||
|
```
|
||||||
|
|
||||||
|
The last form (`?""`) requires only authentication and no specific permission. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for details.
|
||||||
|
|
||||||
|
## Public paths
|
||||||
|
|
||||||
|
To leave some paths unprotected (for example `/.well-known/` or `/static/`), add `location` blocks before the protected `location /` block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /.well-known/ {
|
||||||
|
root /var/www;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
root /var/www;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Nginx `auth_request` always makes the auth subrequest with the same HTTP method as the original request, but the body is suppressed by the configuration above. Paskia uses the `X-Forwarded-Method` and `X-Forwarded-Uri` headers for logging.
|
||||||
|
- The `auth_request_set` variables are empty when the auth request fails, so on 401/403 the backend is never contacted; Nginx returns Paskia's response directly.
|
||||||
|
- For HTTPS, add `listen 443 ssl;` and your certificate configuration as usual.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Traefik ForwardAuth
|
||||||
|
|
||||||
|
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
|
||||||
|
|
||||||
|
This guide uses Traefik's [ForwardAuth middleware](https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/forwardauth/) to ask Paskia whether each request is allowed.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A typical dynamic (YAML) configuration looks like this:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
app:
|
||||||
|
rule: "Host(`app.example.com`)"
|
||||||
|
service: app-backend
|
||||||
|
middlewares:
|
||||||
|
- paskia-auth
|
||||||
|
|
||||||
|
# Route /auth/ straight to Paskia, bypassing the auth middleware.
|
||||||
|
auth:
|
||||||
|
rule: "Host(`app.example.com`) && PathPrefix(`/auth/`)"
|
||||||
|
service: paskia
|
||||||
|
middlewares: []
|
||||||
|
|
||||||
|
middlewares:
|
||||||
|
paskia-auth:
|
||||||
|
forwardAuth:
|
||||||
|
address: "http://localhost:4401/auth/api/forward?perm=myapp:login"
|
||||||
|
# Forward every Remote-* header from the auth response to the backend.
|
||||||
|
authResponseHeadersRegex: "^Remote-"
|
||||||
|
# Explicitly pass the headers Paskia needs. If left empty, all headers
|
||||||
|
# are forwarded; being explicit avoids accidentally leaking hop-by-hop
|
||||||
|
# headers to the auth server.
|
||||||
|
authRequestHeaders:
|
||||||
|
- Host
|
||||||
|
- Cookie
|
||||||
|
- Accept
|
||||||
|
- X-Forwarded-Method
|
||||||
|
- X-Forwarded-Uri
|
||||||
|
- X-Forwarded-Host
|
||||||
|
- X-Forwarded-Proto
|
||||||
|
- X-Forwarded-For
|
||||||
|
|
||||||
|
services:
|
||||||
|
app-backend:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://localhost:3000"
|
||||||
|
|
||||||
|
paskia:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://localhost:4401"
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Traefik sends automatically
|
||||||
|
|
||||||
|
Traefik's ForwardAuth middleware sends the auth request to the configured `address` and includes the following headers derived from the original request:
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `X-Forwarded-Method` | Original HTTP method |
|
||||||
|
| `X-Forwarded-Proto` | Original protocol (`http`/`https`) |
|
||||||
|
| `X-Forwarded-Host` | Original host |
|
||||||
|
| `X-Forwarded-Uri` | Original request URI (path and query) |
|
||||||
|
| `X-Forwarded-For` | Client IP address |
|
||||||
|
|
||||||
|
These are exactly the headers Paskia logs. You should still include them in `authRequestHeaders` if you set that list explicitly, to make sure they are not filtered out.
|
||||||
|
|
||||||
|
## Response headers
|
||||||
|
|
||||||
|
`authResponseHeadersRegex: "^Remote-"` tells Traefik to copy every response header starting with `Remote-` from Paskia's `204` response and add it to the request that is forwarded to your backend. It also strips any `Remote-*` headers that the client may have sent, so the backend can trust the values.
|
||||||
|
|
||||||
|
For stricter control, you can list the headers explicitly instead of using the regex:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
authResponseHeaders:
|
||||||
|
- Remote-User
|
||||||
|
- Remote-Name
|
||||||
|
- Remote-Groups
|
||||||
|
- Remote-Org
|
||||||
|
- Remote-Org-Name
|
||||||
|
- Remote-Role
|
||||||
|
- Remote-Role-Name
|
||||||
|
- Remote-Session-Expires
|
||||||
|
- Remote-Credential
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proxying `/auth/` to Paskia
|
||||||
|
|
||||||
|
The `/auth/` router above forwards all authentication UI, API, and WebSocket traffic to Paskia. Because this router does **not** use the `paskia-auth` middleware, users can reach the login page and profile UI without being authenticated first. Traefik handles WebSocket upgrades automatically when the client requests them.
|
||||||
|
|
||||||
|
If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and start Paskia with `--auth-host auth.example.com`.
|
||||||
|
|
||||||
|
## Adjusting requirements
|
||||||
|
|
||||||
|
Change the `address` query string to require different permissions or recent authentication:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
address: "http://localhost:4401/auth/api/forward?perm=myapp:login"
|
||||||
|
address: "http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min"
|
||||||
|
address: "http://localhost:4401/auth/api/forward"
|
||||||
|
```
|
||||||
|
|
||||||
|
The last form requires only authentication, no specific permission. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md).
|
||||||
|
|
||||||
|
## Docker labels example
|
||||||
|
|
||||||
|
When using Traefik with Docker Compose, you can define the middleware with labels:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
|
||||||
|
- "traefik.http.routers.myapp.middlewares=paskia-auth"
|
||||||
|
- "traefik.http.middlewares.paskia-auth.forwardauth.address=http://localhost:4401/auth/api/forward?perm=myapp:login"
|
||||||
|
- "traefik.http.middlewares.paskia-auth.forwardauth.authResponseHeadersRegex=^Remote-"
|
||||||
|
- "traefik.http.middlewares.paskia-auth.forwardauth.authRequestHeaders=Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- By default ForwardAuth sends a request without the original body. If you need to forward the body for logging/validation, set `forwardBody: true` and a sensible `maxBodySize`, but for Paskia this is not required.
|
||||||
|
- Paskia does not set cookies on the forward-auth response; it only returns `Remote-*` headers. Use the JavaScript helpers from the [paskia](https://www.npmjs.com/package/paskia) package for session renewal in the frontend.
|
||||||
|
- For HTTPS, use `https://` in the `address` and configure TLS options (`tls.insecureSkipVerify: true` only for testing).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execSync, 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, rmSync, writeFileSync } from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -58,6 +58,11 @@ export default async function globalSetup() {
|
|||||||
// Use a fresh database file for tests
|
// Use a fresh database file for tests
|
||||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||||
|
|
||||||
|
if (existsSync(testDbFile)) {
|
||||||
|
console.log(' Removing stale test database...')
|
||||||
|
rmSync(testDbFile, { force: true, recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
// Start the server using Node's spawn
|
// Start the server using Node's spawn
|
||||||
const serverProcess = spawn('uv', serverArgs, {
|
const serverProcess = spawn('uv', serverArgs, {
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default async function globalTeardown() {
|
|||||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
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, { force: true, recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Python coverage report if coverage was collected
|
// Generate Python coverage report if coverage was collected
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<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 } from 'paskia'
|
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||||
import { updateThemeFromSession } from '@/utils/theme'
|
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'
|
||||||
@@ -72,8 +72,8 @@ async function loadUserInfo() {
|
|||||||
// apiJson handles 401/403 with auth.iframe automatically:
|
// apiJson handles 401/403 with auth.iframe automatically:
|
||||||
// shows overlay iframe, waits for auth, retries the request.
|
// shows overlay iframe, waits for auth, retries the request.
|
||||||
const [validateData, userInfoData] = await Promise.all([
|
const [validateData, userInfoData] = await Promise.all([
|
||||||
apiJson('/auth/api/validate', { method: 'POST' }),
|
apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
|
||||||
apiJson('/auth/api/user-info', { method: 'GET' })
|
apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||||
])
|
])
|
||||||
store.userInfo = userInfoData
|
store.userInfo = userInfoData
|
||||||
store.ctx = validateData.ctx
|
store.ctx = validateData.ctx
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ 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, settings as paskiaSettings } from 'paskia'
|
||||||
import { updateThemeFromSession } from '@/utils/theme'
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
import { uuidv7 } from 'uuidv7'
|
import { uuidv7 } from 'uuidv7'
|
||||||
import { getDirection } from '@/utils/keynav'
|
import { getDirection } from '@/utils/keynav'
|
||||||
@@ -196,7 +196,7 @@ function orgUserCount(org) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||||
info.value = data
|
info.value = data
|
||||||
updateThemeFromSession(data.ctx)
|
updateThemeFromSession(data.ctx)
|
||||||
authenticated.value = true
|
authenticated.value = true
|
||||||
@@ -736,10 +736,7 @@ async function refreshUserDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onUserNameSaved() {
|
|
||||||
await refreshUserDetail()
|
|
||||||
authStore.showMessage('User renamed', 'success', 1500)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitDialog() {
|
async function submitDialog() {
|
||||||
if (!dialog.value.type || dialog.value.busy) return
|
if (!dialog.value.type || dialog.value.busy) return
|
||||||
@@ -796,7 +793,7 @@ async function submitDialog() {
|
|||||||
apiJson(`/auth/api/admin/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()
|
loadAdminData()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
||||||
@@ -824,7 +821,7 @@ async function submitDialog() {
|
|||||||
apiJson(`/auth/api/admin/users/${user.uuid}/info`, { 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()
|
refreshUserDetail()
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to update user name', 'error')
|
authStore.showMessage(e.message || 'Failed to update user name', 'error')
|
||||||
@@ -1003,9 +1000,7 @@ async function submitDialog() {
|
|||||||
:show-reg-modal="showRegModal"
|
:show-reg-modal="showRegModal"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
@generate-user-registration-link="generateUserRegistrationLink"
|
@generate-user-registration-link="generateUserRegistrationLink"
|
||||||
@go-overview="goOverview"
|
|
||||||
@open-org="openOrg"
|
@open-org="openOrg"
|
||||||
@on-user-name-saved="onUserNameSaved"
|
|
||||||
@refresh-user-detail="refreshUserDetail"
|
@refresh-user-detail="refreshUserDetail"
|
||||||
@edit-user-name="editUserName"
|
@edit-user-name="editUserName"
|
||||||
@close-reg-modal="showRegModal = false"
|
@close-reg-modal="showRegModal = false"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
|
import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
|
||||||
import { updateThemeFromSession } from '@/utils/theme'
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
const status = reactive({
|
const status = reactive({
|
||||||
@@ -164,7 +164,8 @@ async function exchangeCode(result) {
|
|||||||
}
|
}
|
||||||
return await apiJson('/auth/api/set-session', {
|
return await apiJson('/auth/api/set-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${result.exchange_code}` }
|
headers: { 'Authorization': `Bearer ${result.exchange_code}` },
|
||||||
|
timeout: paskiaSettings.auth_ms,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
|
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||||
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -396,8 +397,19 @@ defineExpose({ focusFirstElement })
|
|||||||
@keydown.enter="$emit('openUser', u)"
|
@keydown.enter="$emit('openUser', u)"
|
||||||
:title="u.uuid"
|
:title="u.uuid"
|
||||||
>
|
>
|
||||||
<span class="name">{{ u.display_name }}</span>
|
<ProfilePicture
|
||||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
|
class="user-chip-picture"
|
||||||
|
:src="u.avatar_url"
|
||||||
|
:title="u.display_name"
|
||||||
|
width="3.25rem"
|
||||||
|
height="100%"
|
||||||
|
radius="0"
|
||||||
|
fallback-size="1.3rem"
|
||||||
|
/>
|
||||||
|
<span class="user-chip-body">
|
||||||
|
<span class="name">{{ u.display_name }}</span>
|
||||||
|
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
@@ -418,7 +430,7 @@ defineExpose({ focusFirstElement })
|
|||||||
.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; }
|
||||||
.roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
.roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
||||||
.role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
.role-column { flex: 0 0 17em; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
||||||
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
.role-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); }
|
||||||
@@ -426,9 +438,12 @@ defineExpose({ focusFirstElement })
|
|||||||
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
||||||
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
||||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
|
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
|
||||||
.user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
.user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0; display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); align-items: stretch; gap: 0; cursor: grab; overflow: hidden; min-height: 3.25rem; }
|
||||||
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||||
.user-chip .meta { font-size: 0.7rem; }
|
.user-chip-picture { align-self: stretch; }
|
||||||
|
.user-chip-body { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 0.1rem; padding: 0.45rem 0.6rem; }
|
||||||
|
.user-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.user-chip .meta { font-size: 0.7rem; opacity: 0.85; }
|
||||||
.user-chip.sortable-ghost { opacity: 0.5; }
|
.user-chip.sortable-ghost { opacity: 0.5; }
|
||||||
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
.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; }
|
.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; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
|
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
import SessionList from '@/components/SessionList.vue'
|
import SessionList from '@/components/SessionList.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -17,12 +18,14 @@ const props = defineProps({
|
|||||||
navigationDisabled: { type: Boolean, default: false }
|
navigationDisabled: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
|
const emit = defineEmits(['generateUserRegistrationLink', 'openOrg', '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)
|
||||||
|
const showPictureDialog = ref(false)
|
||||||
|
const avatarRenderVersion = ref(0)
|
||||||
|
|
||||||
// Convert credentials dict to array with uuid attached as 'credential'
|
// Convert credentials dict to array with uuid attached as 'credential'
|
||||||
const credentials = computed(() =>
|
const credentials = computed(() =>
|
||||||
@@ -48,15 +51,31 @@ function handleEditName() {
|
|||||||
emit('editUserName', props.selectedUser)
|
emit('editUserName', props.selectedUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPictureDialog() {
|
||||||
|
if (!props.userDetail || props.userDetail.error) return
|
||||||
|
showPictureDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePictureDialog() {
|
||||||
|
showPictureDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePictureUpdated() {
|
||||||
|
avatarRenderVersion.value += 1
|
||||||
|
emit('refreshUserDetail')
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(credential) {
|
async function handleDelete(credential) {
|
||||||
try {
|
try {
|
||||||
const data = await apiJson(`/auth/api/admin/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('refreshUserDetail')
|
||||||
|
authStore.showMessage('Passkey removed', 'success', 2500)
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to delete credential', data)
|
authStore.showMessage(data.detail || 'Failed to remove passkey', 'error')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
authStore.showMessage(err.message || 'Failed to remove passkey', 'error')
|
||||||
console.error('Delete credential error', err)
|
console.error('Delete credential error', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +92,7 @@ async function handleTerminateSession(session) {
|
|||||||
location.reload()
|
location.reload()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emit('refreshUserDetail') // Refresh without showing rename message
|
emit('refreshUserDetail')
|
||||||
authStore.showMessage('Session terminated', 'success', 2500)
|
authStore.showMessage('Session terminated', 'success', 2500)
|
||||||
} else {
|
} else {
|
||||||
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
|
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
|
||||||
@@ -102,7 +121,7 @@ function handleUserInfoKeydown(event) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (direction === 'left' || direction === 'right') {
|
if (direction === 'left' || direction === 'right') {
|
||||||
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.mini-btn' })
|
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||||
} else if (direction === 'up') {
|
} else if (direction === 'up') {
|
||||||
emit('navigateOut', 'up')
|
emit('navigateOut', 'up')
|
||||||
} else if (direction === 'down') {
|
} else if (direction === 'down') {
|
||||||
@@ -124,7 +143,7 @@ function handleRegActionsKeydown(event) {
|
|||||||
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
|
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
|
||||||
} else if (direction === 'up') {
|
} else if (direction === 'up') {
|
||||||
// Move to user info edit button
|
// Move to user info edit button
|
||||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||||
} else if (direction === 'down') {
|
} else if (direction === 'down') {
|
||||||
// Move to credential list
|
// Move to credential list
|
||||||
credentialListRef.value?.$el?.focus()
|
credentialListRef.value?.$el?.focus()
|
||||||
@@ -174,10 +193,22 @@ function handleBackButtonKeydown(event) {
|
|||||||
|
|
||||||
// Focus helper for external navigation
|
// Focus helper for external navigation
|
||||||
function focusFirstElement() {
|
function focusFirstElement() {
|
||||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ focusFirstElement })
|
defineExpose({ focusFirstElement })
|
||||||
|
|
||||||
|
const currentPictureEndpoint = computed(() => {
|
||||||
|
if (!props.selectedUser?.uuid) return null
|
||||||
|
return `/auth/api/user/${props.selectedUser.uuid}/profile.webp`
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminPictureTitle = computed(() => {
|
||||||
|
const username = props.userDetail?.user?.preferred_username || props.selectedUser?.preferred_username
|
||||||
|
const displayName = props.userDetail?.user?.display_name || props.selectedUser?.display_name
|
||||||
|
const label = username || displayName || 'User'
|
||||||
|
return `Profile Picture for ${label}`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -186,6 +217,9 @@ defineExpose({ focusFirstElement })
|
|||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="userDetail && !userDetail.error"
|
v-if="userDetail && !userDetail.error"
|
||||||
:name="userDetail.user.display_name || selectedUser.display_name"
|
:name="userDetail.user.display_name || selectedUser.display_name"
|
||||||
|
:avatar-url="userDetail.user.avatar_url"
|
||||||
|
:avatar-render-version="avatarRenderVersion"
|
||||||
|
avatar-clickable
|
||||||
:visits="userDetail.user.visits"
|
:visits="userDetail.user.visits"
|
||||||
:created-at="userDetail.user.created_at"
|
:created-at="userDetail.user.created_at"
|
||||||
:last-seen="userDetail.user.last_seen"
|
:last-seen="userDetail.user.last_seen"
|
||||||
@@ -195,7 +229,7 @@ defineExpose({ focusFirstElement })
|
|||||||
:org-display-name="userDetail.org.display_name"
|
:org-display-name="userDetail.org.display_name"
|
||||||
:role-name="userDetail.role.display_name"
|
:role-name="userDetail.role.display_name"
|
||||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
||||||
@saved="$emit('onUserNameSaved')"
|
@avatar-click="openPictureDialog"
|
||||||
@edit="handleEditName"
|
@edit="handleEditName"
|
||||||
>
|
>
|
||||||
<div class="admin-actions">
|
<div class="admin-actions">
|
||||||
@@ -254,10 +288,19 @@ defineExpose({ focusFirstElement })
|
|||||||
<RegistrationLinkModal
|
<RegistrationLinkModal
|
||||||
v-if="showRegModal"
|
v-if="showRegModal"
|
||||||
:endpoint="`/auth/api/admin/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?.user?.display_name || selectedUser.display_name"
|
||||||
@close="$emit('closeRegModal')"
|
@close="$emit('closeRegModal')"
|
||||||
@copied="onLinkCopied"
|
@copied="onLinkCopied"
|
||||||
/>
|
/>
|
||||||
|
<ProfilePictureEditorModal
|
||||||
|
v-if="showPictureDialog && currentPictureEndpoint"
|
||||||
|
:endpoint="currentPictureEndpoint"
|
||||||
|
:picture-url="userDetail?.user?.avatar_url"
|
||||||
|
:render-version="avatarRenderVersion"
|
||||||
|
:title="adminPictureTitle"
|
||||||
|
@close="closePictureDialog"
|
||||||
|
@updated="handlePictureUpdated"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="340" height="340">
|
||||||
|
<path fill="#DDD" d="m169,.5a169,169 0 1,0 2,0zm0,86a76,76 0 1
|
||||||
|
1-2,0zM57,287q27-35 67-35h92q40,0 67,35a164,164 0 0,1-226,0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 220 B |
@@ -10,6 +10,7 @@
|
|||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="ctx"
|
v-if="ctx"
|
||||||
:name="ctx.user.display_name"
|
:name="ctx.user.display_name"
|
||||||
|
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||||
:visits="authStore.userInfo.user.visits"
|
:visits="authStore.userInfo.user.visits"
|
||||||
:created-at="authStore.userInfo.user.created_at"
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
:last-seen="authStore.userInfo.user.last_seen"
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dialog-overlay" @click="$emit('close')">
|
<div class="dialog-overlay" @click="$emit('close')">
|
||||||
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
|
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +17,9 @@ const props = defineProps({
|
|||||||
// Optional: index to help find next sibling when item is deleted
|
// Optional: index to help find next sibling when item is deleted
|
||||||
focusIndex: { type: Number, default: -1 },
|
focusIndex: { type: Number, default: -1 },
|
||||||
// Optional: selector for finding siblings when restoring focus
|
// Optional: selector for finding siblings when restoring focus
|
||||||
focusSiblingSelector: { type: String, default: '' }
|
focusSiblingSelector: { type: String, default: '' },
|
||||||
|
// Optional: extra class name(s) for the modal panel
|
||||||
|
panelClass: { type: [String, Array, Object], default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="rootTag"
|
||||||
|
v-bind="rootAttrs"
|
||||||
|
class="profile-picture"
|
||||||
|
:class="{ 'profile-picture-btn': clickable }"
|
||||||
|
:style="pictureStyle"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="showPicture"
|
||||||
|
:key="`${src || 'none'}:${renderVersion}`"
|
||||||
|
:src="src"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-image"
|
||||||
|
@error="handleError"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="profileGeneric"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-fallback"
|
||||||
|
/>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import profileGeneric from '@/assets/profile-generic.svg'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: { type: String, default: null },
|
||||||
|
clickable: { type: Boolean, default: false },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
renderVersion: { type: [Number, String], default: 0 },
|
||||||
|
width: { type: String, default: '3rem' },
|
||||||
|
height: { type: String, default: '3rem' },
|
||||||
|
radius: { type: String, default: '0.9rem' },
|
||||||
|
fit: { type: String, default: 'cover' },
|
||||||
|
filter: { type: String, default: 'none' },
|
||||||
|
fallbackSize: { type: String, default: '2em' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['click'])
|
||||||
|
const pictureAvailable = ref(true)
|
||||||
|
|
||||||
|
const rootTag = computed(() => (props.clickable ? 'button' : 'div'))
|
||||||
|
const showPicture = computed(() => !!props.src && pictureAvailable.value)
|
||||||
|
const pictureStyle = computed(() => ({
|
||||||
|
'--profile-picture-width': props.width,
|
||||||
|
'--profile-picture-height': props.height,
|
||||||
|
'--profile-picture-radius': props.radius,
|
||||||
|
'--profile-picture-fit': props.fit,
|
||||||
|
'--profile-picture-filter': props.filter,
|
||||||
|
'--profile-picture-fallback-size': props.fallbackSize
|
||||||
|
}))
|
||||||
|
const rootAttrs = computed(() => {
|
||||||
|
if (!props.clickable) return { title: props.title || undefined }
|
||||||
|
return {
|
||||||
|
type: 'button',
|
||||||
|
disabled: props.loading,
|
||||||
|
title: props.title || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.src, () => {
|
||||||
|
pictureAvailable.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
pictureAvailable.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!props.clickable || props.loading) return
|
||||||
|
emit('click')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-picture {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--profile-picture-width);
|
||||||
|
height: var(--profile-picture-height);
|
||||||
|
font-size: var(--profile-picture-fallback-size);
|
||||||
|
line-height: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--profile-picture-radius);
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-btn {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-btn:disabled {
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: var(--profile-picture-fit);
|
||||||
|
display: block;
|
||||||
|
filter: var(--profile-picture-filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
<template>
|
||||||
|
<Modal panel-class="modal-panel--avatar" @close="closeEditor">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<input
|
||||||
|
ref="pictureInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="profile-picture-editor-input"
|
||||||
|
:disabled="saving"
|
||||||
|
@change="handlePictureSelected"
|
||||||
|
/>
|
||||||
|
<div ref="picturePreview" class="profile-picture-editor-preview" :style="previewStyle">
|
||||||
|
<img
|
||||||
|
v-if="editorImageUrl && displayMetrics"
|
||||||
|
:src="editorImageUrl"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-editor-image"
|
||||||
|
:style="editorImageStyle"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="editorImageUrl && displayMetrics"
|
||||||
|
:src="editorImageUrl"
|
||||||
|
alt=""
|
||||||
|
class="profile-picture-editor-image profile-picture-editor-image--overlay"
|
||||||
|
:style="editorOverlayStyle"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="editorImageUrl && displayMetrics"
|
||||||
|
class="profile-picture-editor-crop"
|
||||||
|
:style="cropBoxStyle"
|
||||||
|
@pointerdown="startMove"
|
||||||
|
>
|
||||||
|
<div class="profile-picture-editor-guides" aria-hidden="true">
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--circle"></div>
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--eyes"></div>
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-left"></div>
|
||||||
|
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-right"></div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--nw"
|
||||||
|
@pointerdown.stop="startResize($event, 'nw')"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--ne"
|
||||||
|
@pointerdown.stop="startResize($event, 'ne')"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--sw"
|
||||||
|
@pointerdown.stop="startResize($event, 'sw')"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-picture-editor-handle profile-picture-editor-handle--se"
|
||||||
|
@pointerdown.stop="startResize($event, 'se')"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<ProfilePicture
|
||||||
|
v-else
|
||||||
|
class="profile-picture-editor-trigger"
|
||||||
|
:src="pictureUrl"
|
||||||
|
:render-version="renderVersion"
|
||||||
|
clickable
|
||||||
|
:loading="saving"
|
||||||
|
title="Choose profile picture"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
radius="0"
|
||||||
|
fit="contain"
|
||||||
|
fallback-size="5rem"
|
||||||
|
@click="triggerPictureSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage" class="error small">{{ errorMessage }}</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" :disabled="saving" @click="closeEditor">Back</button>
|
||||||
|
<button
|
||||||
|
v-if="!editorImageUrl && pictureUrl"
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="removePicture"
|
||||||
|
>Delete</button>
|
||||||
|
<button
|
||||||
|
v-if="editorImageUrl"
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="savePicture"
|
||||||
|
>Save</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { apiJson } from 'paskia'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import Modal from '@/components/Modal.vue'
|
||||||
|
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||||
|
|
||||||
|
const AVATAR_UPLOAD_SIZE = 720
|
||||||
|
const MIN_CROP_SIZE = 36
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
endpoint: { type: String, required: true },
|
||||||
|
pictureUrl: { type: String, default: null },
|
||||||
|
renderVersion: { type: [Number, String], default: 0 },
|
||||||
|
title: { type: String, default: 'Profile Picture' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'updated'])
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const pictureInput = ref(null)
|
||||||
|
const picturePreview = ref(null)
|
||||||
|
const editorImage = ref(null)
|
||||||
|
const editorImageUrl = ref('')
|
||||||
|
const previewObjectUrl = ref(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const cropRect = reactive({ x: 0, y: 0, size: 0 })
|
||||||
|
const previewRect = reactive({ width: 0, height: 0 })
|
||||||
|
const viewportSize = reactive({ width: 0, height: 0 })
|
||||||
|
let dragState = null
|
||||||
|
let previewObserver = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
viewportSize.width = window.innerWidth
|
||||||
|
viewportSize.height = window.innerHeight
|
||||||
|
window.addEventListener('pointermove', handlePointerMove)
|
||||||
|
window.addEventListener('pointerup', endPointerInteraction)
|
||||||
|
window.addEventListener('resize', syncPreviewRect)
|
||||||
|
await nextTick()
|
||||||
|
syncPreviewRect()
|
||||||
|
if (picturePreview.value && typeof ResizeObserver !== 'undefined') {
|
||||||
|
previewObserver = new ResizeObserver(() => syncPreviewRect())
|
||||||
|
previewObserver.observe(picturePreview.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove)
|
||||||
|
window.removeEventListener('pointerup', endPointerInteraction)
|
||||||
|
window.removeEventListener('resize', syncPreviewRect)
|
||||||
|
previewObserver?.disconnect()
|
||||||
|
clearPreviewObjectUrl()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(editorImage, async (image) => {
|
||||||
|
if (!image) return
|
||||||
|
await nextTick()
|
||||||
|
syncPreviewRect()
|
||||||
|
initializeCrop()
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearPreviewObjectUrl = () => {
|
||||||
|
if (!previewObjectUrl.value) return
|
||||||
|
URL.revokeObjectURL(previewObjectUrl.value)
|
||||||
|
previewObjectUrl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetEditor = () => {
|
||||||
|
clearPreviewObjectUrl()
|
||||||
|
editorImage.value = null
|
||||||
|
editorImageUrl.value = ''
|
||||||
|
cropRect.x = 0
|
||||||
|
cropRect.y = 0
|
||||||
|
cropRect.size = 0
|
||||||
|
errorMessage.value = ''
|
||||||
|
if (pictureInput.value) pictureInput.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncPreviewRect = () => {
|
||||||
|
viewportSize.width = window.innerWidth
|
||||||
|
viewportSize.height = window.innerHeight
|
||||||
|
const element = picturePreview.value
|
||||||
|
if (!element) return
|
||||||
|
previewRect.width = element.clientWidth
|
||||||
|
previewRect.height = element.clientHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewStyle = computed(() => {
|
||||||
|
const image = editorImage.value
|
||||||
|
if (!image) {
|
||||||
|
const size = Math.min(viewportSize.width * 0.72, viewportSize.height * 0.42, 352)
|
||||||
|
return {
|
||||||
|
width: `${Math.max(160, Math.round(size))}px`,
|
||||||
|
height: `${Math.max(160, Math.round(size))}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidth = Math.min(viewportSize.width * 0.88, 928)
|
||||||
|
const maxHeight = Math.min(viewportSize.height * 0.62, 620)
|
||||||
|
const scale = Math.min(maxWidth / image.naturalWidth, maxHeight / image.naturalHeight)
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${Math.max(1, Math.round(image.naturalWidth * scale))}px`,
|
||||||
|
height: `${Math.max(1, Math.round(image.naturalHeight * scale))}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayMetrics = computed(() => {
|
||||||
|
const image = editorImage.value
|
||||||
|
if (!image || !previewRect.width || !previewRect.height) return null
|
||||||
|
const scale = Math.min(previewRect.width / image.naturalWidth, previewRect.height / image.naturalHeight)
|
||||||
|
const width = image.naturalWidth * scale
|
||||||
|
const height = image.naturalHeight * scale
|
||||||
|
return {
|
||||||
|
x: (previewRect.width - width) / 2,
|
||||||
|
y: (previewRect.height - height) / 2,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const editorImageStyle = computed(() => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics) return null
|
||||||
|
return {
|
||||||
|
width: `${metrics.width}px`,
|
||||||
|
height: `${metrics.height}px`,
|
||||||
|
left: `${metrics.x}px`,
|
||||||
|
top: `${metrics.y}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const editorOverlayStyle = computed(() => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics || !cropRect.size) return editorImageStyle.value
|
||||||
|
|
||||||
|
const left = cropRect.x
|
||||||
|
const top = cropRect.y
|
||||||
|
const right = cropRect.x + cropRect.size
|
||||||
|
const bottom = cropRect.y + cropRect.size
|
||||||
|
|
||||||
|
return {
|
||||||
|
...editorImageStyle.value,
|
||||||
|
clipPath: `polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${left}px ${top}px, ${left}px ${bottom}px, ${right}px ${bottom}px, ${right}px ${top}px, ${left}px ${top}px)`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cropBoxStyle = computed(() => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics || !cropRect.size) return null
|
||||||
|
return {
|
||||||
|
left: `${metrics.x + cropRect.x}px`,
|
||||||
|
top: `${metrics.y + cropRect.y}px`,
|
||||||
|
width: `${cropRect.size}px`,
|
||||||
|
height: `${cropRect.size}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const initializeCrop = () => {
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics) return
|
||||||
|
const size = Math.min(metrics.width, metrics.height)
|
||||||
|
cropRect.size = size
|
||||||
|
cropRect.x = (metrics.width - size) / 2
|
||||||
|
cropRect.y = (metrics.height - size) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerPictureSelect = () => {
|
||||||
|
pictureInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePictureSelected = async (event) => {
|
||||||
|
const nextFile = event.target.files?.[0] || null
|
||||||
|
resetEditor()
|
||||||
|
if (!nextFile) return
|
||||||
|
|
||||||
|
previewObjectUrl.value = URL.createObjectURL(nextFile)
|
||||||
|
editorImageUrl.value = previewObjectUrl.value
|
||||||
|
const image = new Image()
|
||||||
|
image.decoding = 'async'
|
||||||
|
image.src = editorImageUrl.value
|
||||||
|
try {
|
||||||
|
await image.decode()
|
||||||
|
editorImage.value = image
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'Failed to load image'
|
||||||
|
resetEditor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMove = (event) => {
|
||||||
|
if (!displayMetrics.value || saving.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
dragState = {
|
||||||
|
mode: 'move',
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
initialX: cropRect.x,
|
||||||
|
initialY: cropRect.y,
|
||||||
|
initialSize: cropRect.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResize = (event, handle) => {
|
||||||
|
if (!displayMetrics.value || saving.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
dragState = {
|
||||||
|
mode: 'resize',
|
||||||
|
handle,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
initialX: cropRect.x,
|
||||||
|
initialY: cropRect.y,
|
||||||
|
initialSize: cropRect.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event) => {
|
||||||
|
if (!dragState) return
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics) return
|
||||||
|
|
||||||
|
const dx = event.clientX - dragState.startX
|
||||||
|
const dy = event.clientY - dragState.startY
|
||||||
|
|
||||||
|
if (dragState.mode === 'move') {
|
||||||
|
cropRect.x = Math.max(0, Math.min(metrics.width - dragState.initialSize, dragState.initialX + dx))
|
||||||
|
cropRect.y = Math.max(0, Math.min(metrics.height - dragState.initialSize, dragState.initialY + dy))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionMap = {
|
||||||
|
nw: { deltaX: -1, deltaY: -1 },
|
||||||
|
ne: { deltaX: 1, deltaY: -1 },
|
||||||
|
sw: { deltaX: -1, deltaY: 1 },
|
||||||
|
se: { deltaX: 1, deltaY: 1 }
|
||||||
|
}
|
||||||
|
const direction = directionMap[dragState.handle]
|
||||||
|
if (!direction) return
|
||||||
|
|
||||||
|
const delta = Math.max(dx * direction.deltaX, dy * direction.deltaY)
|
||||||
|
const nextSize = Math.max(
|
||||||
|
MIN_CROP_SIZE,
|
||||||
|
Math.min(getResizeLimit(metrics, dragState), dragState.initialSize + delta)
|
||||||
|
)
|
||||||
|
|
||||||
|
applyResize(dragState, nextSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
const endPointerInteraction = () => {
|
||||||
|
dragState = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getResizeLimit = (metrics, state) => {
|
||||||
|
const { initialX, initialY, initialSize, handle } = state
|
||||||
|
|
||||||
|
if (handle === 'nw') return Math.min(initialX + initialSize, initialY + initialSize)
|
||||||
|
if (handle === 'ne') return Math.min(metrics.width - initialX, initialY + initialSize)
|
||||||
|
if (handle === 'sw') return Math.min(initialX + initialSize, metrics.height - initialY)
|
||||||
|
return Math.min(metrics.width - initialX, metrics.height - initialY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyResize = (state, size) => {
|
||||||
|
const { initialX, initialY, initialSize, handle } = state
|
||||||
|
|
||||||
|
if (handle === 'nw') {
|
||||||
|
cropRect.x = initialX + initialSize - size
|
||||||
|
cropRect.y = initialY + initialSize - size
|
||||||
|
cropRect.size = size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handle === 'ne') {
|
||||||
|
cropRect.x = initialX
|
||||||
|
cropRect.y = initialY + initialSize - size
|
||||||
|
cropRect.size = size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handle === 'sw') {
|
||||||
|
cropRect.x = initialX + initialSize - size
|
||||||
|
cropRect.y = initialY
|
||||||
|
cropRect.size = size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cropRect.x = initialX
|
||||||
|
cropRect.y = initialY
|
||||||
|
cropRect.size = size
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPictureBlob = async () => {
|
||||||
|
const image = editorImage.value
|
||||||
|
if (!image) throw new Error('No image selected')
|
||||||
|
const metrics = displayMetrics.value
|
||||||
|
if (!metrics || !cropRect.size) throw new Error('Crop selection unavailable')
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = AVATAR_UPLOAD_SIZE
|
||||||
|
canvas.height = AVATAR_UPLOAD_SIZE
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) throw new Error('Canvas unavailable')
|
||||||
|
|
||||||
|
const sourceScale = image.naturalWidth / metrics.width
|
||||||
|
const sourceX = cropRect.x * sourceScale
|
||||||
|
const sourceY = cropRect.y * sourceScale
|
||||||
|
const sourceSize = cropRect.size * sourceScale
|
||||||
|
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, AVATAR_UPLOAD_SIZE, AVATAR_UPLOAD_SIZE)
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('Failed to export cropped picture'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(blob)
|
||||||
|
}, 'image/webp', 0.9)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadPictureFromCache = async () => {
|
||||||
|
const response = await fetch(props.endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'reload'
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Failed to refresh profile picture')
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePicture = async () => {
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
const blob = await renderPictureBlob()
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', blob, 'profile.webp')
|
||||||
|
await apiJson(props.endpoint, { method: 'PUT', body: formData })
|
||||||
|
await reloadPictureFromCache()
|
||||||
|
authStore.showMessage('Profile picture updated.', 'success', 3000)
|
||||||
|
emit('updated')
|
||||||
|
closeEditor()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || 'Failed to update profile picture'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePicture = async () => {
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
await apiJson(props.endpoint, { method: 'DELETE' })
|
||||||
|
authStore.showMessage('Profile picture removed.', 'success', 3000)
|
||||||
|
emit('updated')
|
||||||
|
closeEditor()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || 'Failed to remove profile picture'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
resetEditor()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-picture-editor-input { display: none; }
|
||||||
|
.profile-picture-editor-preview { position: relative; display: flex; justify-content: center; align-items: center; width: auto; max-width: min(58rem, 88vw); min-height: 0; margin: 0 auto; overflow: visible; }
|
||||||
|
.profile-picture-editor-trigger { min-width: 0; }
|
||||||
|
.profile-picture-editor-image { position: absolute; user-select: none; pointer-events: none; object-fit: contain; }
|
||||||
|
.profile-picture-editor-image--overlay { filter: grayscale(0.45) saturate(0.7) brightness(0.68); }
|
||||||
|
.profile-picture-editor-crop { position: absolute; border: 2px solid white; cursor: move; touch-action: none; }
|
||||||
|
.profile-picture-editor-guides { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.profile-picture-editor-guide { position: absolute; border-color: rgba(255, 255, 255, 0.52); }
|
||||||
|
.profile-picture-editor-guide--circle { inset: 0; border: 1.5px solid rgba(255, 255, 255, 0.62); border-radius: 999px; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); }
|
||||||
|
.profile-picture-editor-guide--eyes { left: 18%; right: 18%; top: 38%; border-top: 1.5px solid rgba(255, 255, 255, 0.56); }
|
||||||
|
.profile-picture-editor-guide--cheek-left { top: 24%; bottom: 18%; left: 24%; border-left: 1.5px solid rgba(255, 255, 255, 0.48); }
|
||||||
|
.profile-picture-editor-guide--cheek-right { top: 24%; bottom: 18%; right: 24%; border-right: 1.5px solid rgba(255, 255, 255, 0.48); }
|
||||||
|
.profile-picture-editor-handle { position: absolute; width: 1.1rem; height: 1.1rem; border-radius: 999px; border: 2px solid white; background: var(--color-accent); padding: 0; }
|
||||||
|
.profile-picture-editor-handle--nw { left: -0.55rem; top: -0.55rem; cursor: nwse-resize; }
|
||||||
|
.profile-picture-editor-handle--ne { right: -0.55rem; top: -0.55rem; cursor: nesw-resize; }
|
||||||
|
.profile-picture-editor-handle--sw { left: -0.55rem; bottom: -0.55rem; cursor: nesw-resize; }
|
||||||
|
.profile-picture-editor-handle--se { right: -0.55rem; bottom: -0.55rem; cursor: nwse-resize; }
|
||||||
|
:deep(.modal-panel--avatar) { width: fit-content; max-width: min(58rem, 94vw); }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.profile-picture-editor-preview { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
v-if="authStore.userInfo?.user"
|
v-if="authStore.userInfo?.user"
|
||||||
ref="userBasicInfo"
|
ref="userBasicInfo"
|
||||||
:name="authStore.userInfo.user.display_name"
|
:name="authStore.userInfo.user.display_name"
|
||||||
|
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||||
|
:avatar-render-version="avatarRenderVersion"
|
||||||
|
avatar-clickable
|
||||||
:email="authStore.userInfo.user.email"
|
:email="authStore.userInfo.user.email"
|
||||||
:preferred_username="authStore.userInfo.user.preferred_username"
|
:preferred_username="authStore.userInfo.user.preferred_username"
|
||||||
:telephone="authStore.userInfo.user.telephone"
|
:telephone="authStore.userInfo.user.telephone"
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
:role-name="authStore.userInfo.role.display_name"
|
:role-name="authStore.userInfo.role.display_name"
|
||||||
update-endpoint="/auth/api/user/info"
|
update-endpoint="/auth/api/user/info"
|
||||||
@saved="authStore.loadUserInfo()"
|
@saved="authStore.loadUserInfo()"
|
||||||
|
@avatar-click="openAvatarDialog"
|
||||||
@edit="openEditDialog"
|
@edit="openEditDialog"
|
||||||
@keydown="handleUserInfoKeydown"
|
@keydown="handleUserInfoKeydown"
|
||||||
>
|
>
|
||||||
@@ -131,6 +135,15 @@
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<ProfilePictureEditorModal
|
||||||
|
v-if="showAvatarDialog && currentAvatarEndpoint"
|
||||||
|
:endpoint="currentAvatarEndpoint"
|
||||||
|
:picture-url="authStore.userInfo?.user?.avatar_url"
|
||||||
|
:render-version="avatarRenderVersion"
|
||||||
|
@close="closeAvatarDialog"
|
||||||
|
@updated="handleProfilePictureUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
<RegistrationLinkModal
|
<RegistrationLinkModal
|
||||||
v-if="showRegLink"
|
v-if="showRegLink"
|
||||||
endpoint="/auth/api/user/create-link"
|
endpoint="/auth/api/user/create-link"
|
||||||
@@ -144,6 +157,7 @@
|
|||||||
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 ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||||
import ThemeSelector from '@/components/ThemeSelector.vue'
|
import ThemeSelector from '@/components/ThemeSelector.vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
@@ -160,11 +174,13 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const updateInterval = ref(null)
|
const updateInterval = ref(null)
|
||||||
const showEditDialog = ref(false)
|
const showEditDialog = ref(false)
|
||||||
|
const showAvatarDialog = ref(false)
|
||||||
const showRegLink = ref(false)
|
const showRegLink = ref(false)
|
||||||
const editName = ref('')
|
const editName = ref('')
|
||||||
const editEmail = ref('')
|
const editEmail = ref('')
|
||||||
const editUsername = ref('')
|
const editUsername = ref('')
|
||||||
const editTelephone = ref('')
|
const editTelephone = ref('')
|
||||||
|
const avatarRenderVersion = ref(0)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const editError = ref('')
|
const editError = ref('')
|
||||||
const hoveredCredentialUuid = ref(null)
|
const hoveredCredentialUuid = ref(null)
|
||||||
@@ -176,14 +192,15 @@ const credentialButtons = ref(null)
|
|||||||
const sessionList = ref(null)
|
const sessionList = ref(null)
|
||||||
const logoutButtons = ref(null)
|
const logoutButtons = ref(null)
|
||||||
const breadcrumbs = ref(null)
|
const breadcrumbs = ref(null)
|
||||||
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(() => showEditDialog.value || showRegLink.value)
|
const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.value || showRegLink.value)
|
||||||
|
|
||||||
watch(showEditDialog, (open) => {
|
watch(showEditDialog, (open) => {
|
||||||
if (!open) return
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const user = authStore.userInfo.user
|
const user = authStore.userInfo.user
|
||||||
editName.value = user.display_name ?? ''
|
editName.value = user.display_name ?? ''
|
||||||
editEmail.value = user.email ?? ''
|
editEmail.value = user.email ?? ''
|
||||||
@@ -196,7 +213,28 @@ onMounted(() => {
|
|||||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
|
onUnmounted(() => {
|
||||||
|
if (updateInterval.value) clearInterval(updateInterval.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentAvatarEndpoint = computed(() => {
|
||||||
|
const userUuid = authStore.userInfo?.user?.uuid
|
||||||
|
if (!userUuid) return null
|
||||||
|
return `/auth/api/user/${userUuid}/profile.webp`
|
||||||
|
})
|
||||||
|
|
||||||
|
const openAvatarDialog = () => {
|
||||||
|
showAvatarDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAvatarDialog = () => {
|
||||||
|
showAvatarDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProfilePictureUpdated = async () => {
|
||||||
|
await authStore.loadUserInfo()
|
||||||
|
avatarRenderVersion.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
const addNewCredential = async () => {
|
const addNewCredential = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -245,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
|
|||||||
if (direction === 'down') {
|
if (direction === 'down') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// Move to user info section - always focus edit button first
|
// Move to user info section - always focus edit button first
|
||||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
|
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
||||||
}
|
}
|
||||||
// ArrowUp at the top does nothing
|
// ArrowUp at the top does nothing
|
||||||
}
|
}
|
||||||
@@ -257,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
|
|||||||
if (!direction) return
|
if (!direction) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const itemSelector = '.mini-btn, .pairing-input'
|
const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
|
||||||
|
|
||||||
if (direction === 'left' || direction === 'right') {
|
if (direction === 'left' || direction === 'right') {
|
||||||
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
||||||
@@ -278,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
|
|||||||
focusPreferredButton(credentialButtons.value)
|
focusPreferredButton(credentialButtons.value)
|
||||||
} else if (direction === 'up' || direction === 'left') {
|
} else if (direction === 'up' || direction === 'left') {
|
||||||
// Focus user info section - always focus edit button first
|
// Focus user info section - always focus edit button first
|
||||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
|
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +437,7 @@ const saveProfile = async () => {
|
|||||||
try {
|
try {
|
||||||
editError.value = ''
|
editError.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
|
let changed = false
|
||||||
const body = {}
|
const body = {}
|
||||||
if (name !== user.display_name) body.display_name = name
|
if (name !== user.display_name) body.display_name = name
|
||||||
if (emailVal !== (user.email || null)) body.email = emailVal
|
if (emailVal !== (user.email || null)) body.email = emailVal
|
||||||
@@ -406,6 +445,9 @@ const saveProfile = async () => {
|
|||||||
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
||||||
if (Object.keys(body).length) {
|
if (Object.keys(body).length) {
|
||||||
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
await authStore.loadUserInfo()
|
await authStore.loadUserInfo()
|
||||||
authStore.showMessage('Profile updated!', 'success', 3000)
|
authStore.showMessage('Profile updated!', 'success', 3000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
|
import { fetchJson, getUserFriendlyErrorMessage, settings as paskiaSettings } 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'
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
@@ -147,7 +147,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', timeout: paskiaSettings.auth_ms })
|
||||||
updateThemeFromSession(session.value?.ctx)
|
updateThemeFromSession(session.value?.ctx)
|
||||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||||
currentView.value = 'forbidden'
|
currentView.value = 'forbidden'
|
||||||
@@ -198,7 +198,7 @@ async function logoutUser() {
|
|||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||||
session.value = null
|
session.value = null
|
||||||
currentView.value = 'login'
|
currentView.value = 'login'
|
||||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||||
@@ -220,7 +220,7 @@ async function exchangeCode(result) {
|
|||||||
throw new Error('Authentication response missing exchange_code')
|
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.exchange_code}` }
|
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }, timeout: paskiaSettings.auth_ms
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||||
<div class="user-info-content">
|
<div class="user-info-content">
|
||||||
<div class="user-picture">
|
<ProfilePicture
|
||||||
<span>👤</span>
|
:src="avatarUrl"
|
||||||
</div>
|
:render-version="avatarRenderVersion"
|
||||||
|
:clickable="avatarClickable"
|
||||||
|
:loading="loading"
|
||||||
|
:title="avatarClickable ? 'Change profile picture' : ''"
|
||||||
|
width="5.25rem"
|
||||||
|
height="5.25rem"
|
||||||
|
radius="var(--radius-sm)"
|
||||||
|
fallback-size="2.8em"
|
||||||
|
class="user-picture"
|
||||||
|
:class="avatarClickable ? 'user-picture-btn' : ''"
|
||||||
|
@click="emit('avatar-click')"
|
||||||
|
/>
|
||||||
<h3 class="user-name-heading">
|
<h3 class="user-name-heading">
|
||||||
<span class="user-name-row">
|
<span class="user-name-row">
|
||||||
<span class="display-name" :title="name">{{ name }}</span>
|
<span class="display-name" :title="name">{{ name }}</span>
|
||||||
@@ -42,11 +53,13 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||||
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 },
|
||||||
|
avatarUrl: { type: String, default: null },
|
||||||
|
avatarRenderVersion: { type: [Number, String], default: 0 },
|
||||||
email: { type: String, default: null },
|
email: { type: String, default: null },
|
||||||
preferred_username: { type: String, default: null },
|
preferred_username: { type: String, default: null },
|
||||||
telephone: { type: String, default: null },
|
telephone: { type: String, default: null },
|
||||||
@@ -55,14 +68,13 @@ const props = defineProps({
|
|||||||
lastSeen: { type: [String, Number, Date], default: null },
|
lastSeen: { type: [String, Number, Date], default: null },
|
||||||
updateEndpoint: { type: String, default: null },
|
updateEndpoint: { type: String, default: null },
|
||||||
canEdit: { type: Boolean, default: true },
|
canEdit: { type: Boolean, default: true },
|
||||||
|
avatarClickable: { type: Boolean, default: false },
|
||||||
loading: { type: Boolean, default: false },
|
loading: { type: Boolean, default: false },
|
||||||
orgDisplayName: { type: String, default: '' },
|
orgDisplayName: { type: String, default: '' },
|
||||||
roleName: { type: String, default: '' }
|
roleName: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['saved', 'edit'])
|
const emit = defineEmits(['saved', 'edit', 'avatar-click'])
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const userLoaded = computed(() => !!props.name)
|
const userLoaded = computed(() => !!props.name)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -96,12 +108,12 @@ const userLoaded = computed(() => !!props.name)
|
|||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"picture heading fields"
|
"picture heading fields"
|
||||||
"picture org fields"
|
"picture org fields"
|
||||||
". info info";
|
"picture info info";
|
||||||
gap: 0 1rem;
|
gap: 0 1rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
|
:deep(.user-picture) { grid-area: picture; align-self: stretch; }
|
||||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
||||||
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
||||||
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { register, authenticate } from '@/utils/passkey'
|
import { register, authenticate } from '@/utils/passkey'
|
||||||
import { getSettings } from '@/utils/settings'
|
import { getSettings } from '@/utils/settings'
|
||||||
import { apiJson } from 'paskia'
|
import { apiJson, settings as paskiaSettings } from 'paskia'
|
||||||
import { updateThemeFromSession } from '@/utils/theme'
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
@@ -50,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
return await apiJson('/auth/api/set-session', {
|
return await apiJson('/auth/api/set-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Authorization': `Bearer ${result.session_token}`},
|
headers: {'Authorization': `Bearer ${result.session_token}`},
|
||||||
|
timeout: paskiaSettings.auth_ms,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async register() {
|
async register() {
|
||||||
@@ -87,7 +88,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async loadUserInfo() {
|
async loadUserInfo() {
|
||||||
try {
|
try {
|
||||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
|
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||||
updateThemeFromSession(this.userInfo)
|
updateThemeFromSession(this.userInfo)
|
||||||
console.log('User info loaded:', this.userInfo)
|
console.log('User info loaded:', this.userInfo)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
try {
|
try {
|
||||||
await apiJson('/auth/api/logout', {method: 'POST'})
|
await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -134,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async logoutEverywhere() {
|
async logoutEverywhere() {
|
||||||
try {
|
try {
|
||||||
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
|
await apiJson('/auth/api/user/logout-all', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -75,10 +75,16 @@ Discovery: `backchannel_logout_supported: true`
|
|||||||
- `GET /.well-known/openid-configuration` — Discovery
|
- `GET /.well-known/openid-configuration` — Discovery
|
||||||
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
||||||
- `POST /auth/oidc/token` — Exchange/refresh
|
- `POST /auth/oidc/token` — Exchange/refresh
|
||||||
- `GET /auth/oidc/userinfo` — User (bearer token)
|
- `GET /auth/oidc/userinfo` — User (bearer token, includes `picture` when `profile` scope is granted and avatar exists)
|
||||||
- `POST /auth/oidc/backchannel-logout` — Logout
|
- `POST /auth/oidc/backchannel-logout` — Logout
|
||||||
- `POST /auth/api/exchange` — Native auth code → cookie
|
- `POST /auth/api/exchange` — Native auth code → cookie
|
||||||
|
|
||||||
|
## Claims
|
||||||
|
|
||||||
|
- `profile` scope may include `name`, `preferred_username`, and `picture`
|
||||||
|
- `email` scope may include `email`
|
||||||
|
- `groups` is emitted from client-scoped permissions
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
|
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
|
||||||
|
|||||||
@@ -64,6 +64,30 @@ When a 401/403 response includes an auth iframe URL, the request automatically p
|
|||||||
|
|
||||||
The JSON variants set headers automatically, with body and response in JSON.
|
The JSON variants set headers automatically, with body and response in JSON.
|
||||||
|
|
||||||
|
### Timeout Settings
|
||||||
|
|
||||||
|
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { settings } from 'paskia'
|
||||||
|
|
||||||
|
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
|
||||||
|
settings.fetch_ms = 10000
|
||||||
|
|
||||||
|
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
|
||||||
|
settings.auth_ms = 1000
|
||||||
|
|
||||||
|
// SessionValidator polling and idle timers
|
||||||
|
settings.poll_ms = 60000
|
||||||
|
settings.idle_ms = 300000
|
||||||
|
```
|
||||||
|
|
||||||
|
You can still override timeout per request:
|
||||||
|
|
||||||
|
```js
|
||||||
|
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
|
||||||
|
```
|
||||||
|
|
||||||
### Authentication Overlay
|
### Authentication Overlay
|
||||||
|
|
||||||
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paskia",
|
"name": "paskia",
|
||||||
"version": "1.1.0",
|
"version": "1.4.0",
|
||||||
"description": "Paskia authentication utilities for JavaScript",
|
"description": "Paskia authentication utilities for JavaScript",
|
||||||
"author": "Leo Vasanko",
|
"author": "Leo Vasanko",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { showAuthIframe, AuthCancelledError } from './overlay'
|
import { showAuthIframe, AuthCancelledError } from './overlay'
|
||||||
|
import settings from './settings'
|
||||||
|
|
||||||
export { AuthCancelledError }
|
export { AuthCancelledError }
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 1000
|
|
||||||
|
|
||||||
export interface ApiFetchOptions extends RequestInit {
|
export interface ApiFetchOptions extends RequestInit {
|
||||||
timeout?: number
|
timeout?: number
|
||||||
}
|
}
|
||||||
@@ -40,7 +39,7 @@ export class NetworkError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
|
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
|
||||||
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
|
const { timeout = settings.fetch_ms, ...fetchOptions } = options
|
||||||
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export {
|
|||||||
|
|
||||||
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
|
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
|
||||||
|
|
||||||
|
export { default as settings } from './settings'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
holdGlobalBackdrop,
|
holdGlobalBackdrop,
|
||||||
releaseGlobalBackdrop,
|
releaseGlobalBackdrop,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
fetch_ms: 10000,
|
||||||
|
auth_ms: 1000,
|
||||||
|
poll_ms: 60000,
|
||||||
|
idle_ms: 300000,
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { apiJson } from './fetch'
|
import { apiJson } from './fetch'
|
||||||
|
import settings from './settings'
|
||||||
const POLL_INTERVAL = 60 * 1000
|
|
||||||
const IDLE_TIMEOUT = 5 * 60 * 1000
|
|
||||||
|
|
||||||
export class SessionValidator {
|
export class SessionValidator {
|
||||||
private userUuidGetter: () => string | undefined
|
private userUuidGetter: () => string | undefined
|
||||||
@@ -19,12 +17,12 @@ export class SessionValidator {
|
|||||||
resetIdleTimer(): void {
|
resetIdleTimer(): void {
|
||||||
if (this.idleTimer) clearTimeout(this.idleTimer)
|
if (this.idleTimer) clearTimeout(this.idleTimer)
|
||||||
if (!this.active) this.startPolling()
|
if (!this.active) this.startPolling()
|
||||||
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
|
this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(): Promise<void> {
|
async validate(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' })
|
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST', timeout: settings.auth_ms })
|
||||||
const newUuid = data.ctx?.user?.uuid
|
const newUuid = data.ctx?.user?.uuid
|
||||||
if (newUuid !== this.userUuidGetter()) {
|
if (newUuid !== this.userUuidGetter()) {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
@@ -40,7 +38,7 @@ export class SessionValidator {
|
|||||||
startPolling(): void {
|
startPolling(): void {
|
||||||
if (this.active) return
|
if (this.active) return
|
||||||
this.active = true
|
this.active = true
|
||||||
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
|
this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
stopPolling(): void {
|
stopPolling(): void {
|
||||||
|
|||||||
+41
-7
@@ -1,15 +1,20 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from fastapi_vue import server
|
from fastapi_vue import server
|
||||||
from fastapi_vue.hostutil import parse_endpoints
|
from fastapi_vue.hostutil import parse_endpoints
|
||||||
|
from kanta import Kanta
|
||||||
|
|
||||||
from paskia._version import __version__
|
from paskia._version import __version__
|
||||||
from paskia.db.jsonl import load_readonly
|
from paskia.db.paths import db_file_path
|
||||||
|
from paskia.db.structs import DB, Config
|
||||||
from paskia.util import startupbox
|
from paskia.util import startupbox
|
||||||
|
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||||
from paskia.util.hostutil import (
|
from paskia.util.hostutil import (
|
||||||
normalize_auth_host_and_origins,
|
normalize_auth_host_and_origins,
|
||||||
normalize_origin,
|
normalize_origin,
|
||||||
@@ -17,9 +22,6 @@ from paskia.util.hostutil import (
|
|||||||
)
|
)
|
||||||
from paskia.util.runtime import RuntimeConfig
|
from paskia.util.runtime import RuntimeConfig
|
||||||
|
|
||||||
DEFAULT_PORT = 4401
|
|
||||||
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
|
||||||
|
|
||||||
EPILOG = """\
|
EPILOG = """\
|
||||||
Example:
|
Example:
|
||||||
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
|
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
|
||||||
@@ -49,6 +51,36 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_stored_config(db_path: Path, *, rp_id: str) -> Config:
|
||||||
|
"""Load the stored Config from disk using Kanta in read-only mode.
|
||||||
|
|
||||||
|
This must not depend on PASKIA_CONFIG or the global lifecycle Kanta.
|
||||||
|
If the database file does not exist, a default config is returned.
|
||||||
|
"""
|
||||||
|
if not db_path.exists():
|
||||||
|
return Config(rp_id=rp_id)
|
||||||
|
|
||||||
|
kanta = Kanta(
|
||||||
|
str(db_path),
|
||||||
|
DB(config=Config(rp_id=rp_id)),
|
||||||
|
migrations="paskia.db.migrations",
|
||||||
|
)
|
||||||
|
kanta.ctx.rp_id = rp_id
|
||||||
|
|
||||||
|
async def _read() -> Config:
|
||||||
|
await kanta.open(readonly=True)
|
||||||
|
try:
|
||||||
|
return kanta.data.config
|
||||||
|
finally:
|
||||||
|
await kanta.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return asyncio.run(_read())
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception("Failed to load database")
|
||||||
|
raise SystemExit(f"{e}") from e
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Configure logging to remove the "ERROR:root:" prefix
|
# Configure logging to remove the "ERROR:root:" prefix
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||||
@@ -74,10 +106,12 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Load stored config (read-only, no writes, no global state)
|
# Load stored config using a local read-only Kanta instance.
|
||||||
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
|
# This happens before PASKIA_CONFIG is set, so we must not import
|
||||||
|
# modules that initialize the global database lifecycle.
|
||||||
|
db_path = db_file_path(rp_id=args.rp_id, create_root=True)
|
||||||
try:
|
try:
|
||||||
config = load_readonly(db_path, rp_id=args.rp_id).config
|
config = _load_stored_config(db_path, rp_id=args.rp_id)
|
||||||
except SystemExit as e:
|
except SystemExit as e:
|
||||||
print(f"🛑 Paskia {__version__} could not load")
|
print(f"🛑 Paskia {__version__} could not load")
|
||||||
sys.exit(str(e))
|
sys.exit(str(e))
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def reset_expires() -> datetime:
|
|||||||
return datetime.now(UTC) + RESET_LIFETIME
|
return datetime.now(UTC) + RESET_LIFETIME
|
||||||
|
|
||||||
|
|
||||||
def get_reset(token: str) -> "ResetToken":
|
def get_reset(token: str) -> ResetToken:
|
||||||
"""Validate a credential reset token."""
|
"""Validate a credential reset token."""
|
||||||
|
|
||||||
record = ResetToken.by_passphrase(token)
|
record = ResetToken.by_passphrase(token)
|
||||||
|
|||||||
+28
-42
@@ -4,47 +4,37 @@ Bootstrap module for passkey authentication system.
|
|||||||
This module handles initial system setup when a new database is created,
|
This module handles initial system setup when a new database is created,
|
||||||
including creating default admin user, organization, permissions, and
|
including creating default admin user, organization, permissions, and
|
||||||
generating a reset link for initial admin setup.
|
generating a reset link for initial admin setup.
|
||||||
|
|
||||||
|
The actual database seeding is performed by the module-level kanta bootstrap
|
||||||
|
callback defined in :mod:`paskia.db.bootstrap` and registered during
|
||||||
|
:func:`paskia.db.lifecycle.init`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from paskia import authsession, db
|
from paskia import authsession, db
|
||||||
|
from paskia.db.bootstrap import log_reset_link
|
||||||
from paskia.db.structs import Config
|
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
|
|
||||||
ADMIN_RESET_MESSAGE = """
|
def _configure_logger() -> None:
|
||||||
👤 Admin %s
|
if logger.handlers:
|
||||||
- Use this link to register a Passkey for the admin user!
|
return
|
||||||
"""
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
_configure_logger()
|
||||||
|
|
||||||
|
|
||||||
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
|
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)
|
return log_reset_link(passphrase, message)
|
||||||
if message:
|
|
||||||
logger.info(message)
|
|
||||||
logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
|
||||||
return reset_link
|
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_system(config: Config | None = None) -> None:
|
|
||||||
"""
|
|
||||||
Bootstrap the entire system with default data.
|
|
||||||
|
|
||||||
Uses db.bootstrap() which performs all operations in a single transaction.
|
|
||||||
The transaction log will show a single "bootstrap" action with all changes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Configuration to store (rp_id, rp_name, origins, etc.)
|
|
||||||
"""
|
|
||||||
# Call the single-transaction bootstrap function
|
|
||||||
reset_passphrase = db.bootstrap(config=config)
|
|
||||||
|
|
||||||
# Log the reset link (this is separate from the transaction log)
|
|
||||||
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
|
||||||
|
|
||||||
|
|
||||||
async def check_admin_credentials() -> bool:
|
async def check_admin_credentials() -> bool:
|
||||||
@@ -101,22 +91,18 @@ async def check_admin_credentials() -> bool:
|
|||||||
|
|
||||||
async def bootstrap_if_needed(config: Config | None = None) -> bool:
|
async def bootstrap_if_needed(config: Config | None = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if system needs bootstrapping and perform it if necessary.
|
Check if admin needs credentials and create a reset link if needed.
|
||||||
|
|
||||||
|
Database bootstrapping itself is now handled automatically during
|
||||||
|
``db.init()`` via the registered kanta bootstrap callback. This function
|
||||||
|
remains as a post-init hook for credential checks.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
|
config: Kept for backwards compatibility; config is now applied during
|
||||||
|
``db.init()``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if bootstrapping was performed, False if system was already set up
|
bool: Always returns False (bootstrapping is performed during init).
|
||||||
"""
|
"""
|
||||||
# Check if the admin permission exists - if it does, system is already bootstrapped
|
await check_admin_credentials()
|
||||||
if any(p.scope == "auth:admin" for p in db.data().permissions.values()):
|
return False
|
||||||
# Permission exists, system is already bootstrapped
|
|
||||||
# Check if admin needs credentials (only for already-bootstrapped systems)
|
|
||||||
await check_admin_credentials()
|
|
||||||
return False
|
|
||||||
|
|
||||||
# No admin permission found, need to bootstrap
|
|
||||||
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
|
||||||
await bootstrap_system(config=config)
|
|
||||||
return True
|
|
||||||
|
|||||||
@@ -19,15 +19,7 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import paskia.db.operations as operations
|
import paskia.db.operations as operations
|
||||||
from paskia.db.background import (
|
|
||||||
start_background,
|
|
||||||
start_cleanup,
|
|
||||||
stop_background,
|
|
||||||
stop_cleanup,
|
|
||||||
)
|
|
||||||
from paskia.db.bootstrap import bootstrap
|
from paskia.db.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,
|
||||||
@@ -101,19 +93,11 @@ __all__ = [
|
|||||||
"User",
|
"User",
|
||||||
# Instance
|
# Instance
|
||||||
"data",
|
"data",
|
||||||
"init",
|
|
||||||
"load_readonly",
|
|
||||||
# Background
|
|
||||||
"start_background",
|
|
||||||
"stop_background",
|
|
||||||
"start_cleanup",
|
|
||||||
"stop_cleanup",
|
|
||||||
# Read ops
|
# Read ops
|
||||||
# Write ops
|
# Write ops
|
||||||
"add_permission_to_org",
|
"add_permission_to_org",
|
||||||
"add_permission_to_role",
|
"add_permission_to_role",
|
||||||
"bootstrap",
|
"bootstrap",
|
||||||
"cleanup_expired",
|
|
||||||
"create_credential",
|
"create_credential",
|
||||||
"create_credential_session",
|
"create_credential_session",
|
||||||
"create_org",
|
"create_org",
|
||||||
|
|||||||
+10
-41
@@ -1,67 +1,38 @@
|
|||||||
"""
|
"""
|
||||||
Background task for database maintenance.
|
Background task for database maintenance.
|
||||||
|
|
||||||
Periodically flushes pending changes to disk and cleans up expired items.
|
Kanta handles periodic flushing to disk. This module keeps a small
|
||||||
|
companion task that periodically cleans up expired sessions/tokens.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import paskia.db.operations as _ops
|
|
||||||
from paskia.db.lifecycle import cleanup_expired
|
from paskia.db.lifecycle import cleanup_expired
|
||||||
|
|
||||||
FLUSH_INTERVAL = 0.1 # Flush to disk
|
|
||||||
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
||||||
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
_background_task: asyncio.Task | None = None
|
_background_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
||||||
async def flush() -> None:
|
|
||||||
"""Write all pending database changes to disk."""
|
|
||||||
store = _ops._db._store
|
|
||||||
if store is None:
|
|
||||||
_logger.warning("flush() called but _store is None")
|
|
||||||
return
|
|
||||||
await store.flush()
|
|
||||||
|
|
||||||
|
|
||||||
async def _background_loop():
|
async def _background_loop():
|
||||||
"""Background task that periodically flushes changes and cleans up."""
|
"""Background task that periodically cleans up expired items."""
|
||||||
# Run cleanup immediately on startup to clear old expired items
|
# Run cleanup immediately on startup to clear old expired items
|
||||||
cleanup_expired()
|
cleanup_expired()
|
||||||
await flush()
|
|
||||||
|
|
||||||
last_cleanup = datetime.now(UTC)
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(FLUSH_INTERVAL)
|
await asyncio.sleep(CLEANUP_INTERVAL)
|
||||||
# Flush pending changes to disk
|
cleanup_expired()
|
||||||
await flush()
|
|
||||||
|
|
||||||
# Run cleanup periodically
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
|
|
||||||
cleanup_expired()
|
|
||||||
await flush() # Flush cleanup changes
|
|
||||||
last_cleanup = now
|
|
||||||
|
|
||||||
# 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
|
|
||||||
await flush()
|
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
_logger.debug("Error in database background loop", exc_info=True)
|
_logger.debug("Error in database background loop", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def start_background():
|
async def start_background():
|
||||||
"""Start the background flush/cleanup task."""
|
"""Start the background cleanup task."""
|
||||||
global _background_task
|
global _background_task
|
||||||
|
|
||||||
# Check if task exists but is no longer running (e.g., after uvicorn reload)
|
# Check if task exists but is no longer running (e.g., after uvicorn reload)
|
||||||
@@ -75,16 +46,15 @@ async def start_background():
|
|||||||
# Check if task is in current event loop
|
# Check if task is in current event loop
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
task_loop = _background_task.get_loop()
|
task_loop = _background_task.get_loop()
|
||||||
if loop is not task_loop:
|
if loop is task_loop:
|
||||||
_logger.debug("Background task in different event loop, restarting")
|
|
||||||
_background_task = None
|
|
||||||
else:
|
|
||||||
# Task is already running in same loop - idempotent, just return
|
# Task is already running in same loop - idempotent, just return
|
||||||
# This happens with dual IPv4+IPv6 endpoints sharing the same process
|
# This happens with dual IPv4+IPv6 endpoints sharing the same process
|
||||||
_logger.debug(
|
_logger.debug(
|
||||||
"Background task already running in same loop, skipping"
|
"Background task already running in same loop, skipping"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
_logger.debug("Background task in different event loop, restarting")
|
||||||
|
_background_task = None
|
||||||
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
|
||||||
@@ -94,7 +64,7 @@ async def start_background():
|
|||||||
|
|
||||||
|
|
||||||
async def stop_background():
|
async def stop_background():
|
||||||
"""Stop the background task, flush pending changes, and release the file lock."""
|
"""Stop the background cleanup task."""
|
||||||
global _background_task
|
global _background_task
|
||||||
if _background_task:
|
if _background_task:
|
||||||
_background_task.cancel()
|
_background_task.cancel()
|
||||||
@@ -103,7 +73,6 @@ 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
|
||||||
|
|||||||
+95
-60
@@ -2,24 +2,60 @@
|
|||||||
Bootstrap operations for initial system setup.
|
Bootstrap operations for initial system setup.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
import paskia.db.operations as _ops
|
|
||||||
from paskia.authsession import reset_expires
|
from paskia.authsession import reset_expires
|
||||||
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
|
from paskia.db.structs import DB, Config, Org, Permission, ResetToken, Role, User
|
||||||
from paskia.util.crypto import secret_key
|
from paskia.util.crypto import secret_key
|
||||||
|
from paskia.util.hostutil import reset_link_url
|
||||||
|
|
||||||
|
_reset_link_logger = logging.getLogger("paskia.reset_link")
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_reset_link_logger() -> None:
|
||||||
|
if _reset_link_logger.handlers:
|
||||||
|
return
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
_reset_link_logger.addHandler(handler)
|
||||||
|
_reset_link_logger.setLevel(logging.INFO)
|
||||||
|
_reset_link_logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
_configure_reset_link_logger()
|
||||||
|
|
||||||
|
ADMIN_RESET_MESSAGE = """
|
||||||
|
👤 Admin %s
|
||||||
|
- Use this link to register a Passkey for the admin user!
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||||
|
"""Log a reset link message and return the URL."""
|
||||||
|
reset_link = reset_link_url(passphrase)
|
||||||
|
if message:
|
||||||
|
_reset_link_logger.info(message)
|
||||||
|
_reset_link_logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
||||||
|
return reset_link
|
||||||
|
|
||||||
|
|
||||||
def bootstrap(
|
def bootstrap(
|
||||||
|
data: DB,
|
||||||
org_name: str = "Organization",
|
org_name: str = "Organization",
|
||||||
admin_name: str = "Admin",
|
admin_name: str = "Admin",
|
||||||
reset_passphrase: str | None = None,
|
reset_passphrase: str | None = None,
|
||||||
reset_expiry: datetime | None = None,
|
reset_expiry: datetime | None = None,
|
||||||
config: Config | None = None,
|
config: Config | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Bootstrap the entire system in a single transaction.
|
"""Bootstrap the entire system by seeding an empty database.
|
||||||
|
|
||||||
|
This is intended to be called from a ``@kanta.bootstrap`` callback during
|
||||||
|
``kanta.open()``. It mutates the provided root ``data`` object directly;
|
||||||
|
kanta queues the resulting state as the initial "bootstrap" change record.
|
||||||
|
|
||||||
Creates:
|
Creates:
|
||||||
- auth:admin permission (Master Admin)
|
- auth:admin permission (Master Admin)
|
||||||
@@ -29,10 +65,8 @@ def bootstrap(
|
|||||||
- Reset token for admin registration
|
- Reset token for admin registration
|
||||||
- Config (if provided)
|
- 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:
|
Args:
|
||||||
|
data: The live root database object (usually a ``DB`` instance).
|
||||||
org_name: Display name for the organization (default: "Organization")
|
org_name: Display name for the organization (default: "Organization")
|
||||||
admin_name: Display name for the admin user (default: "Admin")
|
admin_name: Display name for the admin user (default: "Admin")
|
||||||
reset_passphrase: Passphrase for the reset token (generated if not provided)
|
reset_passphrase: Passphrase for the reset token (generated if not provided)
|
||||||
@@ -44,7 +78,7 @@ def bootstrap(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Check if system is already bootstrapped
|
# Check if system is already bootstrapped
|
||||||
for p in _ops._db.permissions.values():
|
for p in data.permissions.values():
|
||||||
if p.scope == "auth:admin":
|
if p.scope == "auth:admin":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"System already bootstrapped (auth:admin permission exists)"
|
"System already bootstrapped (auth:admin permission exists)"
|
||||||
@@ -62,65 +96,66 @@ def bootstrap(
|
|||||||
if reset_expiry is None:
|
if reset_expiry is None:
|
||||||
reset_expiry = reset_expires()
|
reset_expiry = reset_expires()
|
||||||
|
|
||||||
with _ops._db.transaction("bootstrap"):
|
# Create auth:admin permission
|
||||||
# Create auth:admin permission
|
perm_admin = Permission(
|
||||||
perm_admin = Permission(
|
scope="auth:admin",
|
||||||
scope="auth:admin",
|
display_name="Master Admin",
|
||||||
display_name="Master Admin",
|
orgs={org_uuid: True}, # Grant to org
|
||||||
orgs={org_uuid: True}, # Grant to org
|
)
|
||||||
)
|
perm_admin.uuid = perm_admin_uuid
|
||||||
perm_admin.uuid = perm_admin_uuid
|
|
||||||
perm_admin.store()
|
|
||||||
|
|
||||||
# Create auth:org:admin permission
|
# Create auth:org:admin permission
|
||||||
perm_org_admin = Permission(
|
perm_org_admin = Permission(
|
||||||
scope="auth:org:admin",
|
scope="auth:org:admin",
|
||||||
display_name="Org Admin",
|
display_name="Org Admin",
|
||||||
orgs={org_uuid: True}, # Grant to org
|
orgs={org_uuid: True}, # Grant to org
|
||||||
)
|
)
|
||||||
perm_org_admin.uuid = perm_org_admin_uuid
|
perm_org_admin.uuid = perm_org_admin_uuid
|
||||||
perm_org_admin.store()
|
|
||||||
|
|
||||||
# Create organization
|
# Create organization
|
||||||
new_org = Org.create(display_name=org_name)
|
new_org = Org.create(display_name=org_name)
|
||||||
new_org.uuid = org_uuid
|
new_org.uuid = org_uuid
|
||||||
new_org.store()
|
|
||||||
|
|
||||||
# Create Administration role with both permissions
|
# Create Administration role with both permissions
|
||||||
admin_role = Role(
|
admin_role = Role(
|
||||||
org_uuid=org_uuid,
|
org_uuid=org_uuid,
|
||||||
display_name="Administration",
|
display_name="Administration",
|
||||||
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
||||||
)
|
)
|
||||||
admin_role.uuid = role_uuid
|
admin_role.uuid = role_uuid
|
||||||
admin_role.store()
|
|
||||||
|
|
||||||
# Create admin user
|
# Create admin user
|
||||||
admin_user = User(
|
admin_user = User(
|
||||||
display_name=admin_name,
|
display_name=admin_name,
|
||||||
role_uuid=role_uuid,
|
role_uuid=role_uuid,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
last_seen=None,
|
last_seen=None,
|
||||||
visits=0,
|
visits=0,
|
||||||
theme="",
|
theme="",
|
||||||
)
|
)
|
||||||
admin_user.uuid = user_uuid
|
admin_user.uuid = user_uuid
|
||||||
admin_user.store()
|
|
||||||
|
|
||||||
# Create reset token
|
# Create reset token
|
||||||
reset_token, reset_passphrase = ResetToken.create(
|
reset_token, reset_passphrase = ResetToken.create(
|
||||||
user=user_uuid,
|
user=user_uuid,
|
||||||
expiry=reset_expiry,
|
expiry=reset_expiry,
|
||||||
token_type="admin bootstrap",
|
token_type="admin bootstrap",
|
||||||
passphrase=reset_passphrase,
|
passphrase=reset_passphrase,
|
||||||
)
|
)
|
||||||
reset_token.store()
|
|
||||||
|
|
||||||
# Set config if provided
|
# Set config if provided
|
||||||
if config is not None:
|
if config is not None:
|
||||||
_ops._db.config = config
|
data.config = config
|
||||||
|
|
||||||
# Generate OIDC signing key
|
# Generate OIDC signing key
|
||||||
_ops._db.oidc.key = secret_key()
|
data.oidc.key = secret_key()
|
||||||
|
|
||||||
|
# Store all bootstrapped objects in the live data object
|
||||||
|
data.permissions[perm_admin_uuid] = perm_admin
|
||||||
|
data.permissions[perm_org_admin_uuid] = perm_org_admin
|
||||||
|
data.orgs[org_uuid] = new_org
|
||||||
|
data.roles[role_uuid] = admin_role
|
||||||
|
data.users[user_uuid] = admin_user
|
||||||
|
data.reset_tokens[reset_token.key] = reset_token
|
||||||
|
|
||||||
return reset_passphrase
|
return reset_passphrase
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
"""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()}")
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
"""
|
|
||||||
JSONL persistence layer for the database.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import copy
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
from collections import deque
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
import jsondiff
|
|
||||||
import msgspec
|
|
||||||
|
|
||||||
from paskia.db.filelock import LockedFile
|
|
||||||
from paskia.db.logging import log_change
|
|
||||||
from paskia.db.migrations import (
|
|
||||||
DBVER,
|
|
||||||
MigrationCtx,
|
|
||||||
apply_all_migrations,
|
|
||||||
apply_migrations_readonly,
|
|
||||||
)
|
|
||||||
from paskia.db.snapshot import SnapshotState
|
|
||||||
from paskia.db.structs import DB, Config, SessionContext
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
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 DatabaseError(ValueError):
|
|
||||||
"""Exception raised for database loading errors."""
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Find and apply the last snapshot
|
|
||||||
snap, start_offset = SnapshotState.load(data)
|
|
||||||
if snap:
|
|
||||||
result.state = snap.state
|
|
||||||
result.v = snap.v
|
|
||||||
result.snapts = snap.ts
|
|
||||||
|
|
||||||
# Replay change records after the snapshot
|
|
||||||
lines = data[start_offset:].split(b"\n")
|
|
||||||
for raw in lines:
|
|
||||||
line = raw.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
change = msgspec.json.decode(line, type=ChangeRecord)
|
|
||||||
except msgspec.DecodeError as e:
|
|
||||||
raise DatabaseError(
|
|
||||||
f"{resolved_path}: {e}\n{line.decode(errors='replace')}"
|
|
||||||
)
|
|
||||||
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
|
|
||||||
result.v = change.v
|
|
||||||
result.ts = change.ts
|
|
||||||
result.changes += 1
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
content = path.read_bytes()
|
|
||||||
r = _replay_from_data(content, str(path.resolve()))
|
|
||||||
data_dict = r.state
|
|
||||||
version = r.v
|
|
||||||
|
|
||||||
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
|
|
||||||
try:
|
|
||||||
return msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
|
||||||
except msgspec.ValidationError as e:
|
|
||||||
raise DatabaseError(f"{path.resolve()}: {e}") from None
|
|
||||||
except OSError as e:
|
|
||||||
_logger.exception("Failed to load database")
|
|
||||||
raise SystemExit(f"{e}")
|
|
||||||
except (ValueError, msgspec.DecodeError) as e:
|
|
||||||
raise SystemExit(f"{e}")
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception("Unexpected error loading database")
|
|
||||||
raise SystemExit(f"{e}")
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
|
|
||||||
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
a: str = "" # action (e.g., "migrate", "login", "create_user")
|
|
||||||
v: int = 0 # schema version after this change
|
|
||||||
u: str | None = None # user UUID who performed the action (None for system)
|
|
||||||
diff: dict
|
|
||||||
|
|
||||||
|
|
||||||
def compute_diff(previous: dict, current: dict) -> dict | None:
|
|
||||||
return jsondiff.diff(previous, current, marshal=True) or None
|
|
||||||
|
|
||||||
|
|
||||||
# Actions that are allowed to create a new database file
|
|
||||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
|
|
||||||
|
|
||||||
|
|
||||||
class JsonlStore:
|
|
||||||
"""JSONL persistence layer for a DB instance."""
|
|
||||||
|
|
||||||
def __init__(self, db: DB, db_path: str):
|
|
||||||
self.db: DB = db
|
|
||||||
self.db_path = Path(db_path)
|
|
||||||
self._file = LockedFile()
|
|
||||||
self._flush_failed = False
|
|
||||||
self._statedict: dict[str, Any] = {}
|
|
||||||
self._pending_changes: deque[ChangeRecord] = deque()
|
|
||||||
self._current_action: str = "system"
|
|
||||||
self._current_user: str | None = None
|
|
||||||
self._in_transaction: bool = False
|
|
||||||
self._transaction_snapshot: dict[str, Any] | None = None
|
|
||||||
self._v: int = DBVER # Schema version for new databases
|
|
||||||
self._snapshot = SnapshotState()
|
|
||||||
|
|
||||||
async def load(
|
|
||||||
self, db_path: str | None = None, *, rp_id: str = "localhost"
|
|
||||||
) -> None:
|
|
||||||
"""Load data from JSONL change log."""
|
|
||||||
if db_path is not None:
|
|
||||||
self.db_path = Path(db_path)
|
|
||||||
self._rp_id = rp_id
|
|
||||||
if not self.db_path.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Open with exclusive write lock and read contents — single threadpool call
|
|
||||||
content = await asyncio.to_thread(self._file.open_and_read, self.db_path)
|
|
||||||
|
|
||||||
# Replay change log to reconstruct state (snapshot-accelerated)
|
|
||||||
try:
|
|
||||||
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
|
|
||||||
|
|
||||||
# Set previous state for diffing (will be updated by _queue_change)
|
|
||||||
self._statedict = copy.deepcopy(statedict)
|
|
||||||
|
|
||||||
# Callback to persist each migration
|
|
||||||
async def persist_migration(
|
|
||||||
action: str, new_version: int, current: dict
|
|
||||||
) -> None:
|
|
||||||
self._v = new_version
|
|
||||||
self._queue_change(action, new_version, current)
|
|
||||||
|
|
||||||
# Apply schema migrations one at a time
|
|
||||||
await apply_all_migrations(
|
|
||||||
statedict,
|
|
||||||
self._v,
|
|
||||||
persist_migration,
|
|
||||||
MigrationCtx(rp_id=rp_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Decode to msgspec struct
|
|
||||||
decoder = msgspec.json.Decoder(DB)
|
|
||||||
self.db = decoder.decode(msgspec.json.encode(statedict))
|
|
||||||
self.db._store = self
|
|
||||||
|
|
||||||
# Normalize via msgspec round-trip (handles omit_defaults etc.)
|
|
||||||
# This ensures _previous_builtins matches what msgspec would produce
|
|
||||||
normalized_dict = msgspec.to_builtins(self.db)
|
|
||||||
await persist_migration("migrate:msgspec", self._v, normalized_dict)
|
|
||||||
|
|
||||||
def _queue_change(
|
|
||||||
self, action: str, version: int, current: dict, user: str | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Queue a change record and log it.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action: The action name for the change record
|
|
||||||
version: The schema version for the change record
|
|
||||||
current: The current state as a plain dict
|
|
||||||
user: Optional user UUID who performed the action
|
|
||||||
"""
|
|
||||||
diff = compute_diff(self._statedict, current)
|
|
||||||
if not diff:
|
|
||||||
return
|
|
||||||
self._pending_changes.append(
|
|
||||||
ChangeRecord(
|
|
||||||
a=action,
|
|
||||||
v=version,
|
|
||||||
u=user,
|
|
||||||
diff=diff,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log the change with user display name if available
|
|
||||||
user_display = None
|
|
||||||
if user:
|
|
||||||
try:
|
|
||||||
user_uuid = UUID(user)
|
|
||||||
if user_uuid in self.db.users:
|
|
||||||
user_display = self.db.users[user_uuid].display_name
|
|
||||||
except (ValueError, KeyError):
|
|
||||||
user_display = user
|
|
||||||
|
|
||||||
log_change(action, diff, user_display, self._statedict, self.db)
|
|
||||||
self._statedict = copy.deepcopy(current)
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def transaction(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
ctx: SessionContext | None = None,
|
|
||||||
*,
|
|
||||||
user: str | None = None,
|
|
||||||
):
|
|
||||||
"""Wrap writes in transaction. Queues change on successful exit.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action: Describes the operation (e.g., "Created user", "Login")
|
|
||||||
ctx: Session context of user performing the action (None for system operations)
|
|
||||||
user: User UUID string (alternative to ctx when full context unavailable)
|
|
||||||
"""
|
|
||||||
if self._in_transaction:
|
|
||||||
raise RuntimeError("Nested transactions are not supported")
|
|
||||||
|
|
||||||
# Check for out-of-transaction modifications
|
|
||||||
current_state = msgspec.to_builtins(self.db)
|
|
||||||
if current_state != self._statedict:
|
|
||||||
# Allow bootstrap to create a new database from empty state
|
|
||||||
is_bootstrap = action in _BOOTSTRAP_ACTIONS
|
|
||||||
if is_bootstrap and not self._statedict:
|
|
||||||
pass # Expected: creating database from scratch
|
|
||||||
else:
|
|
||||||
diff = compute_diff(self._statedict, current_state)
|
|
||||||
diff_json = msgspec.json.encode(diff).decode()
|
|
||||||
_logger.critical(
|
|
||||||
"Database state modified outside of transaction! "
|
|
||||||
"This indicates a bug where DB changes occurred without a transaction wrapper.\n"
|
|
||||||
f"Changes detected:\n{diff_json}"
|
|
||||||
)
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
old_action = self._current_action
|
|
||||||
old_user = self._current_user
|
|
||||||
self._current_action = action
|
|
||||||
# Prefer ctx.user.uuid if ctx provided, otherwise use user param
|
|
||||||
self._current_user = str(ctx.user.uuid) if ctx else user
|
|
||||||
self._in_transaction = True
|
|
||||||
self._transaction_snapshot = current_state
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
current = msgspec.to_builtins(self.db)
|
|
||||||
self._queue_change(
|
|
||||||
self._current_action, self._v, current, self._current_user
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Rollback on error: restore from snapshot
|
|
||||||
_logger.warning("Transaction '%s' failed, rolling back changes", action)
|
|
||||||
if self._transaction_snapshot is not None:
|
|
||||||
decoder = msgspec.json.Decoder(DB)
|
|
||||||
self.db = decoder.decode(
|
|
||||||
msgspec.json.encode(self._transaction_snapshot)
|
|
||||||
)
|
|
||||||
self.db._store = self
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
self._current_action = old_action
|
|
||||||
self._current_user = old_user
|
|
||||||
self._in_transaction = False
|
|
||||||
self._transaction_snapshot = None
|
|
||||||
|
|
||||||
async def flush(self) -> None:
|
|
||||||
"""Write all pending changes to disk.
|
|
||||||
|
|
||||||
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()
|
|
||||||
+130
-19
@@ -2,46 +2,157 @@
|
|||||||
Database lifecycle: initialization and maintenance.
|
Database lifecycle: initialization and maintenance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Any, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from kanta import Kanta
|
||||||
|
from kanta.exceptions import DatabaseError
|
||||||
|
|
||||||
import paskia.db.operations as _ops
|
import paskia.db.operations as _ops
|
||||||
from paskia import oidc_notify
|
from paskia import oidc_notify
|
||||||
from paskia.authsession import EXPIRES
|
from paskia.authsession import EXPIRES
|
||||||
from paskia.db.jsonl import JsonlStore
|
from paskia.db.bootstrap import bootstrap, log_reset_link
|
||||||
|
from paskia.db.paths import db_file_path
|
||||||
|
from paskia.db.structs import DB
|
||||||
|
from paskia.util.runtime import config as runtime_config
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def init(rp_id: str, *args, **kwargs):
|
runtime = runtime_config()
|
||||||
"""Load database from JSONL file."""
|
if runtime is None:
|
||||||
if _ops._db._store:
|
raise RuntimeError("PASKIA_CONFIG must be defined before importing db.lifecycle")
|
||||||
_logger.debug("Database already initialized, skipping reload")
|
|
||||||
return
|
kanta = Kanta(
|
||||||
db_path = os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb")
|
str(db_file_path(rp_id=runtime.config.rp_id, create_root=False)),
|
||||||
store = JsonlStore(_ops._db, db_path)
|
_ops._db,
|
||||||
await store.load(db_path, rp_id=rp_id)
|
migrations="paskia.db.migrations",
|
||||||
_ops._db = store.db
|
)
|
||||||
_ops._db._store = store
|
kanta.ctx.rp_id = runtime.config.rp_id
|
||||||
# Request a snapshot after successful startup
|
_ops._db._store = kanta
|
||||||
store._snapshot.request_force()
|
|
||||||
|
|
||||||
|
def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
|
||||||
|
"""Resolve UUID to label from serialized state dict."""
|
||||||
|
if not state:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Display-name based entities.
|
||||||
|
for bucket in ("users", "orgs", "roles", "permissions"):
|
||||||
|
entity = state.get(bucket, {}).get(uuid_str)
|
||||||
|
if isinstance(entity, dict):
|
||||||
|
display_name = entity.get("display_name")
|
||||||
|
if isinstance(display_name, str) and display_name:
|
||||||
|
return display_name
|
||||||
|
|
||||||
|
# OIDC clients use "name" instead of "display_name".
|
||||||
|
client = state.get("oidc", {}).get("clients", {}).get(uuid_str)
|
||||||
|
if isinstance(client, dict):
|
||||||
|
name = client.get("name")
|
||||||
|
if isinstance(name, str) and name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_uuid_label(
|
||||||
|
uuid_str: str,
|
||||||
|
*,
|
||||||
|
previous: dict | None = None,
|
||||||
|
current: dict | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve known entity UUIDs to human-readable labels."""
|
||||||
|
# Prefer previous state so deletions/renames still show a useful label.
|
||||||
|
label = _lookup_uuid_in_state(previous, uuid_str)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
label = _lookup_uuid_in_state(current, uuid_str)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
|
||||||
|
try:
|
||||||
|
uid = UUID(uuid_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if uid in _ops._db.users:
|
||||||
|
return _ops._db.users[uid].display_name
|
||||||
|
if uid in _ops._db.orgs:
|
||||||
|
return _ops._db.orgs[uid].display_name
|
||||||
|
if uid in _ops._db.roles:
|
||||||
|
return _ops._db.roles[uid].display_name
|
||||||
|
if uid in _ops._db.permissions:
|
||||||
|
return _ops._db.permissions[uid].display_name
|
||||||
|
if uid in _ops._db.oidc.clients:
|
||||||
|
return _ops._db.oidc.clients[uid].name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@kanta.logfmt
|
||||||
|
def format_log_uuid(
|
||||||
|
value: Any,
|
||||||
|
path: str,
|
||||||
|
previous: Annotated[dict, "pre"] | None = None,
|
||||||
|
current: Annotated[dict, "post"] | None = None,
|
||||||
|
) -> Optional[str]: # noqa: UP045
|
||||||
|
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
|
||||||
|
# Censor sensitive OIDC key material regardless of value type, but only
|
||||||
|
# when formatting the value: path components are passed with the component
|
||||||
|
# itself as value and must stay visible ("oidc.key = <hidden>").
|
||||||
|
if (path == "oidc.key" or path.endswith(".oidc.key")) and value != "key":
|
||||||
|
return "<hidden>"
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Works for transaction actor metadata ($user), values, and path components.
|
||||||
|
return _resolve_uuid_label(value, previous=previous, current=current)
|
||||||
|
|
||||||
|
|
||||||
|
@kanta.fatal_error
|
||||||
|
def terminate(error: DatabaseError) -> None:
|
||||||
|
"""Fatal error callback: terminate the process on background write failures."""
|
||||||
|
logger.error("Fatal database error: %s", error)
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
|
||||||
|
|
||||||
|
@kanta.bootstrap
|
||||||
|
def bootstrap_db(data: DB) -> None:
|
||||||
|
reset_passphrase = bootstrap(data, config=runtime.config)
|
||||||
|
log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
||||||
|
|
||||||
|
|
||||||
|
async def init():
|
||||||
|
"""Load database from JSONL file using kanta.
|
||||||
|
|
||||||
|
If the database file is empty, the configured bootstrap callback seeds it
|
||||||
|
with default permissions, organization, role, admin user and a reset token.
|
||||||
|
"""
|
||||||
|
rootpath = Path(kanta.filename).parent
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(rootpath.mkdir, parents=True, exist_ok=True)
|
||||||
|
await kanta.open()
|
||||||
|
except Exception as e:
|
||||||
|
raise SystemExit(f"{e}") from e
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired() -> int:
|
def cleanup_expired() -> int:
|
||||||
"""Remove expired sessions and reset tokens. Returns count removed."""
|
"""Remove expired sessions and reset tokens. Returns count removed."""
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
count = 0
|
|
||||||
limit = now - EXPIRES
|
limit = now - EXPIRES
|
||||||
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
|
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
|
||||||
if expired_sessions:
|
if expired_sessions:
|
||||||
oidc_notify.schedule_notifications(expired_sessions)
|
oidc_notify.schedule_notifications(expired_sessions)
|
||||||
with _ops._db.transaction("expiry"):
|
with kanta.transaction("expiry"):
|
||||||
for k in expired_sessions:
|
for k in expired_sessions:
|
||||||
del _ops._db.sessions[k]
|
del _ops._db.sessions[k]
|
||||||
count += 1
|
|
||||||
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
|
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
|
||||||
for k in expired_tokens:
|
for k in expired_tokens:
|
||||||
del _ops._db.reset_tokens[k]
|
del _ops._db.reset_tokens[k]
|
||||||
count += 1
|
return len(expired_sessions) + len(expired_tokens)
|
||||||
return count
|
|
||||||
|
|||||||
@@ -1,466 +0,0 @@
|
|||||||
"""
|
|
||||||
Database change logging with pretty-printed diffs.
|
|
||||||
|
|
||||||
Provides a logger for JSONL database changes that formats diffs
|
|
||||||
in a human-readable path.notation style with color coding.
|
|
||||||
|
|
||||||
UUIDs are replaced with display names where available, or the full UUID string
|
|
||||||
for types without display names.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from paskia.db.structs import DB
|
|
||||||
|
|
||||||
logger = logging.getLogger("paskia.db")
|
|
||||||
|
|
||||||
# UUID regex pattern (8-4-4-4-12 hex format)
|
|
||||||
_UUID_PATTERN = re.compile(
|
|
||||||
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pattern to match control characters and bidirectional overrides
|
|
||||||
_UNSAFE_CHARS = re.compile(
|
|
||||||
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
|
|
||||||
r"\u200e\u200f" # LRM, RLM
|
|
||||||
r"\u202a-\u202e" # LRE, RLE, PDF, LRO, RLO
|
|
||||||
r"\u2066-\u2069" # LRI, RLI, FSI, PDI
|
|
||||||
r"]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ANSI color codes (matching FastAPI logging style)
|
|
||||||
_RESET = "\033[0m"
|
|
||||||
_SEP = "\033[38;5;242m" # Dark grey for separators (like host/timing in access log)
|
|
||||||
_PATH_PREFIX = "\033[38;5;242m" # Dark grey for path prefix (like host in access log)
|
|
||||||
_PATH_FINAL = "\033[38;5;250m" # Default for final element (like path in access log)
|
|
||||||
_DELETE = "\033[1;31m" # Red for deletions
|
|
||||||
_ADD = "\033[0;32m" # Green for additions
|
|
||||||
_ACTION = "\033[1;34m" # Bold blue for action name
|
|
||||||
_USER = "\033[0;34m" # Blue for user display
|
|
||||||
|
|
||||||
|
|
||||||
def _is_uuid(value: str) -> bool:
|
|
||||||
"""Check if a string is a UUID."""
|
|
||||||
return bool(_UUID_PATTERN.match(value))
|
|
||||||
|
|
||||||
|
|
||||||
class UuidResolver:
|
|
||||||
"""Resolve UUIDs to display names or short suffixes.
|
|
||||||
|
|
||||||
Uses the previous state for lookups to show the name before any changes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
|
|
||||||
self._db = db
|
|
||||||
self._previous = previous
|
|
||||||
|
|
||||||
def resolve(self, uuid_str: str) -> str:
|
|
||||||
"""Resolve a UUID to its display name or the full UUID string."""
|
|
||||||
display = self._get_display_name(uuid_str)
|
|
||||||
if display:
|
|
||||||
return display
|
|
||||||
return uuid_str
|
|
||||||
|
|
||||||
def _get_display_name(self, uuid_str: str) -> str | None:
|
|
||||||
"""Look up display name for a UUID.
|
|
||||||
|
|
||||||
First checks the previous state (to show names before changes),
|
|
||||||
then falls back to the current database.
|
|
||||||
"""
|
|
||||||
# Try previous state first (for showing name before a change)
|
|
||||||
name = self._lookup_in_previous(uuid_str)
|
|
||||||
if name:
|
|
||||||
return name
|
|
||||||
|
|
||||||
# Fall back to current database
|
|
||||||
return self._lookup_in_db(uuid_str)
|
|
||||||
|
|
||||||
def _lookup_in_previous(self, uuid_str: str) -> str | None:
|
|
||||||
"""Look up display name in the previous state dict."""
|
|
||||||
if not self._previous:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check users
|
|
||||||
if "users" in self._previous and uuid_str in self._previous["users"]:
|
|
||||||
user_data = self._previous["users"][uuid_str]
|
|
||||||
if isinstance(user_data, dict) and "display_name" in user_data:
|
|
||||||
return user_data["display_name"]
|
|
||||||
|
|
||||||
# Check orgs
|
|
||||||
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
|
|
||||||
org_data = self._previous["orgs"][uuid_str]
|
|
||||||
if isinstance(org_data, dict) and "display_name" in org_data:
|
|
||||||
return org_data["display_name"]
|
|
||||||
|
|
||||||
# Check roles
|
|
||||||
if "roles" in self._previous and uuid_str in self._previous["roles"]:
|
|
||||||
role_data = self._previous["roles"][uuid_str]
|
|
||||||
if isinstance(role_data, dict) and "display_name" in role_data:
|
|
||||||
return role_data["display_name"]
|
|
||||||
|
|
||||||
# Check permissions
|
|
||||||
if (
|
|
||||||
"permissions" in self._previous
|
|
||||||
and uuid_str in self._previous["permissions"]
|
|
||||||
):
|
|
||||||
perm_data = self._previous["permissions"][uuid_str]
|
|
||||||
if isinstance(perm_data, dict) and "display_name" in perm_data:
|
|
||||||
return perm_data["display_name"]
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _lookup_in_db(self, uuid_str: str) -> str | None:
|
|
||||||
"""Look up display name in the current database."""
|
|
||||||
if not self._db:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
uuid_obj = UUID(uuid_str)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check users
|
|
||||||
if uuid_obj in self._db.users:
|
|
||||||
return self._db.users[uuid_obj].display_name
|
|
||||||
|
|
||||||
# Check orgs
|
|
||||||
if uuid_obj in self._db.orgs:
|
|
||||||
return self._db.orgs[uuid_obj].display_name
|
|
||||||
|
|
||||||
# Check roles
|
|
||||||
if uuid_obj in self._db.roles:
|
|
||||||
return self._db.roles[uuid_obj].display_name
|
|
||||||
|
|
||||||
# Check permissions
|
|
||||||
if uuid_obj in self._db.permissions:
|
|
||||||
return self._db.permissions[uuid_obj].display_name
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _format_value(
|
|
||||||
value: Any,
|
|
||||||
max_len: int = 60,
|
|
||||||
resolver: UuidResolver | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Format a value for display, truncating if needed.
|
|
||||||
|
|
||||||
If resolver is provided, UUIDs are replaced with display names or short suffixes.
|
|
||||||
"""
|
|
||||||
if value is None:
|
|
||||||
return "null"
|
|
||||||
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return "true" if value else "false"
|
|
||||||
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
if isinstance(value, str):
|
|
||||||
# Check if it's a UUID and resolve to display name
|
|
||||||
if resolver and _is_uuid(value):
|
|
||||||
return resolver.resolve(value)
|
|
||||||
# Filter out control characters and bidirectional overrides
|
|
||||||
value = _UNSAFE_CHARS.sub("", value)
|
|
||||||
# Truncate long strings
|
|
||||||
if len(value) > max_len:
|
|
||||||
return value[: max_len - 3] + "..."
|
|
||||||
return value
|
|
||||||
|
|
||||||
if isinstance(value, dict):
|
|
||||||
if not value:
|
|
||||||
return "{}"
|
|
||||||
# Check if all values are True - render as set-like {key1, key2}
|
|
||||||
all_true = all(v is True for v in value.values())
|
|
||||||
parts = []
|
|
||||||
for k, v in value.items():
|
|
||||||
# Replace UUID keys with display names
|
|
||||||
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
|
|
||||||
if all_true:
|
|
||||||
parts.append(key_display)
|
|
||||||
else:
|
|
||||||
val_display = _format_value(v, max_len=30, resolver=resolver)
|
|
||||||
parts.append(f"{key_display}: {val_display}")
|
|
||||||
return "{" + ", ".join(parts) + "}"
|
|
||||||
|
|
||||||
if isinstance(value, list):
|
|
||||||
if not value:
|
|
||||||
return "[]"
|
|
||||||
parts = [_format_value(v, max_len=30, resolver=resolver) for v in value]
|
|
||||||
return "[" + ", ".join(parts) + "]"
|
|
||||||
|
|
||||||
# Fallback for other types
|
|
||||||
text = str(value)
|
|
||||||
if len(text) > max_len:
|
|
||||||
text = text[: max_len - 3] + "..."
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _format_path(path: list[str], resolver: UuidResolver | None = None) -> str:
|
|
||||||
"""Format a path as dot notation with prefix in dark grey, final in default.
|
|
||||||
|
|
||||||
If resolver is provided, UUIDs in the path are replaced with display names.
|
|
||||||
"""
|
|
||||||
if not path:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# Replace UUIDs in path with display names
|
|
||||||
if resolver:
|
|
||||||
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
|
|
||||||
|
|
||||||
if len(path) == 1:
|
|
||||||
return f"{_PATH_FINAL}{path[0]}{_RESET}"
|
|
||||||
prefix = ".".join(path[:-1])
|
|
||||||
final = path[-1]
|
|
||||||
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
|
|
||||||
|
|
||||||
|
|
||||||
def _get_nested(data: dict | None, path: list[str]) -> Any:
|
|
||||||
"""Get a nested value from a dict by path, or None if not found."""
|
|
||||||
if data is None:
|
|
||||||
return None
|
|
||||||
current = data
|
|
||||||
for key in path:
|
|
||||||
if not isinstance(current, dict) or key not in current:
|
|
||||||
return None
|
|
||||||
current = current[key]
|
|
||||||
return current
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_changes(
|
|
||||||
diff: dict,
|
|
||||||
path: list[str],
|
|
||||||
changes: list[tuple[str, list[str], Any]],
|
|
||||||
previous: dict | None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Recursively collect changes from a diff into a flat list.
|
|
||||||
|
|
||||||
Each change is a tuple of (change_type, path, new_value).
|
|
||||||
change_type is one of: 'add', 'update', 'delete'
|
|
||||||
"""
|
|
||||||
if not isinstance(diff, dict):
|
|
||||||
# Leaf value - check if it existed before
|
|
||||||
existed = _get_nested(previous, path) is not None
|
|
||||||
changes.append(("update" if existed else "add", path, diff))
|
|
||||||
return
|
|
||||||
|
|
||||||
for key, value in diff.items():
|
|
||||||
if key == "$delete":
|
|
||||||
# $delete contains a list of keys to delete
|
|
||||||
if isinstance(value, list):
|
|
||||||
for deleted_key in value:
|
|
||||||
changes.append(("delete", path + [str(deleted_key)], None))
|
|
||||||
else:
|
|
||||||
changes.append(("delete", path + [str(value)], None))
|
|
||||||
|
|
||||||
elif key == "$replace":
|
|
||||||
# $replace replaces the entire collection at this path
|
|
||||||
# We need to track what was added and what was deleted
|
|
||||||
old_collection = _get_nested(previous, path)
|
|
||||||
old_keys = (
|
|
||||||
set(old_collection.keys())
|
|
||||||
if isinstance(old_collection, dict)
|
|
||||||
else set()
|
|
||||||
)
|
|
||||||
new_keys = set(value.keys()) if isinstance(value, dict) else set()
|
|
||||||
|
|
||||||
# Items that existed before but not in new = deleted
|
|
||||||
for deleted_key in old_keys - new_keys:
|
|
||||||
changes.append(("delete", path + [str(deleted_key)], None))
|
|
||||||
|
|
||||||
# Items in new collection
|
|
||||||
if isinstance(value, dict):
|
|
||||||
for rkey, rval in value.items():
|
|
||||||
existed = rkey in old_keys
|
|
||||||
changes.append(
|
|
||||||
("update" if existed else "add", path + [str(rkey)], rval)
|
|
||||||
)
|
|
||||||
elif value or not old_keys:
|
|
||||||
# Non-dict replacement or empty replacement with nothing before
|
|
||||||
changes.append(
|
|
||||||
("update" if old_collection is not None else "add", path, value)
|
|
||||||
)
|
|
||||||
|
|
||||||
elif key.startswith("$"):
|
|
||||||
# Other special operations (future-proofing)
|
|
||||||
changes.append(("add", path, {key: value}))
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Regular nested key - check if this item existed before
|
|
||||||
new_path = path + [str(key)]
|
|
||||||
existed = _get_nested(previous, new_path) is not None
|
|
||||||
if existed:
|
|
||||||
# Item exists - recurse to show specific field changes
|
|
||||||
_collect_changes(value, new_path, changes, previous)
|
|
||||||
else:
|
|
||||||
# New item - record as add with full value, don't recurse
|
|
||||||
changes.append(("add", new_path, value))
|
|
||||||
|
|
||||||
|
|
||||||
def _format_change_lines(
|
|
||||||
change_type: str,
|
|
||||||
path: list[str],
|
|
||||||
value: Any,
|
|
||||||
resolver: UuidResolver | None = None,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Format a single change as one or more lines.
|
|
||||||
|
|
||||||
If resolver is provided, UUIDs are replaced with display names.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Helper to format a value, checking for censored paths
|
|
||||||
def fmt_value(v: Any, child_path: list[str]) -> str:
|
|
||||||
if child_path[-2:] == ["oidc", "key"]:
|
|
||||||
return f"{_SEP}<hidden>{_RESET}"
|
|
||||||
return _format_value(v, resolver=resolver)
|
|
||||||
|
|
||||||
# Helper to format path with UUID replacement
|
|
||||||
def fmt_path(p: list[str]) -> list[str]:
|
|
||||||
if resolver:
|
|
||||||
return [resolver.resolve(x) if _is_uuid(x) else x for x in p]
|
|
||||||
return p
|
|
||||||
|
|
||||||
formatted_path = fmt_path(path)
|
|
||||||
|
|
||||||
if change_type == "delete":
|
|
||||||
if len(formatted_path) == 1:
|
|
||||||
return [f" {_DELETE}{formatted_path[0]} ✗{_RESET}"]
|
|
||||||
prefix = ".".join(formatted_path[:-1])
|
|
||||||
final = formatted_path[-1]
|
|
||||||
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
|
||||||
|
|
||||||
if change_type == "add":
|
|
||||||
# New item being created - only final element in green
|
|
||||||
# For dict values, show children on separate indented lines
|
|
||||||
if isinstance(value, dict) and value:
|
|
||||||
lines = []
|
|
||||||
# First line: path with green final element and grey =
|
|
||||||
if len(formatted_path) == 1:
|
|
||||||
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET}")
|
|
||||||
else:
|
|
||||||
prefix = ".".join(formatted_path[:-1])
|
|
||||||
final = formatted_path[-1]
|
|
||||||
lines.append(
|
|
||||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET}"
|
|
||||||
)
|
|
||||||
# Child lines: indented key: value, with aligned values
|
|
||||||
# Format keys (may contain UUIDs)
|
|
||||||
formatted_items = []
|
|
||||||
for k, v in value.items():
|
|
||||||
k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
|
|
||||||
v_str = fmt_value(v, path + [k])
|
|
||||||
formatted_items.append((k_display, v_str))
|
|
||||||
max_key_len = max(len(k) for k, _ in formatted_items)
|
|
||||||
field_width = max(max_key_len, 12) # minimum 12 chars
|
|
||||||
for k_display, v_str in formatted_items:
|
|
||||||
padding = " " * (field_width - len(k_display))
|
|
||||||
lines.append(f" {k_display}{_SEP}:{_RESET}{padding} {v_str}")
|
|
||||||
return lines
|
|
||||||
else:
|
|
||||||
value_str = fmt_value(value, path)
|
|
||||||
if len(formatted_path) == 1:
|
|
||||||
return [
|
|
||||||
f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET} {value_str}"
|
|
||||||
]
|
|
||||||
prefix = ".".join(formatted_path[:-1])
|
|
||||||
final = formatted_path[-1]
|
|
||||||
return [
|
|
||||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET} {value_str}"
|
|
||||||
]
|
|
||||||
|
|
||||||
# update: Existing item being updated - normal path colors
|
|
||||||
value_str = fmt_value(value, path)
|
|
||||||
path_str = _format_path(path, resolver=resolver)
|
|
||||||
return [f" {path_str} {_SEP}={_RESET} {value_str}"]
|
|
||||||
|
|
||||||
|
|
||||||
def format_diff(
|
|
||||||
diff: dict, previous: dict | None = None, db: "DB | None" = None
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Format a JSON diff as human-readable lines.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
diff: The JSON diff dict
|
|
||||||
previous: The previous state dict (for determining add vs update)
|
|
||||||
db: Optional database for looking up display names
|
|
||||||
|
|
||||||
Returns a list of formatted lines (without newlines).
|
|
||||||
UUIDs are replaced with display names (using previous state for lookups).
|
|
||||||
"""
|
|
||||||
changes: list[tuple[str, list[str], Any]] = []
|
|
||||||
_collect_changes(diff, [], changes, previous)
|
|
||||||
|
|
||||||
if not changes:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Create resolver for UUID replacement (uses previous state for lookups)
|
|
||||||
resolver = UuidResolver(db, previous)
|
|
||||||
|
|
||||||
# Format each change
|
|
||||||
lines = []
|
|
||||||
for change_type, path, value in changes:
|
|
||||||
lines.extend(_format_change_lines(change_type, path, value, resolver))
|
|
||||||
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def format_action_header(action: str, user_display: str | None = None) -> str:
|
|
||||||
"""Format the action header line."""
|
|
||||||
action_str = f"{_ACTION}{action}{_RESET}"
|
|
||||||
if user_display:
|
|
||||||
user_str = f"{_USER}{user_display}{_RESET}"
|
|
||||||
return f"{action_str} by {user_str}"
|
|
||||||
return action_str
|
|
||||||
|
|
||||||
|
|
||||||
def log_change(
|
|
||||||
action: str,
|
|
||||||
diff: dict,
|
|
||||||
user_display: str | None = None,
|
|
||||||
previous: dict | None = None,
|
|
||||||
db: "DB | None" = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Log a database change with pretty-printed diff.
|
|
||||||
|
|
||||||
UUIDs are replaced with display names for readability. For types without
|
|
||||||
display names, the full UUID string is used.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action: The action name (e.g., "login", "admin:delete_user")
|
|
||||||
diff: The JSON diff dict
|
|
||||||
user_display: Optional display name of the user who performed the action
|
|
||||||
previous: The previous state dict (for determining add vs update)
|
|
||||||
db: Optional database for looking up display names
|
|
||||||
"""
|
|
||||||
header = format_action_header(action, user_display)
|
|
||||||
diff_lines = format_diff(diff, previous, db)
|
|
||||||
|
|
||||||
if not diff_lines:
|
|
||||||
logger.info(header)
|
|
||||||
return
|
|
||||||
|
|
||||||
if len(diff_lines) == 1:
|
|
||||||
# Single change - combine on one line
|
|
||||||
logger.info(f"{header}{diff_lines[0]}")
|
|
||||||
else:
|
|
||||||
# Multiple changes - header on its own line, then changes
|
|
||||||
logger.info(header)
|
|
||||||
for line in diff_lines:
|
|
||||||
logger.info(line)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_db_logging() -> None:
|
|
||||||
"""Configure the database logger to output to stderr without prefix."""
|
|
||||||
handler = logging.StreamHandler(sys.stderr)
|
|
||||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
||||||
logger.addHandler(handler)
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
logger.propagate = False
|
|
||||||
+11
-50
@@ -6,82 +6,43 @@ Each migration should be idempotent and only run when needed.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
import msgspec
|
from kanta import Kanta
|
||||||
|
|
||||||
from paskia.util.crypto import secret_key
|
from paskia.util.crypto import secret_key
|
||||||
|
|
||||||
|
|
||||||
class MigrationCtx(msgspec.Struct):
|
def migrate_v1(d: dict) -> None:
|
||||||
"""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:
|
def migrate_v2(d: dict, kanta: Kanta) -> None:
|
||||||
"""Add config field if missing."""
|
"""Add config field if missing."""
|
||||||
if "config" not in d:
|
if "config" not in d:
|
||||||
d["config"] = {"rp_id": ctx.rp_id}
|
d["config"] = {"rp_id": kanta.ctx.rp_id}
|
||||||
|
|
||||||
|
|
||||||
def migrate_v3(d: dict, ctx: MigrationCtx) -> None:
|
def migrate_v3(d: dict) -> None:
|
||||||
"""Ensure all users have visits field."""
|
"""Ensure all users have visits field."""
|
||||||
for user_data in d["users"].values():
|
for user_data in d["users"].values():
|
||||||
user_data.setdefault("visits", 0)
|
user_data.setdefault("visits", 0)
|
||||||
|
|
||||||
|
|
||||||
def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
|
def migrate_v4(d: dict) -> None:
|
||||||
"""OpenID Connect support and hardened session keys."""
|
"""OpenID Connect support and hardened session keys."""
|
||||||
# Session keys changed to hashes, drop old sessions
|
# Session keys changed to hashes, drop old sessions
|
||||||
d["sessions"] = {}
|
d["sessions"] = {}
|
||||||
# Create OIDC structure with a generated new key
|
# Create OIDC structure with a generated new key
|
||||||
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
|
d["oidc"] = {
|
||||||
|
"clients": {},
|
||||||
|
"key": base64.standard_b64encode(secret_key()).decode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def migrate_v5(d: dict, ctx: MigrationCtx) -> None:
|
def migrate_v5(d: dict) -> None:
|
||||||
"""Convert config.listen from str to list[str] if needed."""
|
"""Convert config.listen from str to list[str] if needed."""
|
||||||
listen = d["config"].get("listen")
|
listen = d["config"].get("listen")
|
||||||
if listen and isinstance(listen, str):
|
if listen and isinstance(listen, str):
|
||||||
d["config"]["listen"] = [listen]
|
d["config"]["listen"] = [listen]
|
||||||
|
|
||||||
|
|
||||||
migrations = sorted(
|
|
||||||
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
|
||||||
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
|
||||||
)
|
|
||||||
|
|
||||||
DBVER = len(migrations) # Used by bootstrap to set initial version
|
|
||||||
|
|
||||||
|
|
||||||
def apply_migrations_readonly(
|
|
||||||
data_dict: dict,
|
|
||||||
current_version: int,
|
|
||||||
ctx: MigrationCtx,
|
|
||||||
) -> int:
|
|
||||||
"""Apply migration functions in-place without persistence.
|
|
||||||
|
|
||||||
Returns the new version after all migrations.
|
|
||||||
"""
|
|
||||||
while current_version < DBVER:
|
|
||||||
migrations[current_version](data_dict, ctx)
|
|
||||||
current_version += 1
|
|
||||||
return current_version
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_all_migrations(
|
|
||||||
data_dict: dict,
|
|
||||||
current_version: int,
|
|
||||||
persist: Callable[[str, int, dict], Awaitable[None]],
|
|
||||||
ctx: MigrationCtx,
|
|
||||||
) -> None:
|
|
||||||
while current_version < DBVER:
|
|
||||||
migrations[current_version](data_dict, ctx)
|
|
||||||
current_version += 1
|
|
||||||
await persist(f"migrate:v{current_version}", current_version, data_dict)
|
|
||||||
|
|||||||
+56
-38
@@ -40,6 +40,26 @@ _UNSET = object()
|
|||||||
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
||||||
|
|
||||||
|
|
||||||
|
def _store():
|
||||||
|
"""Return active Kanta instance for the current DB object."""
|
||||||
|
store = _db._store
|
||||||
|
if store is None:
|
||||||
|
raise RuntimeError("Kanta store is not initialized")
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _transaction(
|
||||||
|
action: str,
|
||||||
|
ctx: SessionContext | None = None,
|
||||||
|
*,
|
||||||
|
user: str | None = None,
|
||||||
|
mtime: bool | datetime = True,
|
||||||
|
):
|
||||||
|
"""Create a Kanta transaction with minimal metadata mapping."""
|
||||||
|
user_id = str(ctx.user.uuid) if ctx else user
|
||||||
|
return _store().transaction(action, user=user_id, mtime=mtime)
|
||||||
|
|
||||||
|
|
||||||
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||||
"""Check if a preferred_username is already taken by another user."""
|
"""Check if a preferred_username is already taken by another user."""
|
||||||
if not username:
|
if not username:
|
||||||
@@ -58,7 +78,7 @@ def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
|||||||
|
|
||||||
def update_config(config: Config) -> None:
|
def update_config(config: Config) -> None:
|
||||||
"""Update the stored configuration."""
|
"""Update the stored configuration."""
|
||||||
with _db.transaction("update_config"):
|
with _transaction("update_config"):
|
||||||
_db.config = config
|
_db.config = config
|
||||||
|
|
||||||
|
|
||||||
@@ -66,7 +86,7 @@ def create_permission(perm: Permission, *, ctx: SessionContext | 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 _transaction("admin:create_permission", ctx):
|
||||||
perm.store()
|
perm.store()
|
||||||
|
|
||||||
|
|
||||||
@@ -84,7 +104,7 @@ def update_permission(
|
|||||||
"""
|
"""
|
||||||
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:update_permission", ctx):
|
with _transaction("admin:update_permission", ctx):
|
||||||
_db.permissions[uuid].scope = scope
|
_db.permissions[uuid].scope = scope
|
||||||
_db.permissions[uuid].display_name = display_name
|
_db.permissions[uuid].display_name = display_name
|
||||||
_db.permissions[uuid].domain = domain
|
_db.permissions[uuid].domain = domain
|
||||||
@@ -94,7 +114,7 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|||||||
"""Delete a permission and remove it from all roles."""
|
"""Delete a permission and remove it from all roles."""
|
||||||
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 _transaction("admin:delete_permission", ctx):
|
||||||
_db.permissions[uuid].delete()
|
_db.permissions[uuid].delete()
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +126,7 @@ 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)
|
now = datetime.now(UTC)
|
||||||
with _db.transaction("admin:create_org", ctx):
|
with _transaction("admin:create_org", ctx):
|
||||||
new_org = Org.create(display_name=org.display_name, created_at=now)
|
new_org = Org.create(display_name=org.display_name, created_at=now)
|
||||||
new_org.uuid = org.uuid
|
new_org.uuid = org.uuid
|
||||||
new_org.store()
|
new_org.store()
|
||||||
@@ -138,7 +158,7 @@ def update_org_name(
|
|||||||
"""Update organization display name."""
|
"""Update organization display name."""
|
||||||
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:update_org_name", ctx):
|
with _transaction("admin:update_org_name", ctx):
|
||||||
_db.orgs[uuid].display_name = display_name
|
_db.orgs[uuid].display_name = display_name
|
||||||
|
|
||||||
|
|
||||||
@@ -146,7 +166,7 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|||||||
"""Delete organization and all its roles/users."""
|
"""Delete organization and all its roles/users."""
|
||||||
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 _transaction("admin:delete_org", ctx):
|
||||||
_db.orgs[uuid].delete()
|
_db.orgs[uuid].delete()
|
||||||
|
|
||||||
|
|
||||||
@@ -163,7 +183,7 @@ def add_permission_to_org(
|
|||||||
if permission_uuid not in _db.permissions:
|
if permission_uuid not in _db.permissions:
|
||||||
raise ValueError(f"Permission {permission_uuid} not found")
|
raise ValueError(f"Permission {permission_uuid} not found")
|
||||||
|
|
||||||
with _db.transaction("admin:add_permission_to_org", ctx):
|
with _transaction("admin:add_permission_to_org", ctx):
|
||||||
_db.permissions[permission_uuid].orgs[org_uuid] = True
|
_db.permissions[permission_uuid].orgs[org_uuid] = True
|
||||||
|
|
||||||
|
|
||||||
@@ -180,7 +200,7 @@ def remove_permission_from_org(
|
|||||||
if permission_uuid not in _db.permissions:
|
if permission_uuid not in _db.permissions:
|
||||||
return # Permission not found, silently return
|
return # Permission not found, silently return
|
||||||
|
|
||||||
with _db.transaction("admin:remove_permission_from_org", ctx):
|
with _transaction("admin:remove_permission_from_org", ctx):
|
||||||
_db.permissions[permission_uuid].orgs.pop(org_uuid, None)
|
_db.permissions[permission_uuid].orgs.pop(org_uuid, None)
|
||||||
|
|
||||||
|
|
||||||
@@ -190,7 +210,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
|
|||||||
raise ValueError(f"Role {role.uuid} already exists")
|
raise ValueError(f"Role {role.uuid} already exists")
|
||||||
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 _transaction("admin:create_role", ctx):
|
||||||
role.store()
|
role.store()
|
||||||
|
|
||||||
|
|
||||||
@@ -203,7 +223,7 @@ def update_role_name(
|
|||||||
"""Update role display name."""
|
"""Update role display name."""
|
||||||
if uuid not in _db.roles:
|
if uuid not in _db.roles:
|
||||||
raise ValueError(f"Role {uuid} not found")
|
raise ValueError(f"Role {uuid} not found")
|
||||||
with _db.transaction("admin:update_role_name", ctx):
|
with _transaction("admin:update_role_name", ctx):
|
||||||
_db.roles[uuid].display_name = display_name
|
_db.roles[uuid].display_name = display_name
|
||||||
|
|
||||||
|
|
||||||
@@ -218,7 +238,7 @@ def add_permission_to_role(
|
|||||||
raise ValueError(f"Role {role_uuid} not found")
|
raise ValueError(f"Role {role_uuid} not found")
|
||||||
if permission_uuid not in _db.permissions:
|
if permission_uuid not in _db.permissions:
|
||||||
raise ValueError(f"Permission {permission_uuid} not found")
|
raise ValueError(f"Permission {permission_uuid} not found")
|
||||||
with _db.transaction("admin:add_permission_to_role", ctx):
|
with _transaction("admin:add_permission_to_role", ctx):
|
||||||
_db.roles[role_uuid].permissions[permission_uuid] = True
|
_db.roles[role_uuid].permissions[permission_uuid] = True
|
||||||
|
|
||||||
|
|
||||||
@@ -231,7 +251,7 @@ def remove_permission_from_role(
|
|||||||
"""Remove permission from role by UUID."""
|
"""Remove permission from role by UUID."""
|
||||||
if role_uuid not in _db.roles:
|
if role_uuid not in _db.roles:
|
||||||
raise ValueError(f"Role {role_uuid} not found")
|
raise ValueError(f"Role {role_uuid} not found")
|
||||||
with _db.transaction("admin:remove_permission_from_role", ctx):
|
with _transaction("admin:remove_permission_from_role", ctx):
|
||||||
_db.roles[role_uuid].permissions.pop(permission_uuid, None)
|
_db.roles[role_uuid].permissions.pop(permission_uuid, None)
|
||||||
|
|
||||||
|
|
||||||
@@ -243,7 +263,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|||||||
role = _db.roles[uuid]
|
role = _db.roles[uuid]
|
||||||
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 _transaction("admin:delete_role", ctx):
|
||||||
_db.roles[uuid].delete()
|
_db.roles[uuid].delete()
|
||||||
|
|
||||||
|
|
||||||
@@ -253,7 +273,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
|||||||
raise ValueError(f"User {new_user.uuid} already exists")
|
raise ValueError(f"User {new_user.uuid} already exists")
|
||||||
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 _transaction("admin:create_user", ctx):
|
||||||
new_user.store()
|
new_user.store()
|
||||||
|
|
||||||
|
|
||||||
@@ -280,7 +300,7 @@ def update_user_display_name(
|
|||||||
if not display_name:
|
if not display_name:
|
||||||
raise ValueError("Display name cannot be empty")
|
raise ValueError("Display name cannot be empty")
|
||||||
user = _db.users[uuid]
|
user = _db.users[uuid]
|
||||||
with _db.transaction("update_user_display_name", ctx):
|
with _transaction("update_user_display_name", ctx):
|
||||||
user.display_name = display_name
|
user.display_name = display_name
|
||||||
# Auto-fill preferred_username if not already set
|
# Auto-fill preferred_username if not already set
|
||||||
if user.preferred_username is None:
|
if user.preferred_username is None:
|
||||||
@@ -354,7 +374,7 @@ def update_user_info(
|
|||||||
elif len(telephone) > 32:
|
elif len(telephone) > 32:
|
||||||
raise ValueError("telephone too long")
|
raise ValueError("telephone too long")
|
||||||
|
|
||||||
with _db.transaction("update_user_info", ctx):
|
with _transaction("update_user_info", ctx):
|
||||||
if display_name is not _UNSET:
|
if display_name is not _UNSET:
|
||||||
user.display_name = display_name
|
user.display_name = display_name
|
||||||
if theme is not _UNSET:
|
if theme is not _UNSET:
|
||||||
@@ -378,7 +398,7 @@ def update_user_role(
|
|||||||
raise ValueError(f"User {uuid} not found")
|
raise ValueError(f"User {uuid} not found")
|
||||||
if role_uuid not in _db.roles:
|
if role_uuid not in _db.roles:
|
||||||
raise ValueError(f"Role {role_uuid} not found")
|
raise ValueError(f"Role {role_uuid} not found")
|
||||||
with _db.transaction("admin:update_user_role", ctx):
|
with _transaction("admin:update_user_role", ctx):
|
||||||
_db.users[uuid].role_uuid = role_uuid
|
_db.users[uuid].role_uuid = role_uuid
|
||||||
|
|
||||||
|
|
||||||
@@ -386,7 +406,7 @@ 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")
|
||||||
with _db.transaction("admin:delete_user", ctx):
|
with _transaction("admin:delete_user", ctx):
|
||||||
_db.users[uuid].delete()
|
_db.users[uuid].delete()
|
||||||
|
|
||||||
|
|
||||||
@@ -396,7 +416,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
|
|||||||
raise ValueError(f"Credential {cred.uuid} already exists")
|
raise ValueError(f"Credential {cred.uuid} already exists")
|
||||||
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 _transaction("create_credential", ctx):
|
||||||
cred.store()
|
cred.store()
|
||||||
|
|
||||||
|
|
||||||
@@ -410,7 +430,7 @@ def update_credential_sign_count(
|
|||||||
"""Update credential sign count and last_used."""
|
"""Update credential sign count and last_used."""
|
||||||
if uuid not in _db.credentials:
|
if uuid not in _db.credentials:
|
||||||
raise ValueError(f"Credential {uuid} not found")
|
raise ValueError(f"Credential {uuid} not found")
|
||||||
with _db.transaction("update_credential_sign_count", ctx):
|
with _transaction("update_credential_sign_count", ctx):
|
||||||
_db.credentials[uuid].sign_count = sign_count
|
_db.credentials[uuid].sign_count = sign_count
|
||||||
if last_used:
|
if last_used:
|
||||||
_db.credentials[uuid].last_used = last_used
|
_db.credentials[uuid].last_used = last_used
|
||||||
@@ -432,12 +452,12 @@ def delete_credential(
|
|||||||
if user_uuid is not None:
|
if user_uuid is not None:
|
||||||
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 _transaction("delete_credential", ctx):
|
||||||
cred.delete()
|
cred.delete()
|
||||||
|
|
||||||
|
|
||||||
def update_session(
|
def update_session(
|
||||||
key: bytes,
|
key: str,
|
||||||
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,
|
||||||
@@ -448,7 +468,7 @@ def update_session(
|
|||||||
"""Update session metadata."""
|
"""Update session metadata."""
|
||||||
if key not in _db.sessions:
|
if key not in _db.sessions:
|
||||||
raise ValueError("Session not found")
|
raise ValueError("Session not found")
|
||||||
with _db.transaction("update_session", ctx):
|
with _transaction("update_session", ctx):
|
||||||
s = _db.sessions[key]
|
s = _db.sessions[key]
|
||||||
if host is not None:
|
if host is not None:
|
||||||
s.host = host
|
s.host = host
|
||||||
@@ -460,9 +480,7 @@ def update_session(
|
|||||||
s.validated = validated
|
s.validated = validated
|
||||||
|
|
||||||
|
|
||||||
def set_session_host(
|
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
|
||||||
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)
|
||||||
|
|
||||||
@@ -480,7 +498,7 @@ def delete_session(
|
|||||||
raise ValueError("Session not found")
|
raise ValueError("Session not found")
|
||||||
|
|
||||||
oidc_notify.schedule_notifications([key])
|
oidc_notify.schedule_notifications([key])
|
||||||
with _db.transaction(action, ctx):
|
with _transaction(action, ctx):
|
||||||
_db.sessions[key].delete()
|
_db.sessions[key].delete()
|
||||||
|
|
||||||
|
|
||||||
@@ -499,7 +517,7 @@ def delete_sessions_for_user(
|
|||||||
|
|
||||||
keys = [s.key for s in user.sessions]
|
keys = [s.key for s in user.sessions]
|
||||||
oidc_notify.schedule_notifications(keys)
|
oidc_notify.schedule_notifications(keys)
|
||||||
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
with _transaction("admin:delete_sessions_for_user", ctx):
|
||||||
for sess in user.sessions:
|
for sess in user.sessions:
|
||||||
sess.delete()
|
sess.delete()
|
||||||
|
|
||||||
@@ -530,7 +548,7 @@ def create_reset_token(
|
|||||||
)
|
)
|
||||||
if token.key in _db.reset_tokens:
|
if token.key in _db.reset_tokens:
|
||||||
raise ValueError("Reset token already exists")
|
raise ValueError("Reset token already exists")
|
||||||
with _db.transaction("create_reset_token", ctx, user=user):
|
with _transaction("create_reset_token", ctx, user=user):
|
||||||
token.store()
|
token.store()
|
||||||
return passphrase
|
return passphrase
|
||||||
|
|
||||||
@@ -539,7 +557,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
|
|||||||
"""Delete a reset token."""
|
"""Delete a reset token."""
|
||||||
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 _transaction("delete_reset_token", ctx):
|
||||||
_db.reset_tokens[key].delete()
|
_db.reset_tokens[key].delete()
|
||||||
|
|
||||||
|
|
||||||
@@ -588,7 +606,7 @@ def login(
|
|||||||
validated=now,
|
validated=now,
|
||||||
)
|
)
|
||||||
user_str = str(user_uuid)
|
user_str = str(user_uuid)
|
||||||
with _db.transaction("login", user=user_str):
|
with _transaction("login", user=user_str):
|
||||||
session.store(now)
|
session.store(now)
|
||||||
# Update credential
|
# Update credential
|
||||||
_db.credentials[credential_uuid].sign_count = sign_count
|
_db.credentials[credential_uuid].sign_count = sign_count
|
||||||
@@ -615,7 +633,7 @@ def oidc_login(
|
|||||||
"""
|
"""
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
user_str = str(session.user_uuid)
|
user_str = str(session.user_uuid)
|
||||||
with _db.transaction("oidc_login", user=user_str):
|
with _transaction("oidc_login", user=user_str):
|
||||||
session.store(now)
|
session.store(now)
|
||||||
# Update credential
|
# Update credential
|
||||||
_db.credentials[credential_uuid].sign_count = sign_count
|
_db.credentials[credential_uuid].sign_count = sign_count
|
||||||
@@ -661,7 +679,7 @@ def create_credential_session(
|
|||||||
validated=now,
|
validated=now,
|
||||||
)
|
)
|
||||||
user_str = str(user_uuid)
|
user_str = str(user_uuid)
|
||||||
with _db.transaction("create_credential_session", user=user_str):
|
with _transaction("create_credential_session", user=user_str):
|
||||||
# Update display name if provided
|
# Update display name if provided
|
||||||
if display_name:
|
if display_name:
|
||||||
_db.users[user_uuid].display_name = display_name
|
_db.users[user_uuid].display_name = display_name
|
||||||
@@ -694,7 +712,7 @@ def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> N
|
|||||||
"""Create a new OIDC client."""
|
"""Create a new OIDC client."""
|
||||||
if client.uuid in _db.oidc.clients:
|
if client.uuid in _db.oidc.clients:
|
||||||
raise ValueError(f"OIDC client {client.uuid} already exists")
|
raise ValueError(f"OIDC client {client.uuid} already exists")
|
||||||
with _db.transaction("admin:create_oid_client", ctx):
|
with _transaction("admin:create_oid_client", ctx):
|
||||||
_db.oidc.clients[client.uuid] = client
|
_db.oidc.clients[client.uuid] = client
|
||||||
|
|
||||||
|
|
||||||
@@ -735,7 +753,7 @@ def update_oid_client(
|
|||||||
else client.backchannel_logout_uri
|
else client.backchannel_logout_uri
|
||||||
)
|
)
|
||||||
|
|
||||||
with _db.transaction("admin:update_oid_client", ctx):
|
with _transaction("admin:update_oid_client", ctx):
|
||||||
# Create updated client with new values
|
# Create updated client with new values
|
||||||
updated_client = Client(
|
updated_client = Client(
|
||||||
client_secret_hash=secret_hash
|
client_secret_hash=secret_hash
|
||||||
@@ -761,7 +779,7 @@ def reset_oid_client_secret(
|
|||||||
if client_uuid not in _db.oidc.clients:
|
if client_uuid not in _db.oidc.clients:
|
||||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||||
client = _db.oidc.clients[client_uuid]
|
client = _db.oidc.clients[client_uuid]
|
||||||
with _db.transaction("admin:reset_oid_client_secret", ctx):
|
with _transaction("admin:reset_oid_client_secret", ctx):
|
||||||
updated = Client(
|
updated = Client(
|
||||||
client_secret_hash=new_secret_hash,
|
client_secret_hash=new_secret_hash,
|
||||||
name=client.name,
|
name=client.name,
|
||||||
@@ -776,5 +794,5 @@ def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -
|
|||||||
"""Delete an OIDC client."""
|
"""Delete an OIDC client."""
|
||||||
if client_uuid not in _db.oidc.clients:
|
if client_uuid not in _db.oidc.clients:
|
||||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||||
with _db.transaction("admin:delete_oid_client", ctx):
|
with _transaction("admin:delete_oid_client", ctx):
|
||||||
del _db.oidc.clients[client_uuid]
|
del _db.oidc.clients[client_uuid]
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def db_root_path(*, rp_id: str = "localhost") -> Path:
|
||||||
|
"""Return the configured persistence root directory."""
|
||||||
|
return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb"))
|
||||||
|
|
||||||
|
|
||||||
|
def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||||
|
"""Return the JSONL database file path under the persistence root."""
|
||||||
|
root = db_root_path(rp_id=rp_id)
|
||||||
|
|
||||||
|
if root.is_file():
|
||||||
|
_migrate_legacy_db_file(root)
|
||||||
|
|
||||||
|
if create_root:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return root / "main.db"
|
||||||
|
|
||||||
|
|
||||||
|
def users_root_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||||
|
"""Return the filesystem root for persisted user files."""
|
||||||
|
root = db_root_path(rp_id=rp_id)
|
||||||
|
|
||||||
|
if root.is_file():
|
||||||
|
_migrate_legacy_db_file(root)
|
||||||
|
|
||||||
|
if create_root:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return root / "users"
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_legacy_db_file(legacy_path: Path) -> None:
|
||||||
|
"""Upgrade a legacy single-file database path into a directory root."""
|
||||||
|
temp_root = legacy_path.parent / f".{legacy_path.name}.migrating"
|
||||||
|
shutil.rmtree(temp_root, ignore_errors=True)
|
||||||
|
temp_root.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
temp_root.mkdir(parents=True)
|
||||||
|
legacy_path.replace(temp_root / "main.db")
|
||||||
|
temp_root.rename(legacy_path)
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
@@ -618,7 +619,7 @@ class Config(msgspec.Struct, omit_defaults=True):
|
|||||||
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||||
"""In-memory database. Access fields directly for reads."""
|
"""In-memory database. Access fields directly for reads."""
|
||||||
|
|
||||||
config: Config
|
config: Config = msgspec.field(default_factory=lambda: Config(rp_id="localhost"))
|
||||||
permissions: dict[UUID, Permission] = {}
|
permissions: dict[UUID, Permission] = {}
|
||||||
orgs: dict[UUID, Org] = {}
|
orgs: dict[UUID, Org] = {}
|
||||||
roles: dict[UUID, Role] = {}
|
roles: dict[UUID, Role] = {}
|
||||||
@@ -630,8 +631,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
# Store reference for persistence (not serialized)
|
# Optional store reference for non-global DB instances (e.g. tests).
|
||||||
self._store = None
|
self._store: Any | None = None
|
||||||
# Set the key fields on all stored objects
|
# Set the key fields on all stored objects
|
||||||
for uuid, perm in self.permissions.items():
|
for uuid, perm in self.permissions.items():
|
||||||
perm.uuid = uuid
|
perm.uuid = uuid
|
||||||
@@ -651,10 +652,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
for uuid, client in self.oidc.clients.items():
|
for uuid, client in self.oidc.clients.items():
|
||||||
client.uuid = uuid
|
client.uuid = uuid
|
||||||
|
|
||||||
def transaction(self, action, ctx=None, *, user=None):
|
|
||||||
"""Wrap writes in transaction. Delegates to JsonlStore."""
|
|
||||||
return self._store.transaction(action, ctx, user=user)
|
|
||||||
|
|
||||||
def session_ctx(
|
def session_ctx(
|
||||||
self, session_secret: str, host: str | None = None
|
self, session_secret: str, host: str | None = None
|
||||||
) -> SessionContext | None:
|
) -> SessionContext | None:
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from paskia.fastapi.mainapp import app
|
|
||||||
|
|
||||||
__all__ = ["app"]
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from paskia.fastapi.front import frontend
|
|||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import (
|
from paskia.util import (
|
||||||
|
avatar,
|
||||||
permutil,
|
permutil,
|
||||||
vitedev,
|
vitedev,
|
||||||
)
|
)
|
||||||
@@ -26,6 +27,7 @@ from paskia.util.apistructs import (
|
|||||||
ApiOrg,
|
ApiOrg,
|
||||||
ApiOrgResponse,
|
ApiOrgResponse,
|
||||||
ApiPermission,
|
ApiPermission,
|
||||||
|
ApiUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
@@ -79,7 +81,11 @@ async def admin_info(request: Request, auth=AUTH_COOKIE):
|
|||||||
org=ApiOrg.from_db(o),
|
org=ApiOrg.from_db(o),
|
||||||
permissions={p.uuid: p for p in o.permissions},
|
permissions={p.uuid: p for p in o.permissions},
|
||||||
roles={r.uuid: r for r in roles},
|
roles={r.uuid: r for r in roles},
|
||||||
users={u.uuid: u for r in roles for u in r.users},
|
users={
|
||||||
|
u.uuid: ApiUser.from_db(u, avatar_url=avatar.avatar_browser_url(u.uuid))
|
||||||
|
for r in roles
|
||||||
|
for u in r.users
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
|
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async def admin_create_oidc_client(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
client_uuid = UUID(client_id)
|
client_uuid = UUID(client_id)
|
||||||
except (ValueError, AttributeError):
|
except ValueError, AttributeError:
|
||||||
raise ValueError("client_id must be a valid UUID")
|
raise ValueError("client_id must be a valid UUID")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def _validate_permission_domain(domain: str | None) -> None:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
rp_id = passkey.instance.rp_id
|
rp_id = passkey.rp_id
|
||||||
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
||||||
return
|
return
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ async def admin_get_server_config(
|
|||||||
):
|
):
|
||||||
"""Get current server configuration (master admin only)."""
|
"""Get current server configuration (master admin only)."""
|
||||||
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||||
pk = passkey.instance
|
pk = passkey
|
||||||
config = db.data().config
|
config = db.data().config
|
||||||
return {
|
return {
|
||||||
"rp_name": pk.rp_name,
|
"rp_name": pk.rp_name,
|
||||||
@@ -46,7 +46,7 @@ async def admin_update_server_config(
|
|||||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||||
)
|
)
|
||||||
config = db.data().config
|
config = db.data().config
|
||||||
pk = passkey.instance
|
pk = passkey
|
||||||
|
|
||||||
rp_name = payload.get("rp_name", "").strip() or None
|
rp_name = payload.get("rp_name", "").strip() or None
|
||||||
auth_host = payload.get("auth_host", "").strip() or None
|
auth_host = payload.get("auth_host", "").strip() or None
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from paskia.fastapi import authz
|
|||||||
from paskia.fastapi.admin.errors import install_error_handlers
|
from paskia.fastapi.admin.errors import install_error_handlers
|
||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import hostutil, permutil
|
from paskia.util import avatar, hostutil, permutil
|
||||||
from paskia.util.apistructs import (
|
from paskia.util.apistructs import (
|
||||||
ApiAaguidInfo,
|
ApiAaguidInfo,
|
||||||
ApiCreateLinkResponse,
|
ApiCreateLinkResponse,
|
||||||
@@ -65,7 +65,7 @@ async def admin_update_user_role(
|
|||||||
raise ValueError("role_uuid is required")
|
raise ValueError("role_uuid is required")
|
||||||
try:
|
try:
|
||||||
new_role_uuid = UUID(role_uuid_str)
|
new_role_uuid = UUID(role_uuid_str)
|
||||||
except (ValueError, TypeError):
|
except ValueError, TypeError:
|
||||||
raise ValueError("Invalid role UUID")
|
raise ValueError("Invalid role UUID")
|
||||||
new_role = db.data().roles.get(new_role_uuid)
|
new_role = db.data().roles.get(new_role_uuid)
|
||||||
if not new_role or new_role.org_uuid != user.org.uuid:
|
if not new_role or new_role.org_uuid != user.org.uuid:
|
||||||
@@ -165,7 +165,7 @@ async def admin_get_user_detail(
|
|||||||
|
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
ApiUserDetail(
|
ApiUserDetail(
|
||||||
user=ApiUser.from_db(user),
|
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
|
||||||
credentials={c.uuid: c for c in user.credentials},
|
credentials={c.uuid: c for c in user.credentials},
|
||||||
aaguid_info={
|
aaguid_info={
|
||||||
k: ApiAaguidInfo(**v)
|
k: ApiAaguidInfo(**v)
|
||||||
|
|||||||
+53
-10
@@ -32,6 +32,7 @@ from paskia.util.apistructs import (
|
|||||||
ApiUserContext,
|
ApiUserContext,
|
||||||
ApiValidateResponse,
|
ApiValidateResponse,
|
||||||
)
|
)
|
||||||
|
from paskia.util.crypto import hash_secret
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=False)
|
bearer_auth = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
@@ -56,6 +57,12 @@ async def http_exception_handler(_request: Request, exc: HTTPException):
|
|||||||
_REFRESH_INTERVAL = timedelta(minutes=5)
|
_REFRESH_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_log_extra(request: Request, *parts: str) -> None:
|
||||||
|
values = [part for part in parts if part]
|
||||||
|
if values:
|
||||||
|
request.state.log_extra = " ".join(values)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(ValueError)
|
@app.exception_handler(ValueError)
|
||||||
async def value_error_handler(_request: Request, exc: ValueError):
|
async def value_error_handler(_request: Request, exc: ValueError):
|
||||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||||
@@ -72,10 +79,22 @@ async def auth_exception_handler(_request: Request, exc: authz.AuthException):
|
|||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def general_exception_handler(
|
async def general_exception_handler(
|
||||||
_request: Request, exc: Exception
|
request: Request, exc: Exception
|
||||||
): # pragma: no cover
|
): # pragma: no cover
|
||||||
logging.exception("Unhandled exception in API app")
|
logging.exception("Unhandled exception in API app")
|
||||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
# Identify the origin endpoint for proxied clients (e.g. forward auth)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": f"{request.url.path}: Internal server error"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_perm(perm: list[str]) -> list[tuple[str, ...]]:
|
||||||
|
"""Parse perm query arguments into groups of OR alternatives (400 on syntax error)."""
|
||||||
|
try:
|
||||||
|
return permutil.parse_perm_args(perm)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/validate")
|
@app.post("/validate")
|
||||||
@@ -87,10 +106,11 @@ async def validate_token(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
"""Validate session and return context. Refreshes session expiry."""
|
"""Validate session and return context. Refreshes session expiry."""
|
||||||
|
perm_groups = _parse_perm(perm)
|
||||||
try:
|
try:
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
" ".join(perm).split(),
|
perm_groups,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
max_age=max_age,
|
max_age=max_age,
|
||||||
)
|
)
|
||||||
@@ -104,12 +124,13 @@ async def validate_token(
|
|||||||
db.update_session(
|
db.update_session(
|
||||||
ctx.session.key,
|
ctx.session.key,
|
||||||
ip=get_client_ip(request),
|
ip=get_client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent") or "",
|
user_agent=request.headers.get("user-agent"),
|
||||||
validated=datetime.now(UTC),
|
validated=datetime.now(UTC),
|
||||||
ctx=ctx,
|
ctx=ctx,
|
||||||
)
|
)
|
||||||
session.set_session_cookie(response, auth)
|
session.set_session_cookie(response, auth)
|
||||||
renewed = True
|
renewed = True
|
||||||
|
_set_log_extra(request, ctx.session.key)
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
ApiValidateResponse(
|
ApiValidateResponse(
|
||||||
valid=True,
|
valid=True,
|
||||||
@@ -129,7 +150,8 @@ async def check_user(
|
|||||||
|
|
||||||
Query Params:
|
Query Params:
|
||||||
- user: UUID of the user to check.
|
- user: UUID of the user to check.
|
||||||
- perm: repeated permission scope the user must possess (ALL required).
|
- perm: repeated permission scope the user must possess (ALL required;
|
||||||
|
separate alternatives with '|' for OR semantics within a group).
|
||||||
|
|
||||||
Returns 200 with valid=True/False and the user's effective permissions,
|
Returns 200 with valid=True/False and the user's effective permissions,
|
||||||
scoped to the requesting host (domain-restricted permissions are filtered).
|
scoped to the requesting host (domain-restricted permissions are filtered).
|
||||||
@@ -160,9 +182,9 @@ async def check_user(
|
|||||||
continue
|
continue
|
||||||
effective_perms.append(p)
|
effective_perms.append(p)
|
||||||
|
|
||||||
required = " ".join(perm).split()
|
required_groups = _parse_perm(perm)
|
||||||
effective_scopes = {p.scope for p in effective_perms}
|
effective_scopes = {p.scope for p in effective_perms}
|
||||||
valid = permutil.has_all_scopes(effective_scopes, required)
|
valid = permutil.has_all_scopes_groups(effective_scopes, required_groups)
|
||||||
|
|
||||||
ctx = ApiSessionContext(
|
ctx = ApiSessionContext(
|
||||||
user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme),
|
user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme),
|
||||||
@@ -184,7 +206,8 @@ async def forward_authentication(
|
|||||||
"""Forward auth validation for Caddy/Nginx.
|
"""Forward auth validation for Caddy/Nginx.
|
||||||
|
|
||||||
Query Params:
|
Query Params:
|
||||||
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
- perm: repeated permission scopes the authenticated user must possess (ALL
|
||||||
|
required; separate alternatives with '|' for OR semantics within a group).
|
||||||
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
|
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
|
||||||
is older than this, user must re-authenticate.
|
is older than this, user must re-authenticate.
|
||||||
|
|
||||||
@@ -195,13 +218,30 @@ async def forward_authentication(
|
|||||||
- Otherwise: JSON response with error details and an `iframe` field
|
- Otherwise: JSON response with error details and an `iframe` field
|
||||||
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
|
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
|
||||||
"""
|
"""
|
||||||
|
forwarded_method = request.headers.get("x-forwarded-method", "").strip()
|
||||||
|
forwarded_uri = request.headers.get("x-forwarded-uri", "").strip()
|
||||||
|
forwarded = (
|
||||||
|
f"{forwarded_method} {forwarded_uri}"
|
||||||
|
if forwarded_method and forwarded_uri
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
_set_log_extra(request, forwarded)
|
||||||
|
|
||||||
|
try:
|
||||||
|
perm_groups = permutil.parse_perm_args(perm)
|
||||||
|
except ValueError:
|
||||||
|
# Identify the error origin for proxied clients; do not echo query args
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="/auth/api/forward: invalid perm argument"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
" ".join(perm).split(),
|
perm_groups,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
max_age=max_age,
|
max_age=max_age,
|
||||||
)
|
)
|
||||||
|
_set_log_extra(request, forwarded, ctx.session.key)
|
||||||
# Build permission scopes for Remote-Groups header
|
# Build permission scopes for Remote-Groups header
|
||||||
role_permissions = (
|
role_permissions = (
|
||||||
{p.scope for p in ctx.permissions} if ctx.permissions else set()
|
{p.scope for p in ctx.permissions} if ctx.permissions else set()
|
||||||
@@ -238,7 +278,7 @@ async def forward_authentication(
|
|||||||
|
|
||||||
@app.get("/settings")
|
@app.get("/settings")
|
||||||
async def get_settings():
|
async def get_settings():
|
||||||
pk = global_passkey.instance
|
pk = global_passkey
|
||||||
base_path = hostutil.ui_base_path()
|
base_path = hostutil.ui_base_path()
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
ApiSettings(
|
ApiSettings(
|
||||||
@@ -276,6 +316,8 @@ async def api_user_info(
|
|||||||
clear_session=True,
|
clear_session=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_set_log_extra(request, ctx.session.key)
|
||||||
|
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
await userinfo.build_user_info(
|
await userinfo.build_user_info(
|
||||||
user_uuid=ctx.user.uuid,
|
user_uuid=ctx.user.uuid,
|
||||||
@@ -351,5 +393,6 @@ async def api_set_session(
|
|||||||
if not ctx:
|
if not ctx:
|
||||||
raise HTTPException(401, f"Session not found on {host}")
|
raise HTTPException(401, f"Session not found on {host}")
|
||||||
|
|
||||||
|
_set_log_extra(request, hash_secret("cookie", secret))
|
||||||
session.set_session_cookie(response, secret)
|
session.set_session_cookie(response, secret)
|
||||||
return {"status": "ok", "user": str(ctx.user.uuid)}
|
return {"status": "ok", "user": str(ctx.user.uuid)}
|
||||||
|
|||||||
+30
-19
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -54,13 +55,17 @@ async def auth_error_content(exc: AuthException) -> dict:
|
|||||||
|
|
||||||
async def verify(
|
async def verify(
|
||||||
auth: str | None,
|
auth: str | None,
|
||||||
perm: list[str],
|
perm: list[str] | list[tuple[str, ...]],
|
||||||
match=permutil.has_all,
|
match: Callable | None = None,
|
||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
max_age: str | None = None,
|
max_age: str | None = None,
|
||||||
):
|
):
|
||||||
"""Validate session token and optional list of required permissions.
|
"""Validate session token and optional list of required permissions.
|
||||||
|
|
||||||
|
Each perm entry is either a scope pattern or a tuple of alternative
|
||||||
|
scope patterns (OR semantics within a group). All entries must be
|
||||||
|
satisfied (AND semantics).
|
||||||
|
|
||||||
Returns the session context.
|
Returns the session context.
|
||||||
|
|
||||||
Raises AuthException on failure with metadata for UI rendering.
|
Raises AuthException on failure with metadata for UI rendering.
|
||||||
@@ -83,6 +88,29 @@ async def verify(
|
|||||||
# User's theme preference for iframe (only if explicitly set)
|
# User's theme preference for iframe (only if explicitly set)
|
||||||
user_theme = ctx.user.theme if ctx.user.theme else None
|
user_theme = ctx.user.theme if ctx.user.theme else None
|
||||||
|
|
||||||
|
groups = [(p,) if isinstance(p, str) else tuple(p) for p in perm]
|
||||||
|
ok = match(ctx, perm) if match else permutil.has_all_groups(ctx, groups)
|
||||||
|
if not ok:
|
||||||
|
effective_scopes = (
|
||||||
|
{p.scope for p in (ctx.permissions or [])}
|
||||||
|
if ctx.permissions
|
||||||
|
else set(ctx.role.permissions or [])
|
||||||
|
)
|
||||||
|
missing = [
|
||||||
|
"|".join(g)
|
||||||
|
for g in groups
|
||||||
|
if not permutil.group_satisfied(effective_scopes, g)
|
||||||
|
]
|
||||||
|
log_permission_denied(
|
||||||
|
ctx, ["|".join(g) for g in groups], missing, require_all=True
|
||||||
|
)
|
||||||
|
raise AuthException(
|
||||||
|
status_code=403,
|
||||||
|
mode="forbidden",
|
||||||
|
detail="Permission required",
|
||||||
|
theme=user_theme,
|
||||||
|
)
|
||||||
|
|
||||||
# Check max_age requirement if specified
|
# Check max_age requirement if specified
|
||||||
if max_age:
|
if max_age:
|
||||||
try:
|
try:
|
||||||
@@ -97,21 +125,4 @@ async def verify(
|
|||||||
# Invalid max_age format - log but don't fail the request
|
# Invalid max_age format - log but don't fail the request
|
||||||
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
||||||
|
|
||||||
if not match(ctx, perm):
|
|
||||||
effective_scopes = (
|
|
||||||
{p.scope for p in (ctx.permissions or [])}
|
|
||||||
if ctx.permissions
|
|
||||||
else set(ctx.role.permissions or [])
|
|
||||||
)
|
|
||||||
missing = sorted(set(perm) - effective_scopes)
|
|
||||||
log_permission_denied(
|
|
||||||
ctx, perm, missing, require_all=(match == permutil.has_all)
|
|
||||||
)
|
|
||||||
raise AuthException(
|
|
||||||
status_code=403,
|
|
||||||
mode="forbidden",
|
|
||||||
detail="Permission required",
|
|
||||||
theme=user_theme,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
+35
-17
@@ -26,8 +26,8 @@ _METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
|||||||
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
||||||
_PATH = "\033[38;5;250m" # path (white)
|
_PATH = "\033[38;5;250m" # path (white)
|
||||||
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
|
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
|
||||||
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
_WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
|
||||||
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
_WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
|
||||||
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
|
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
|
||||||
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
|
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
|
||||||
_AUTHZ_USER = "\033[1;34m" # User info (light blue)
|
_AUTHZ_USER = "\033[1;34m" # User info (light blue)
|
||||||
@@ -112,7 +112,13 @@ def method_color(method: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def format_access_log(
|
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,
|
||||||
|
extra: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Format access log line with colors and aligned fields."""
|
"""Format access log line with colors and aligned fields."""
|
||||||
# Format components with fixed widths for alignment
|
# Format components with fixed widths for alignment
|
||||||
@@ -126,8 +132,11 @@ def format_access_log(
|
|||||||
host_str = f"{_HOST}{host}{_RESET}"
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
path_str = f"{_PATH}{path}{_RESET}"
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
|
|
||||||
# Format: "IP STATUS METHOD host path TIMING"
|
# Format: "IP STATUS METHOD host path [extra] TIMING"
|
||||||
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||||
|
return (
|
||||||
|
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# WebSocket connection counter (mod 100)
|
# WebSocket connection counter (mod 100)
|
||||||
@@ -152,20 +161,21 @@ def log_ws_open(ws) -> int:
|
|||||||
origin = ws.headers.get("origin")
|
origin = ws.headers.get("origin")
|
||||||
|
|
||||||
ip = format_client_ip(client).ljust(19)
|
ip = format_client_ip(client).ljust(19)
|
||||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
# ID right-aligned like status codes (3 chars), emoji formatted like method
|
||||||
|
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
|
||||||
|
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
|
||||||
|
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
|
||||||
|
|
||||||
# Determine if origin should be shown (omit when same as host)
|
# Determine if origin should be shown (omit when same as host)
|
||||||
# Origin header includes scheme (e.g., "https://example.com"), compare host part
|
# Origin header includes scheme (e.g., "https://example.com"), compare host part
|
||||||
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
|
||||||
|
|
||||||
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
|
||||||
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 ""
|
||||||
|
|
||||||
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
|
||||||
return ws_id
|
return ws_id
|
||||||
|
|
||||||
|
|
||||||
@@ -191,25 +201,29 @@ 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."""
|
||||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
# ID right-aligned like status codes (3 chars), "closed" formatted like method
|
||||||
|
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
|
||||||
|
# Pad within the dim color to keep full width in color (8 display chars)
|
||||||
|
closed_str = f"{_TIMING}closed {_RESET}"
|
||||||
timing = f"{duration * 1000:.0f}ms"
|
timing = f"{duration * 1000:.0f}ms"
|
||||||
|
|
||||||
# Convert close code to status text
|
# Convert close code to status text
|
||||||
if close_code is None:
|
if close_code is None:
|
||||||
status = "closed"
|
code = "----"
|
||||||
|
status = "unknown"
|
||||||
else:
|
else:
|
||||||
|
code = str(close_code)
|
||||||
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
||||||
|
|
||||||
# 🔌 aligned with status, ID aligned with method
|
# Status code and text in normal color, not dim
|
||||||
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
|
status_str = f"{code} {status}"
|
||||||
status_str = f"{_WS_STATUS}{status}{_RESET}"
|
|
||||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||||
|
|
||||||
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
|
||||||
|
|
||||||
|
|
||||||
def log_permission_denied(
|
def log_permission_denied(
|
||||||
ctx: "SessionContext", required: list[str], missing: list[str], *, require_all: bool
|
ctx: SessionContext, required: list[str], missing: list[str], *, require_all: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Log permission denied with org, role, user and highlighted missing scopes."""
|
"""Log permission denied with org, role, user and highlighted missing scopes."""
|
||||||
missing_set = set(missing)
|
missing_set = set(missing)
|
||||||
@@ -244,7 +258,11 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
|||||||
path = f"{path}?{request.url.query}"
|
path = f"{path}?{request.url.query}"
|
||||||
status = response.status_code
|
status = response.status_code
|
||||||
|
|
||||||
line = format_access_log(client, status, method, host, path, duration_ms)
|
extra = getattr(request.state, "log_extra", "")
|
||||||
|
|
||||||
|
line = format_access_log(
|
||||||
|
client, status, method, host, path, duration_ms, extra=extra
|
||||||
|
)
|
||||||
logger.info(line)
|
logger.info(line)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
+33
-32
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -6,13 +7,12 @@ from pathlib import Path
|
|||||||
import msgspec
|
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 kanta.logging import configure_logging as configure_kanta_logging
|
||||||
|
|
||||||
from paskia import authcode, db, globals
|
from paskia import authcode, db, remoteauth
|
||||||
from paskia.__main__ import DEVMODE
|
|
||||||
from paskia.bootstrap import bootstrap_if_needed
|
from paskia.bootstrap import bootstrap_if_needed
|
||||||
from paskia.db import start_background, stop_background
|
from paskia.db.background import start_background, stop_background
|
||||||
from paskia.db.background import flush
|
from paskia.db.lifecycle import kanta
|
||||||
from paskia.db.logging import configure_db_logging
|
|
||||||
from paskia.fastapi import admin, api, auth_host, oid, ws
|
from paskia.fastapi import admin, api, auth_host, oid, ws
|
||||||
from paskia.fastapi.admin.adminapp import adminapp
|
from paskia.fastapi.admin.adminapp import adminapp
|
||||||
|
|
||||||
@@ -21,11 +21,12 @@ 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.constants import DEVMODE
|
||||||
from paskia.util.runtime import RuntimeConfig
|
from paskia.util.runtime import RuntimeConfig
|
||||||
|
|
||||||
# Configure custom logging
|
# Configure custom logging
|
||||||
configure_access_logging()
|
configure_access_logging()
|
||||||
configure_db_logging()
|
configure_kanta_logging()
|
||||||
|
|
||||||
_access_logger = logging.getLogger("paskia.access")
|
_access_logger = logging.getLogger("paskia.access")
|
||||||
|
|
||||||
@@ -43,34 +44,33 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
"""
|
"""
|
||||||
runtime = msgspec.json.decode(os.environ["PASKIA_CONFIG"], type=RuntimeConfig)
|
runtime = msgspec.json.decode(os.environ["PASKIA_CONFIG"], type=RuntimeConfig)
|
||||||
|
|
||||||
try:
|
await asyncio.to_thread(
|
||||||
await globals.init(
|
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
|
||||||
rp_id=runtime.config.rp_id,
|
)
|
||||||
rp_name=runtime.config.rp_name,
|
async with kanta:
|
||||||
origins=runtime.config.origins,
|
try:
|
||||||
bootstrap=False,
|
await remoteauth.init()
|
||||||
)
|
await authcode.start()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logging.error(f"⚠️ {e}")
|
logging.error(f"⚠️ {e}")
|
||||||
# Re-raise to fail fast
|
# Re-raise to fail fast
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Bootstrap and persist config now that the full DB is loaded
|
# Bootstrap and persist config now that the full DB is loaded
|
||||||
await bootstrap_if_needed(config=runtime.config)
|
await bootstrap_if_needed(config=runtime.config)
|
||||||
if runtime.save:
|
if runtime.save:
|
||||||
db.update_config(runtime.config)
|
db.update_config(runtime.config)
|
||||||
await flush()
|
|
||||||
|
|
||||||
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
||||||
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
||||||
if app.debug:
|
if app.debug:
|
||||||
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()
|
await authcode.stop()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -126,6 +126,7 @@ async def openid_configuration(request: Request):
|
|||||||
"name",
|
"name",
|
||||||
"preferred_username",
|
"preferred_username",
|
||||||
"email",
|
"email",
|
||||||
|
"picture",
|
||||||
"groups",
|
"groups",
|
||||||
"sid",
|
"sid",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from fastapi.security import HTTPBearer
|
|||||||
|
|
||||||
from paskia import authcode, db
|
from paskia import authcode, db
|
||||||
from paskia.db.structs import Session
|
from paskia.db.structs import Session
|
||||||
from paskia.util import oidjwt
|
from paskia.util import avatar, oidjwt
|
||||||
from paskia.util.crypto import hash_secret
|
from paskia.util.crypto import hash_secret
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -361,6 +361,7 @@ def _build_token_response(
|
|||||||
name=user.display_name,
|
name=user.display_name,
|
||||||
preferred_username=user.preferred_username,
|
preferred_username=user.preferred_username,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
|
picture=avatar.current_avatar_url(user.uuid),
|
||||||
groups=groups or None,
|
groups=groups or None,
|
||||||
auth_time=auth_time,
|
auth_time=auth_time,
|
||||||
)
|
)
|
||||||
@@ -421,7 +422,7 @@ async def userinfo(
|
|||||||
# Get user
|
# Get user
|
||||||
try:
|
try:
|
||||||
user_uuid = UUID(payload["sub"])
|
user_uuid = UUID(payload["sub"])
|
||||||
except (KeyError, ValueError):
|
except KeyError, ValueError:
|
||||||
raise HTTPException(401, "Invalid token")
|
raise HTTPException(401, "Invalid token")
|
||||||
|
|
||||||
user = db.data().users.get(user_uuid)
|
user = db.data().users.get(user_uuid)
|
||||||
@@ -442,12 +443,15 @@ async def userinfo(
|
|||||||
|
|
||||||
# Build userinfo response based on scope
|
# Build userinfo response based on scope
|
||||||
scope = payload.get("scope", "openid").split()
|
scope = payload.get("scope", "openid").split()
|
||||||
response = {"sub": str(user.uuid)}
|
response: dict[str, object] = {"sub": str(user.uuid)}
|
||||||
|
|
||||||
if "profile" in scope:
|
if "profile" in scope:
|
||||||
response["name"] = user.display_name
|
response["name"] = user.display_name
|
||||||
if user.preferred_username:
|
if user.preferred_username:
|
||||||
response["preferred_username"] = user.preferred_username
|
response["preferred_username"] = user.preferred_username
|
||||||
|
picture = avatar.current_avatar_url(user.uuid)
|
||||||
|
if picture:
|
||||||
|
response["picture"] = picture
|
||||||
|
|
||||||
if "email" in scope and user.email:
|
if "email" in scope and user.email:
|
||||||
response["email"] = user.email
|
response["email"] = user.email
|
||||||
|
|||||||
@@ -312,7 +312,13 @@ 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, secret = await authenticate_and_login(ws, auth)
|
ctx, secret = await authenticate_and_login(
|
||||||
|
ws,
|
||||||
|
auth,
|
||||||
|
session_host=request.host,
|
||||||
|
session_ip=request.ip,
|
||||||
|
session_user_agent=request.user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
reset_token = None
|
reset_token = None
|
||||||
|
|
||||||
|
|||||||
+89
-2
@@ -3,11 +3,13 @@ from uuid import UUID
|
|||||||
from fastapi import (
|
from fastapi import (
|
||||||
Body,
|
Body,
|
||||||
FastAPI,
|
FastAPI,
|
||||||
|
File,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
|
UploadFile,
|
||||||
)
|
)
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.authsession import (
|
from paskia.authsession import (
|
||||||
@@ -18,12 +20,48 @@ from paskia.authsession import (
|
|||||||
from paskia.fastapi import authz, session
|
from paskia.fastapi import authz, session
|
||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import hostutil
|
from paskia.util import avatar, hostutil
|
||||||
from paskia.util.apistructs import ApiCreateLinkResponse
|
from paskia.util.apistructs import ApiCreateLinkResponse
|
||||||
|
|
||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _can_manage_avatar(ctx, target_user) -> bool:
|
||||||
|
if ctx.user.uuid == target_user.uuid:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if any(p.scope == "auth:admin" for p in ctx.permissions):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return ctx.org.uuid == target_user.org.uuid and any(
|
||||||
|
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _avatar_write_ctx(request: Request, user_uuid: UUID, auth):
|
||||||
|
if not auth:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Authentication Required", mode="login"
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = session_ctx(auth, request.headers.get("host"))
|
||||||
|
if not ctx:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
)
|
||||||
|
|
||||||
|
user = db.data().users.get(user_uuid)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||||
|
|
||||||
|
if not _can_manage_avatar(ctx, user):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ctx, user
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(authz.AuthException)
|
@app.exception_handler(authz.AuthException)
|
||||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||||
"""Handle AuthException with auth info for UI."""
|
"""Handle AuthException with auth info for UI."""
|
||||||
@@ -103,6 +141,55 @@ async def user_update_info(
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/{user_uuid}/profile.webp")
|
||||||
|
async def serve_avatar(request: Request, user_uuid: UUID):
|
||||||
|
"""Serve a user's current avatar with short-lived caching and ETag."""
|
||||||
|
user = db.data().users.get(user_uuid)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||||
|
|
||||||
|
path = avatar.avatar_path(user_uuid)
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||||
|
|
||||||
|
data = avatar.read_avatar_bytes(user_uuid)
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||||
|
|
||||||
|
etag = avatar.avatar_etag(data)
|
||||||
|
if request.headers.get("if-none-match") == f'"{etag}"':
|
||||||
|
return Response(status_code=304, headers={"ETag": f'"{etag}"'})
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"ETag": f'"{etag}"',
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileResponse(path, media_type="image/webp", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/{user_uuid}/profile.webp")
|
||||||
|
async def upload_avatar(
|
||||||
|
request: Request,
|
||||||
|
user_uuid: UUID,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Upload a user's browser-prepared WebP avatar on the same URL it is served from."""
|
||||||
|
_ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
|
||||||
|
data = await avatar.read_upload(file)
|
||||||
|
avatar.store_avatar(user_uuid, data)
|
||||||
|
return {"status": "ok", "avatar_url": avatar.avatar_browser_url(user_uuid)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/{user_uuid}/profile.webp")
|
||||||
|
async def delete_avatar(request: Request, user_uuid: UUID, auth=AUTH_COOKIE):
|
||||||
|
"""Delete a user's avatar image on the same URL it is served from."""
|
||||||
|
_ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
|
||||||
|
avatar.remove_avatar_file(user_uuid)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/theme")
|
@app.patch("/theme")
|
||||||
async def user_update_theme(
|
async def user_update_theme(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ async def websocket_register_add(
|
|||||||
if reset is not None:
|
if reset is not None:
|
||||||
if not passphrase.is_well_formed(reset):
|
if not passphrase.is_well_formed(reset):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
|
f"The reset link for {passkey.rp_name} is invalid or has expired"
|
||||||
)
|
)
|
||||||
s = get_reset(reset)
|
s = get_reset(reset)
|
||||||
user_uuid = s.user_uuid
|
user_uuid = s.user_uuid
|
||||||
|
|||||||
+36
-16
@@ -23,14 +23,14 @@ async def register_chat(
|
|||||||
credential_ids: list[bytes] | None = None,
|
credential_ids: list[bytes] | None = None,
|
||||||
):
|
):
|
||||||
"""Run WebAuthn registration flow and return the verified credential."""
|
"""Run WebAuthn registration flow and return the verified credential."""
|
||||||
options, challenge = passkey.instance.reg_generate_options(
|
options, challenge = passkey.reg_generate_options(
|
||||||
user_id=user_uuid,
|
user_id=user_uuid,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
credential_ids=credential_ids,
|
credential_ids=credential_ids,
|
||||||
)
|
)
|
||||||
await ws.send_json({"optionsJSON": options})
|
await ws.send_json({"optionsJSON": options})
|
||||||
response = await ws.receive_json()
|
response = await ws.receive_json()
|
||||||
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
return passkey.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_chat(
|
async def authenticate_chat(
|
||||||
@@ -43,11 +43,9 @@ async def authenticate_chat(
|
|||||||
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
|
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
|
||||||
"""
|
"""
|
||||||
origin = validate_origin(ws)
|
origin = validate_origin(ws)
|
||||||
options, challenge = passkey.instance.auth_generate_options(
|
options, challenge = passkey.auth_generate_options(credential_ids=credential_ids)
|
||||||
credential_ids=credential_ids
|
|
||||||
)
|
|
||||||
await ws.send_json({"optionsJSON": options})
|
await ws.send_json({"optionsJSON": options})
|
||||||
authcred = passkey.instance.auth_parse(await ws.receive_json())
|
authcred = passkey.auth_parse(await ws.receive_json())
|
||||||
|
|
||||||
cred = next(
|
cred = next(
|
||||||
(
|
(
|
||||||
@@ -58,22 +56,31 @@ async def authenticate_chat(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if not cred:
|
if not cred:
|
||||||
raise ValueError(
|
raise ValueError(f"This passkey is no longer registered with {passkey.rp_name}")
|
||||||
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
verification = passkey.instance.auth_verify(authcred, challenge, cred, origin)
|
verification = passkey.auth_verify(authcred, challenge, cred, origin)
|
||||||
return cred, verification.new_sign_count
|
return cred, verification.new_sign_count
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_and_login(
|
async def authenticate_and_login(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
auth: str | None = None,
|
auth: str | None = None,
|
||||||
|
*,
|
||||||
|
session_host: str | None = None,
|
||||||
|
session_ip: str | None = None,
|
||||||
|
session_user_agent: str | None = None,
|
||||||
) -> tuple[SessionContext, str]:
|
) -> tuple[SessionContext, str]:
|
||||||
"""Run WebAuthn authentication flow, create session, and return the session context.
|
"""Run WebAuthn authentication flow, create session, and return the session context.
|
||||||
|
|
||||||
If auth is provided, restrict authentication to credentials of that session's user.
|
If auth is provided, restrict authentication to credentials of that session's user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ws: The WebSocket connection (used for WebAuthn and origin validation)
|
||||||
|
auth: Existing session cookie for re-auth credential restriction
|
||||||
|
session_host: Override host for the new session (defaults to ws origin)
|
||||||
|
session_ip: Override IP for the new session (defaults to ws client IP)
|
||||||
|
session_user_agent: Override user-agent for the new session (defaults to ws headers)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (SessionContext for the authenticated session, session secret)
|
Tuple of (SessionContext for the authenticated session, session secret)
|
||||||
"""
|
"""
|
||||||
@@ -83,7 +90,7 @@ async def authenticate_and_login(
|
|||||||
if not normalized_host:
|
if not normalized_host:
|
||||||
raise ValueError("Host required for session creation")
|
raise ValueError("Host required for session creation")
|
||||||
hostname = normalized_host.split(":")[0]
|
hostname = normalized_host.split(":")[0]
|
||||||
rp_id = passkey.instance.rp_id
|
rp_id = passkey.rp_id
|
||||||
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
||||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||||
metadata = infodict(ws, "auth")
|
metadata = infodict(ws, "auth")
|
||||||
@@ -97,18 +104,31 @@ async def authenticate_and_login(
|
|||||||
|
|
||||||
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
||||||
|
|
||||||
|
# Use overrides if provided, otherwise use websocket metadata
|
||||||
|
login_host = (
|
||||||
|
hostutil.normalize_host(session_host)
|
||||||
|
if session_host is not None
|
||||||
|
else normalized_host
|
||||||
|
)
|
||||||
|
if not login_host:
|
||||||
|
raise ValueError("Host required for session creation")
|
||||||
|
login_ip = session_ip if session_ip is not None else metadata["ip"]
|
||||||
|
login_user_agent = (
|
||||||
|
session_user_agent if session_user_agent is not None else metadata["user_agent"]
|
||||||
|
)
|
||||||
|
|
||||||
# Create session and update user/credential
|
# Create session and update user/credential
|
||||||
secret = db.login(
|
secret = db.login(
|
||||||
user_uuid=cred.user_uuid,
|
user_uuid=cred.user_uuid,
|
||||||
credential_uuid=cred.uuid,
|
credential_uuid=cred.uuid,
|
||||||
sign_count=new_sign_count,
|
sign_count=new_sign_count,
|
||||||
host=normalized_host,
|
host=login_host,
|
||||||
ip=metadata["ip"],
|
ip=login_ip,
|
||||||
user_agent=metadata["user_agent"],
|
user_agent=login_user_agent,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch and return the full session context
|
# Fetch and return the full session context (using the same host the session was created with)
|
||||||
ctx = session_ctx(secret, host)
|
ctx = session_ctx(secret, login_host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise ValueError("Failed to create session context")
|
raise ValueError("Failed to create session context")
|
||||||
return ctx, secret
|
return ctx, secret
|
||||||
|
|||||||
@@ -96,4 +96,4 @@ def validate_origin(ws: WebSocket) -> str:
|
|||||||
origin = ws.headers.get("origin")
|
origin = ws.headers.get("origin")
|
||||||
if not origin:
|
if not origin:
|
||||||
raise ValueError("Origin header is required for WebSocket connections")
|
raise ValueError("Origin header is required for WebSocket connections")
|
||||||
return passkey.instance.validate_origin(origin)
|
return passkey.validate_origin(origin)
|
||||||
|
|||||||
+16
-67
@@ -1,71 +1,20 @@
|
|||||||
from typing import Generic, TypeVar
|
"""Global Passkey instance configured from PASKIA_CONFIG.
|
||||||
|
|
||||||
|
The Passkey instance is created at import time using the runtime configuration
|
||||||
|
passed via the ``PASKIA_CONFIG`` environment variable. Other runtime setup
|
||||||
|
(remote auth, auth codes, bootstrap checks) is performed explicitly by the
|
||||||
|
FastAPI lifespan once the database is open.
|
||||||
|
"""
|
||||||
|
|
||||||
from paskia import authcode, db, remoteauth
|
|
||||||
from paskia.bootstrap import bootstrap_if_needed
|
|
||||||
from paskia.sansio import Passkey
|
from paskia.sansio import Passkey
|
||||||
|
from paskia.util import runtime
|
||||||
|
|
||||||
T = TypeVar("T")
|
runtime = runtime.config()
|
||||||
|
if runtime is None:
|
||||||
|
raise RuntimeError("PASKIA_CONFIG must be defined before importing paskia.globals")
|
||||||
|
|
||||||
|
passkey = Passkey(
|
||||||
class Manager(Generic[T]):
|
rp_id=runtime.config.rp_id,
|
||||||
"""Generic manager for global instances."""
|
rp_name=runtime.config.rp_name,
|
||||||
|
origins=runtime.config.origins,
|
||||||
def __init__(self, name: str):
|
)
|
||||||
self._instance: T | None = None
|
|
||||||
self._name = name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def instance(self) -> T:
|
|
||||||
if self._instance is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{self._name} not initialized. Call globals.init() first."
|
|
||||||
)
|
|
||||||
return self._instance
|
|
||||||
|
|
||||||
@instance.setter
|
|
||||||
def instance(self, instance: T) -> None:
|
|
||||||
self._instance = instance
|
|
||||||
|
|
||||||
|
|
||||||
async def init(
|
|
||||||
rp_id: str = "localhost",
|
|
||||||
rp_name: str | None = None,
|
|
||||||
origins: list[str] | None = None,
|
|
||||||
*,
|
|
||||||
bootstrap: bool = True,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize global passkey + database.
|
|
||||||
|
|
||||||
If bootstrap=True (default) the system bootstrap_if_needed() will be invoked.
|
|
||||||
In FastAPI lifespan we call with bootstrap=False to avoid duplicate bootstrapping
|
|
||||||
since the CLI performs it once before servers start.
|
|
||||||
|
|
||||||
Database configuration:
|
|
||||||
Set PASKIA_DB environment variable to specify the JSONL database file path.
|
|
||||||
Default: {rp_id}.paskiadb
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Initialize passkey instance with provided parameters
|
|
||||||
passkey.instance = Passkey(
|
|
||||||
rp_id=rp_id,
|
|
||||||
rp_name=rp_name or rp_id,
|
|
||||||
origins=origins,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize database
|
|
||||||
await db.init(rp_id=rp_id)
|
|
||||||
|
|
||||||
# Initialize remote auth manager
|
|
||||||
await remoteauth.init()
|
|
||||||
|
|
||||||
# Initialize auth code manager
|
|
||||||
await authcode.start()
|
|
||||||
|
|
||||||
if bootstrap:
|
|
||||||
# Bootstrap system if needed
|
|
||||||
|
|
||||||
await bootstrap_if_needed()
|
|
||||||
|
|
||||||
|
|
||||||
# Global instances
|
|
||||||
passkey = Manager[Passkey]("Passkey")
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import httpx
|
|||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.util import oidjwt
|
from paskia.util import oidjwt
|
||||||
from paskia.util.runtime import _load_config
|
from paskia.util.runtime import config as runtime_config
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ _TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
|||||||
|
|
||||||
def _issuer() -> str:
|
def _issuer() -> str:
|
||||||
"""Derive issuer URL from config (same base as discovery document)."""
|
"""Derive issuer URL from config (same base as discovery document)."""
|
||||||
cfg = _load_config()
|
cfg = runtime_config()
|
||||||
return cfg.get("site_url", "https://localhost")
|
return cfg.site_url if cfg else "https://localhost"
|
||||||
|
|
||||||
|
|
||||||
def _collect_oidc_sessions(
|
def _collect_oidc_sessions(
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ class ApiUser(User, kw_only=True):
|
|||||||
"""User with uuid serialized."""
|
"""User with uuid serialized."""
|
||||||
|
|
||||||
uuid: UUID
|
uuid: UUID
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db(cls, u: User) -> ApiUser:
|
def from_db(cls, u: User, *, avatar_url: str | None = None) -> ApiUser:
|
||||||
return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
|
return cls(uuid=u.uuid, avatar_url=avatar_url, **msgspec.structs.asdict(u))
|
||||||
|
|
||||||
|
|
||||||
class ApiOrg(Org, kw_only=True):
|
class ApiOrg(Org, kw_only=True):
|
||||||
@@ -139,7 +140,7 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
|
|||||||
user: ApiUser
|
user: ApiUser
|
||||||
credentials: dict[UUID, Credential]
|
credentials: dict[UUID, Credential]
|
||||||
aaguid_info: dict[str, ApiAaguidInfo]
|
aaguid_info: dict[str, ApiAaguidInfo]
|
||||||
sessions: dict[bytes, ApiUserSession]
|
sessions: dict[str, ApiUserSession]
|
||||||
permissions: dict[UUID, ApiPermission] = {}
|
permissions: dict[UUID, ApiPermission] = {}
|
||||||
org: ApiOrg | None = None
|
org: ApiOrg | None = None
|
||||||
role: ApiRole | None = None
|
role: ApiRole | None = None
|
||||||
@@ -156,7 +157,7 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
|
|||||||
org: ApiOrg
|
org: ApiOrg
|
||||||
permissions: dict[UUID, Permission]
|
permissions: dict[UUID, Permission]
|
||||||
roles: dict[UUID, Role]
|
roles: dict[UUID, Role]
|
||||||
users: dict[UUID, User]
|
users: dict[UUID, ApiUser]
|
||||||
|
|
||||||
|
|
||||||
class ApiSettings(msgspec.Struct):
|
class ApiSettings(msgspec.Struct):
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Avatar storage and URL helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
|
||||||
|
from paskia.db.paths import users_root_path
|
||||||
|
from paskia.util import hostutil
|
||||||
|
|
||||||
|
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def media_root() -> Path:
|
||||||
|
"""Return the filesystem root for auxiliary media files."""
|
||||||
|
return users_root_path(create_root=True)
|
||||||
|
|
||||||
|
|
||||||
|
def avatars_root() -> Path:
|
||||||
|
"""Return the filesystem root for stored avatar images."""
|
||||||
|
return media_root()
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_path(user_uuid: UUID) -> Path:
|
||||||
|
"""Return the avatar file path for a user."""
|
||||||
|
return avatars_root() / str(user_uuid) / "profile.webp"
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_public_path(user_uuid: UUID) -> str:
|
||||||
|
"""Return the public relative path for a user's avatar."""
|
||||||
|
return f"/auth/api/user/{user_uuid}/profile.webp"
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_browser_url(user_uuid: UUID) -> str | None:
|
||||||
|
"""Return the browser-facing avatar URL."""
|
||||||
|
if not avatar_path(user_uuid).is_file():
|
||||||
|
return None
|
||||||
|
return avatar_public_path(user_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_url(user_uuid: UUID) -> str | None:
|
||||||
|
"""Return the absolute public avatar URL for a user, or None."""
|
||||||
|
if not avatar_path(user_uuid).is_file():
|
||||||
|
return None
|
||||||
|
return hostutil.api_url(f"user/{user_uuid}/profile.webp")
|
||||||
|
|
||||||
|
|
||||||
|
def current_avatar_url(user_uuid: UUID) -> str | None:
|
||||||
|
"""Return the current absolute avatar URL for a user UUID."""
|
||||||
|
return avatar_url(user_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_avatar_file(user_uuid: UUID) -> None:
|
||||||
|
"""Delete a stored avatar file if it exists."""
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
avatar_path(user_uuid).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def read_avatar_bytes(user_uuid: UUID) -> bytes | None:
|
||||||
|
"""Read the stored avatar file for a user, if present."""
|
||||||
|
path = avatar_path(user_uuid)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_webp(data: bytes) -> bool:
|
||||||
|
"""Return True when bytes look like a RIFF WebP file."""
|
||||||
|
return len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP"
|
||||||
|
|
||||||
|
|
||||||
|
async def read_upload(upload: UploadFile) -> bytes:
|
||||||
|
"""Read an uploaded avatar and require it to already be WebP."""
|
||||||
|
data = await upload.read(MAX_UPLOAD_BYTES + 1)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=400, detail="No avatar file uploaded")
|
||||||
|
if len(data) > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Avatar upload too large")
|
||||||
|
|
||||||
|
if not _is_webp(data):
|
||||||
|
raise HTTPException(status_code=400, detail="Avatar upload must be WebP")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def store_avatar(user_uuid: UUID, data: bytes) -> None:
|
||||||
|
"""Store avatar bytes."""
|
||||||
|
path = avatar_path(user_uuid)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_etag(data: bytes) -> str:
|
||||||
|
"""Return a stable ETag value for avatar bytes."""
|
||||||
|
return hashlib.sha256(data).hexdigest()[:16]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Small, dependency-free constants shared by CLI and server modules."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
DEFAULT_PORT = 4401
|
||||||
|
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
||||||
+14
-3
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
from urllib.parse import urlparse, urlsplit
|
from urllib.parse import urlparse, urlsplit
|
||||||
|
|
||||||
from paskia.util.runtime import _load_config
|
from paskia.util.runtime import clear_config_cache
|
||||||
|
from paskia.util.runtime import config as runtime_config
|
||||||
|
|
||||||
|
|
||||||
def _cfg():
|
def _cfg():
|
||||||
return _load_config()
|
return runtime_config()
|
||||||
|
|
||||||
|
|
||||||
def is_root_mode() -> bool:
|
def is_root_mode() -> bool:
|
||||||
@@ -29,6 +30,16 @@ def ui_base_path() -> str:
|
|||||||
return "/" if is_root_mode() else "/auth/"
|
return "/" if is_root_mode() else "/auth/"
|
||||||
|
|
||||||
|
|
||||||
|
def api_url(path: str = "") -> str:
|
||||||
|
"""Return an absolute URL under the canonical /auth/api/ prefix."""
|
||||||
|
cfg = _cfg()
|
||||||
|
base = cfg.site_url if cfg else "https://localhost"
|
||||||
|
if not path:
|
||||||
|
return f"{base}/auth/api/"
|
||||||
|
normalized = path.lstrip("/")
|
||||||
|
return f"{base}/auth/api/{normalized}"
|
||||||
|
|
||||||
|
|
||||||
def auth_site_url() -> str:
|
def auth_site_url() -> str:
|
||||||
"""Return the base URL for the auth site UI (computed at startup)."""
|
"""Return the base URL for the auth site UI (computed at startup)."""
|
||||||
cfg = _cfg()
|
cfg = _cfg()
|
||||||
@@ -95,7 +106,7 @@ def normalize_auth_host_and_origins(
|
|||||||
|
|
||||||
|
|
||||||
def reload_config() -> None:
|
def reload_config() -> None:
|
||||||
_load_config.cache_clear()
|
clear_config_cache()
|
||||||
|
|
||||||
|
|
||||||
def normalize_host(raw_host: str | None) -> str | None:
|
def normalize_host(raw_host: str | None) -> str | None:
|
||||||
|
|||||||
+29
-15
@@ -29,11 +29,14 @@ def _load_or_generate_key() -> None:
|
|||||||
global _private_key, _public_key, _kid
|
global _private_key, _public_key, _kid
|
||||||
|
|
||||||
data = db.data()
|
data = db.data()
|
||||||
|
store = data._store
|
||||||
|
if store is None:
|
||||||
|
raise RuntimeError("Kanta store is not initialized")
|
||||||
if data.oidc.key is not None:
|
if data.oidc.key is not None:
|
||||||
_private_key = public_key_from_secret(data.oidc.key)
|
_private_key = public_key_from_secret(data.oidc.key)
|
||||||
else:
|
else:
|
||||||
raw_key = secret_key()
|
raw_key = secret_key()
|
||||||
with data.transaction("oidc_key"):
|
with store.transaction("oidc_key"):
|
||||||
data.oidc.key = raw_key
|
data.oidc.key = raw_key
|
||||||
_private_key = public_key_from_secret(raw_key)
|
_private_key = public_key_from_secret(raw_key)
|
||||||
|
|
||||||
@@ -78,6 +81,7 @@ def create_id_token(
|
|||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
preferred_username: str | None = None,
|
preferred_username: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
|
picture: str | None = None,
|
||||||
groups: list[str] | None = None,
|
groups: list[str] | None = None,
|
||||||
auth_time: datetime | None = None,
|
auth_time: datetime | None = None,
|
||||||
expires_in: int = 3600,
|
expires_in: int = 3600,
|
||||||
@@ -93,6 +97,7 @@ def create_id_token(
|
|||||||
name: User's display name
|
name: User's display name
|
||||||
preferred_username: User's preferred username
|
preferred_username: User's preferred username
|
||||||
email: User's email address
|
email: User's email address
|
||||||
|
picture: User avatar URL
|
||||||
groups: List of permission scopes (groups claim)
|
groups: List of permission scopes (groups claim)
|
||||||
auth_time: When the user authenticated (last credential use time)
|
auth_time: When the user authenticated (last credential use time)
|
||||||
expires_in: Token lifetime in seconds
|
expires_in: Token lifetime in seconds
|
||||||
@@ -101,8 +106,9 @@ def create_id_token(
|
|||||||
Signed JWT string
|
Signed JWT string
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
_ensure_key()
|
||||||
|
assert _private_key is not None
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
payload = {
|
payload: dict[str, object] = {
|
||||||
"iss": issuer,
|
"iss": issuer,
|
||||||
"sub": str(subject),
|
"sub": str(subject),
|
||||||
"aud": audience,
|
"aud": audience,
|
||||||
@@ -119,6 +125,8 @@ def create_id_token(
|
|||||||
payload["preferred_username"] = preferred_username
|
payload["preferred_username"] = preferred_username
|
||||||
if email:
|
if email:
|
||||||
payload["email"] = email
|
payload["email"] = email
|
||||||
|
if picture:
|
||||||
|
payload["picture"] = picture
|
||||||
if groups:
|
if groups:
|
||||||
payload["groups"] = groups
|
payload["groups"] = groups
|
||||||
if auth_time:
|
if auth_time:
|
||||||
@@ -147,8 +155,9 @@ def create_access_token(
|
|||||||
Signed JWT string
|
Signed JWT string
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
_ensure_key()
|
||||||
|
assert _private_key is not None
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
payload = {
|
payload: dict[str, object] = {
|
||||||
"iss": issuer,
|
"iss": issuer,
|
||||||
"sub": str(subject),
|
"sub": str(subject),
|
||||||
"aud": audience,
|
"aud": audience,
|
||||||
@@ -173,20 +182,24 @@ def decode_access_token(
|
|||||||
Decoded payload or None if invalid
|
Decoded payload or None if invalid
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
_ensure_key()
|
||||||
|
assert _public_key is not None
|
||||||
try:
|
try:
|
||||||
# PyJWT requires audience parameter when token has aud claim.
|
|
||||||
# When audience is None, we skip PyJWT's audience validation and validate manually.
|
|
||||||
options = {}
|
|
||||||
decode_kwargs = {
|
|
||||||
"algorithms": ["EdDSA"],
|
|
||||||
"issuer": issuer,
|
|
||||||
}
|
|
||||||
if audience is not None:
|
if audience is not None:
|
||||||
decode_kwargs["audience"] = audience
|
return jwt.decode(
|
||||||
else:
|
token,
|
||||||
options["verify_aud"] = False
|
_public_key,
|
||||||
|
algorithms=["EdDSA"],
|
||||||
|
issuer=issuer,
|
||||||
|
audience=audience,
|
||||||
|
)
|
||||||
|
|
||||||
return jwt.decode(token, _public_key, options=options, **decode_kwargs)
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
_public_key,
|
||||||
|
algorithms=["EdDSA"],
|
||||||
|
issuer=issuer,
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -212,8 +225,9 @@ def create_logout_token(
|
|||||||
Signed JWT string
|
Signed JWT string
|
||||||
"""
|
"""
|
||||||
_ensure_key()
|
_ensure_key()
|
||||||
|
assert _private_key is not None
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
payload = {
|
payload: dict[str, object] = {
|
||||||
"iss": issuer,
|
"iss": issuer,
|
||||||
"aud": audience,
|
"aud": audience,
|
||||||
"iat": int(now.timestamp()),
|
"iat": int(now.timestamp()),
|
||||||
|
|||||||
+91
-3
@@ -1,21 +1,96 @@
|
|||||||
"""Minimal permission helpers with '*' wildcard support (no DB expansion)."""
|
"""Minimal permission helpers with '*' wildcard support (no DB expansion)."""
|
||||||
|
|
||||||
|
import re
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from fnmatch import fnmatchcase
|
from functools import lru_cache
|
||||||
|
|
||||||
from paskia.authsession import session_ctx
|
from paskia.authsession import session_ctx
|
||||||
from paskia.util.hostutil import normalize_host
|
from paskia.util.hostutil import normalize_host
|
||||||
|
|
||||||
__all__ = ["has_any", "has_all", "has_all_scopes", "session_context"]
|
__all__ = [
|
||||||
|
"group_satisfied",
|
||||||
|
"has_all",
|
||||||
|
"has_all_groups",
|
||||||
|
"has_all_scopes",
|
||||||
|
"has_all_scopes_groups",
|
||||||
|
"has_any",
|
||||||
|
"parse_perm_args",
|
||||||
|
"session_context",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Characters allowed in a scope pattern within the perm query argument:
|
||||||
|
# the scope charset (see querysafe) plus the '*' wildcard.
|
||||||
|
_SCOPE_PATTERN_RE = re.compile(r"^[A-Za-z0-9:._~/*-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_perm_args(values: Sequence[str]) -> list[tuple[str, ...]]:
|
||||||
|
"""Parse repeated perm query argument values into groups of alternatives.
|
||||||
|
|
||||||
|
Each value holds space-separated groups; each group holds one or more
|
||||||
|
scope patterns separated by '|'. A group is satisfied when any of its
|
||||||
|
alternatives matches; all groups must be satisfied (AND semantics).
|
||||||
|
|
||||||
|
Extra spaces around groups (leading, trailing, repeated) are tolerated.
|
||||||
|
Anything else out of spec raises ValueError: empty values, empty
|
||||||
|
alternatives around '|', characters outside the scope charset and the
|
||||||
|
'*' wildcard, stray '+' etc.
|
||||||
|
"""
|
||||||
|
groups: list[tuple[str, ...]] = []
|
||||||
|
for value in values:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ValueError("perm value must not be empty")
|
||||||
|
for group in value.split(" "):
|
||||||
|
if not group:
|
||||||
|
# Tolerate extra spaces from URL formatting
|
||||||
|
continue
|
||||||
|
alternatives = group.split("|")
|
||||||
|
for alt in alternatives:
|
||||||
|
if not alt:
|
||||||
|
raise ValueError("empty alternative around '|' in perm argument")
|
||||||
|
if not _SCOPE_PATTERN_RE.match(alt):
|
||||||
|
raise ValueError("invalid character in perm scope pattern")
|
||||||
|
groups.append(tuple(alternatives))
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def _pattern_regex(pattern: str) -> re.Pattern:
|
||||||
|
"""Compile a scope pattern to a regex with filename-like wildcards.
|
||||||
|
|
||||||
|
'*' matches any sequence within a single path segment (it crosses
|
||||||
|
neither ':' nor '/'), '**' matches across separators. Partial segments
|
||||||
|
may be wildcarded (e.g. 'myapp:re*' or '*:read').
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
i = 0
|
||||||
|
while i < len(pattern):
|
||||||
|
if pattern[i] == "*":
|
||||||
|
if pattern[i + 1 : i + 2] == "*":
|
||||||
|
parts.append(".*")
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
parts.append("[^:/]*")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
parts.append(re.escape(pattern[i]))
|
||||||
|
i += 1
|
||||||
|
return re.compile("".join(parts))
|
||||||
|
|
||||||
|
|
||||||
def _match(perms: set[str], patterns: Sequence[str]):
|
def _match(perms: set[str], patterns: Sequence[str]):
|
||||||
return (
|
return (
|
||||||
any(fnmatchcase(p, pat) for p in perms) if "*" in pat else pat in perms
|
any(_pattern_regex(pat).fullmatch(p) for p in perms)
|
||||||
|
if "*" in pat
|
||||||
|
else pat in perms
|
||||||
for pat in patterns
|
for pat in patterns
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def group_satisfied(perms: set[str], group: Sequence[str]) -> bool:
|
||||||
|
"""Check that at least one alternative in the group matches."""
|
||||||
|
return any(_match(perms, group))
|
||||||
|
|
||||||
|
|
||||||
def _get_effective_scopes(ctx) -> set[str]:
|
def _get_effective_scopes(ctx) -> set[str]:
|
||||||
"""Get effective permission scopes from context.
|
"""Get effective permission scopes from context.
|
||||||
|
|
||||||
@@ -36,11 +111,24 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
|
|||||||
return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
||||||
|
|
||||||
|
|
||||||
|
def has_all_groups(ctx, groups: Sequence[Sequence[str]]) -> bool:
|
||||||
|
"""Check that every group has at least one matching alternative."""
|
||||||
|
if not ctx:
|
||||||
|
return False
|
||||||
|
scopes = _get_effective_scopes(ctx)
|
||||||
|
return all(group_satisfied(scopes, g) for g in groups)
|
||||||
|
|
||||||
|
|
||||||
def has_all_scopes(scopes: set[str], patterns: Sequence[str]) -> bool:
|
def has_all_scopes(scopes: set[str], patterns: Sequence[str]) -> bool:
|
||||||
"""Check that a pre-computed scope set satisfies all required patterns."""
|
"""Check that a pre-computed scope set satisfies all required patterns."""
|
||||||
return all(_match(scopes, patterns)) if patterns else True
|
return all(_match(scopes, patterns)) if patterns else True
|
||||||
|
|
||||||
|
|
||||||
|
def has_all_scopes_groups(scopes: set[str], groups: Sequence[Sequence[str]]) -> bool:
|
||||||
|
"""Check that a pre-computed scope set satisfies every group of alternatives."""
|
||||||
|
return all(group_satisfied(scopes, g) for g in groups)
|
||||||
|
|
||||||
|
|
||||||
async def session_context(auth: str | None, host: str | None = None):
|
async def session_context(auth: str | None, host: str | None = None):
|
||||||
if not auth:
|
if not auth:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
_SAFE_RE = re.compile(r"^[A-Za-z0-9:._~-]+$")
|
_SAFE_RE = re.compile(r"^[A-Za-z0-9:._~/-]+$")
|
||||||
|
|
||||||
|
|
||||||
def assert_safe(value: str, *, field: str = "value") -> None:
|
def assert_safe(value: str, *, field: str = "value") -> None:
|
||||||
if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
|
if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
|
||||||
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$")
|
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~/-]+$")
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["assert_safe"]
|
__all__ = ["assert_safe"]
|
||||||
|
|||||||
+25
-9
@@ -22,7 +22,7 @@ class RuntimeConfig(msgspec.Struct):
|
|||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _load_config() -> "RuntimeConfig | None":
|
def _load_config() -> RuntimeConfig | None:
|
||||||
"""Load RuntimeConfig from PASKIA_CONFIG env var."""
|
"""Load RuntimeConfig from PASKIA_CONFIG env var."""
|
||||||
config_json = os.getenv("PASKIA_CONFIG")
|
config_json = os.getenv("PASKIA_CONFIG")
|
||||||
if not config_json:
|
if not config_json:
|
||||||
@@ -31,21 +31,37 @@ def _load_config() -> "RuntimeConfig | None":
|
|||||||
return msgspec.json.decode(config_json.encode(), type=RuntimeConfig)
|
return msgspec.json.decode(config_json.encode(), type=RuntimeConfig)
|
||||||
|
|
||||||
|
|
||||||
|
def config() -> RuntimeConfig | None:
|
||||||
|
"""Return cached runtime config loaded from PASKIA_CONFIG."""
|
||||||
|
return _load_config()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_config_cache() -> None:
|
||||||
|
"""Clear cached runtime config; next config() call reloads from env."""
|
||||||
|
_load_config.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
def update_runtime_config(new_config: Config) -> None:
|
def update_runtime_config(new_config: Config) -> None:
|
||||||
"""Update the runtime configuration with a new Config and refresh the cache."""
|
"""Update the runtime configuration with a new Config and refresh the cache."""
|
||||||
current_runtime = _load_config()
|
current_runtime = config()
|
||||||
if not current_runtime:
|
if not current_runtime:
|
||||||
return # No runtime config to update
|
return # No runtime config to update
|
||||||
|
|
||||||
# Recompute site_url and site_path based on new config
|
# Recompute site_url and site_path based on new config
|
||||||
site_path = "/" if new_config.auth_host else "/auth/"
|
old_auth_host = current_runtime.config.auth_host
|
||||||
if new_config.auth_host:
|
if new_config.auth_host:
|
||||||
site_url = new_config.auth_host
|
site_url, site_path = new_config.auth_host, "/"
|
||||||
elif new_config.origins:
|
|
||||||
site_url = new_config.origins[0]
|
|
||||||
else:
|
else:
|
||||||
# Keep current site_url if no auth_host and no origins
|
site_path = "/auth/"
|
||||||
site_url = current_runtime.site_url
|
# Never derive site_url from a just-removed auth host
|
||||||
|
origins = [o for o in (new_config.origins or []) if o != old_auth_host]
|
||||||
|
if origins:
|
||||||
|
site_url = origins[0]
|
||||||
|
elif current_runtime.site_url != old_auth_host:
|
||||||
|
# Keep current site_url if it wasn't derived from the removed auth host
|
||||||
|
site_url = current_runtime.site_url
|
||||||
|
else:
|
||||||
|
site_url = f"https://{new_config.rp_id}"
|
||||||
|
|
||||||
new_runtime = RuntimeConfig(
|
new_runtime = RuntimeConfig(
|
||||||
config=new_config,
|
config=new_config,
|
||||||
@@ -56,4 +72,4 @@ def update_runtime_config(new_config: Config) -> None:
|
|||||||
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(new_runtime).decode()
|
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(new_runtime).decode()
|
||||||
|
|
||||||
# Clear the cache so next access loads the updated config
|
# Clear the cache so next access loads the updated config
|
||||||
_load_config.cache_clear()
|
clear_config_cache()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
|
|||||||
from fastapi_vue.hostutil import parse_endpoints
|
from fastapi_vue.hostutil import parse_endpoints
|
||||||
|
|
||||||
from paskia._version import __version__
|
from paskia._version import __version__
|
||||||
|
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||||
from paskia.util.hostutil import format_endpoint
|
from paskia.util.hostutil import format_endpoint
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -19,8 +20,8 @@ BOX_WIDTH = 60 # Inner width (excluding box chars)
|
|||||||
|
|
||||||
# ANSI color codes
|
# ANSI color codes
|
||||||
RESET = "\033[0m"
|
RESET = "\033[0m"
|
||||||
YELLOW = "\033[33m" # Dark yellow
|
YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
|
||||||
BRIGHT_YELLOW = "\033[93m" # Bright yellow
|
BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
|
||||||
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
|
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
|
||||||
|
|
||||||
|
|
||||||
@@ -50,8 +51,8 @@ def bottom() -> str:
|
|||||||
def print_startup_config(runtime: RuntimeConfig) -> None:
|
def print_startup_config(runtime: RuntimeConfig) -> None:
|
||||||
"""Print server configuration on startup."""
|
"""Print server configuration on startup."""
|
||||||
# Key graphic with yellow shading (bright for highlights, dark for body)
|
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||||
y = YELLOW # Dark yellow for main body
|
y = YELLOW # Bright golden yellow for main body
|
||||||
b = BRIGHT_YELLOW # Bright yellow for highlights/edges
|
b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
|
||||||
w = BRIGHT_WHITE # Bold white for URL
|
w = BRIGHT_WHITE # Bold white for URL
|
||||||
r = RESET
|
r = RESET
|
||||||
|
|
||||||
@@ -73,16 +74,13 @@ def print_startup_config(runtime: RuntimeConfig) -> None:
|
|||||||
if runtime.config.auth_host:
|
if runtime.config.auth_host:
|
||||||
lines.append(line(f"Auth Host: {runtime.config.auth_host}"))
|
lines.append(line(f"Auth Host: {runtime.config.auth_host}"))
|
||||||
|
|
||||||
from paskia.__main__ import DEFAULT_PORT as P # noqa: PLC0415 - circular
|
|
||||||
from paskia.__main__ import DEVMODE # noqa: PLC0415 - circular
|
|
||||||
|
|
||||||
# Show frontend URL if in dev mode
|
# Show frontend URL if in dev mode
|
||||||
if DEVMODE:
|
if DEVMODE:
|
||||||
lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}"))
|
lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}"))
|
||||||
|
|
||||||
# Format listen endpoints (dev mode only uses the first endpoint)
|
# Format listen endpoints (dev mode only uses the first endpoint)
|
||||||
|
|
||||||
endpoints = list(parse_endpoints(runtime.config.listen, P))
|
endpoints = list(parse_endpoints(runtime.config.listen, DEFAULT_PORT))
|
||||||
if DEVMODE:
|
if DEVMODE:
|
||||||
endpoints = endpoints[:1] # server.run reload=True uses only one
|
endpoints = endpoints[:1] # server.run reload=True uses only one
|
||||||
parts = [format_endpoint(ep) for ep in endpoints]
|
parts = [format_endpoint(ep) for ep in endpoints]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from paskia import aaguid, db
|
from paskia import aaguid, db
|
||||||
from paskia.db import SessionContext
|
from paskia.db import SessionContext
|
||||||
from paskia.util import hostutil
|
from paskia.util import avatar, hostutil
|
||||||
from paskia.util.apistructs import (
|
from paskia.util.apistructs import (
|
||||||
ApiAaguidInfo,
|
ApiAaguidInfo,
|
||||||
ApiOrg,
|
ApiOrg,
|
||||||
@@ -56,7 +56,7 @@ async def build_user_info(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ApiUserDetail(
|
return ApiUserDetail(
|
||||||
user=ApiUser.from_db(user),
|
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
|
||||||
credentials={c.uuid: c for c in user.credentials},
|
credentials={c.uuid: c for c in user.credentials},
|
||||||
aaguid_info={
|
aaguid_info={
|
||||||
k: ApiAaguidInfo(**v)
|
k: ApiAaguidInfo(**v)
|
||||||
|
|||||||
+2
-1
@@ -11,7 +11,7 @@ keywords = [ "forward_auth", "auth_request", "FastAPI" ]
|
|||||||
authors = [
|
authors = [
|
||||||
{name = "Leo Vasanko"},
|
{name = "Leo Vasanko"},
|
||||||
]
|
]
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.129.0",
|
"fastapi[standard]>=0.129.0",
|
||||||
"websockets>=16.0",
|
"websockets>=16.0",
|
||||||
@@ -23,6 +23,7 @@ dependencies = [
|
|||||||
"msgspec>=0.20.0",
|
"msgspec>=0.20.0",
|
||||||
"fastapi-vue>=1.1.0",
|
"fastapi-vue>=1.1.0",
|
||||||
"ua-parser[regex]>=1.0.1",
|
"ua-parser[regex]>=1.0.1",
|
||||||
|
"kanta>=0.7.0",
|
||||||
]
|
]
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def _check_node_version(node_path: str) -> None:
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||||
)
|
)
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
except subprocess.CalledProcessError, FileNotFoundError, ValueError:
|
||||||
pass
|
pass
|
||||||
raise RuntimeError("Could not determine Node.js version")
|
raise RuntimeError("Could not determine Node.js version")
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class ProcessGroup:
|
|||||||
return proc
|
return proc
|
||||||
|
|
||||||
async def wait(
|
async def wait(
|
||||||
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
|
self, *waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||||
|
|
||||||
|
|||||||
+82
-23
@@ -12,6 +12,7 @@ in the database to test authenticated endpoints.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -22,25 +23,38 @@ from uuid import UUID
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
from kanta import Kanta
|
||||||
|
|
||||||
|
# Keep runtime initialization invariant aligned with production:
|
||||||
|
# db.lifecycle requires PASKIA_CONFIG at import time.
|
||||||
|
os.environ.setdefault(
|
||||||
|
"PASKIA_CONFIG",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"config": {"rp_id": "localhost", "rp_name": "localhost"},
|
||||||
|
"site_url": "http://localhost:4401",
|
||||||
|
"site_path": "/auth/",
|
||||||
|
"save": False,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
import paskia.db.operations as ops_db
|
import paskia.db.operations as ops_db
|
||||||
from paskia import globals as paskia_globals
|
from paskia import globals as paskia_globals
|
||||||
from paskia.authsession import reset_expires
|
from paskia.authsession import reset_expires
|
||||||
from paskia.config import SESSION_LIFETIME
|
from paskia.config import SESSION_LIFETIME
|
||||||
from paskia.db import (
|
from paskia.db import (
|
||||||
Config,
|
|
||||||
Credential,
|
Credential,
|
||||||
Org,
|
Org,
|
||||||
Permission,
|
Permission,
|
||||||
Role,
|
Role,
|
||||||
User,
|
User,
|
||||||
bootstrap,
|
|
||||||
create_credential,
|
create_credential,
|
||||||
create_reset_token,
|
create_reset_token,
|
||||||
create_role,
|
create_role,
|
||||||
create_user,
|
create_user,
|
||||||
)
|
)
|
||||||
from paskia.db.jsonl import JsonlStore
|
from paskia.db.bootstrap import bootstrap
|
||||||
from paskia.db.operations import DB
|
from paskia.db.operations import DB
|
||||||
from paskia.db.structs import Session
|
from paskia.db.structs import Session
|
||||||
from paskia.fastapi.mainapp import app
|
from paskia.fastapi.mainapp import app
|
||||||
@@ -58,42 +72,60 @@ def event_loop():
|
|||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
async def test_db() -> AsyncGenerator[DB, None]:
|
async def test_db() -> AsyncGenerator[DB]:
|
||||||
"""Create an in-memory JSON database for testing.
|
"""Create a temporary JSONL database for testing using kanta.
|
||||||
|
|
||||||
Uses bootstrap() to properly initialize the database with:
|
Uses a kanta bootstrap callback to properly initialize the database with:
|
||||||
- auth:admin and auth:org:admin permissions
|
- auth:admin and auth:org:admin permissions
|
||||||
- A default organization with Administration role
|
- A default organization with Administration role
|
||||||
- An admin user with the Administration role
|
- An admin user with the Administration role
|
||||||
"""
|
"""
|
||||||
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
||||||
db = DB(config=Config(rp_id="test.example.com"))
|
db = DB()
|
||||||
store = JsonlStore(db, f.name)
|
kanta = Kanta(
|
||||||
db._store = store
|
f.name,
|
||||||
await store.load()
|
db,
|
||||||
ops_db._db = db
|
migrations="paskia.db.migrations",
|
||||||
ops_db._store = store
|
|
||||||
# Bootstrap creates the initial permissions, org, role, and admin user
|
|
||||||
bootstrap(
|
|
||||||
org_name="Test Organization",
|
|
||||||
admin_name="Test Admin",
|
|
||||||
)
|
)
|
||||||
yield db
|
kanta.ctx.rp_id = "test.example.com"
|
||||||
|
|
||||||
|
# Register bootstrap callback so kanta seeds the empty DB during open()
|
||||||
|
@kanta.bootstrap(action="bootstrap")
|
||||||
|
def bootstrap_test_db(data: DB) -> None:
|
||||||
|
bootstrap(
|
||||||
|
data,
|
||||||
|
org_name="Test Organization",
|
||||||
|
admin_name="Test Admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
await kanta.open()
|
||||||
|
ops_db._db = db
|
||||||
|
ops_db._db._store = kanta
|
||||||
|
yield ops_db._db
|
||||||
|
await kanta.close()
|
||||||
ops_db._db = None
|
ops_db._db = None
|
||||||
ops_db._store = None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
async def passkey_instance() -> Passkey:
|
async def passkey_instance() -> Passkey:
|
||||||
"""Initialize a passkey instance for testing."""
|
"""Override the module-level passkey instance for testing."""
|
||||||
pk = Passkey(
|
pk = Passkey(
|
||||||
rp_id="localhost",
|
rp_id="localhost",
|
||||||
rp_name="Test RP",
|
rp_name="Test RP",
|
||||||
origins=["http://localhost:4401"],
|
origins=["http://localhost:4401"],
|
||||||
)
|
)
|
||||||
paskia_globals.passkey._instance = pk
|
original = {
|
||||||
|
"rp_id": paskia_globals.passkey.rp_id,
|
||||||
|
"rp_name": paskia_globals.passkey.rp_name,
|
||||||
|
"allowed_origins": paskia_globals.passkey.allowed_origins,
|
||||||
|
}
|
||||||
|
paskia_globals.passkey.rp_id = pk.rp_id
|
||||||
|
paskia_globals.passkey.rp_name = pk.rp_name
|
||||||
|
paskia_globals.passkey.allowed_origins = pk.allowed_origins
|
||||||
yield pk
|
yield pk
|
||||||
paskia_globals.passkey._instance = None
|
paskia_globals.passkey.rp_id = original["rp_id"]
|
||||||
|
paskia_globals.passkey.rp_name = original["rp_name"]
|
||||||
|
paskia_globals.passkey.allowed_origins = original["allowed_origins"]
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
@@ -216,7 +248,7 @@ async def reset_token(test_db: DB, test_user: User, test_credential: Credential)
|
|||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
async def client(
|
async def client(
|
||||||
test_db: DB, passkey_instance: Passkey
|
test_db: DB, passkey_instance: Passkey
|
||||||
) -> AsyncGenerator[httpx.AsyncClient, None]:
|
) -> AsyncGenerator[httpx.AsyncClient]:
|
||||||
"""Create an async test client for the FastAPI app.
|
"""Create an async test client for the FastAPI app.
|
||||||
|
|
||||||
Note: We import the app inside the fixture to ensure globals are
|
Note: We import the app inside the fixture to ensure globals are
|
||||||
@@ -280,6 +312,33 @@ def create_test_session(
|
|||||||
)
|
)
|
||||||
if session.key in ops_db._db.sessions:
|
if session.key in ops_db._db.sessions:
|
||||||
raise ValueError("Session already exists")
|
raise ValueError("Session already exists")
|
||||||
with ops_db._db.transaction("create_test_session"):
|
store = ops_db._db._store
|
||||||
|
if store is None:
|
||||||
|
raise RuntimeError("Test DB store is not initialized")
|
||||||
|
with store.transaction("create_test_session"):
|
||||||
session.store(now)
|
session.store(now)
|
||||||
return session.key, token
|
return session.key, token
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_image_bytes(
|
||||||
|
*,
|
||||||
|
image_format: str = "WEBP",
|
||||||
|
) -> bytes:
|
||||||
|
"""Return deterministic test upload bytes without image-library dependencies."""
|
||||||
|
fixtures = {
|
||||||
|
"WEBP": (
|
||||||
|
b"RIFF\x1a\x00\x00\x00WEBPVP8 "
|
||||||
|
b"\x0e\x00\x00\x000\x01\x00\x9d\x01*\x01\x00\x01\x00\x01\x00"
|
||||||
|
),
|
||||||
|
"PNG": (
|
||||||
|
b"\x89PNG\r\n\x1a\n"
|
||||||
|
b"\x00\x00\x00\rIHDR"
|
||||||
|
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
|
||||||
|
b"\x90wS\xde"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return fixtures[image_format.upper()]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"Unsupported test image format: {image_format}") from exc
|
||||||
|
|||||||
+177
-1
@@ -14,6 +14,7 @@ These tests cover:
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urlsplit
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -36,8 +37,11 @@ from paskia.db import (
|
|||||||
create_user,
|
create_user,
|
||||||
)
|
)
|
||||||
from paskia.db.operations import DB
|
from paskia.db.operations import DB
|
||||||
|
from paskia.util import hostutil
|
||||||
from paskia.util.crypto import hash_secret
|
from paskia.util.crypto import hash_secret
|
||||||
from tests.conftest import auth_headers, create_test_session
|
from paskia.util.runtime import clear_config_cache
|
||||||
|
from paskia.util.runtime import config as runtime_config
|
||||||
|
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||||
|
|
||||||
# -------------------- Additional Fixtures --------------------
|
# -------------------- Additional Fixtures --------------------
|
||||||
|
|
||||||
@@ -238,6 +242,38 @@ class TestAdminOrganizations:
|
|||||||
assert "roles" in org_data
|
assert "roles" in org_data
|
||||||
assert "users" in org_data
|
assert "users" in org_data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_orgs_includes_user_avatar_urls(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_org,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Admin org payload should include canonical avatar URLs for listed users."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
upload = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 200
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/auth/api/admin/info",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
listed_user = data["orgs"][str(test_org.uuid)]["users"][str(test_user.uuid)]
|
||||||
|
parts = urlsplit(listed_user["avatar_url"])
|
||||||
|
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||||
|
assert parts.query == ""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_orgs_with_org_admin(
|
async def test_list_orgs_with_org_admin(
|
||||||
self,
|
self,
|
||||||
@@ -902,6 +938,34 @@ class TestAdminUsersInOrg:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "display_name too long" in data["detail"]
|
assert "display_name too long" in data["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_can_upload_user_avatar(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user: User,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Admin should be able to upload avatar for a managed user."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-admin-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
detail = await client.get(
|
||||||
|
f"/auth/api/admin/users/{test_user.uuid}",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert detail.status_code == 200
|
||||||
|
avatar_url = detail.json()["user"]["avatar_url"]
|
||||||
|
parts = urlsplit(avatar_url)
|
||||||
|
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_user_role_in_org(
|
async def test_update_user_role_in_org(
|
||||||
self,
|
self,
|
||||||
@@ -1728,3 +1792,115 @@ class TestOrgAdminAuthExceptions:
|
|||||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerConfig:
|
||||||
|
"""Tests for GET/PATCH /auth/api/admin/server-config/ runtime updates."""
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def restore_runtime_config(self):
|
||||||
|
"""Restore PASKIA_CONFIG env and cache after a test mutates runtime."""
|
||||||
|
original = os.environ["PASKIA_CONFIG"]
|
||||||
|
yield
|
||||||
|
os.environ["PASKIA_CONFIG"] = original
|
||||||
|
clear_config_cache()
|
||||||
|
|
||||||
|
async def _set_auth_host(self, client, session_token, test_user, test_credential):
|
||||||
|
"""Configure an auth host via PATCH, as the admin UI would."""
|
||||||
|
r = await client.patch(
|
||||||
|
"/auth/api/admin/server-config/",
|
||||||
|
json={
|
||||||
|
"rp_name": "",
|
||||||
|
"auth_host": "auth.localhost",
|
||||||
|
"origins": ["auth.localhost", "localhost"],
|
||||||
|
},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert db.data().config.auth_host == "https://auth.localhost"
|
||||||
|
assert hostutil.dedicated_auth_host() == "auth.localhost"
|
||||||
|
assert hostutil.auth_site_url() == "https://auth.localhost/"
|
||||||
|
# Session for requests coming from the auth host (sessions are host-bound)
|
||||||
|
_, token = create_test_session(
|
||||||
|
test_user.uuid, test_credential.uuid, host="auth.localhost"
|
||||||
|
)
|
||||||
|
return {**auth_headers(token), "Host": "auth.localhost"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_auth_host_updates_runtime(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
test_credential,
|
||||||
|
restore_runtime_config,
|
||||||
|
):
|
||||||
|
"""Removing auth_host must clear it from runtime config and URLs."""
|
||||||
|
headers = await self._set_auth_host(
|
||||||
|
client, session_token, test_user, test_credential
|
||||||
|
)
|
||||||
|
|
||||||
|
# The dialog still lists the old auth host among origins, so it is sent back
|
||||||
|
r = await client.patch(
|
||||||
|
"/auth/api/admin/server-config/",
|
||||||
|
json={
|
||||||
|
"rp_name": "",
|
||||||
|
"auth_host": "",
|
||||||
|
"origins": ["auth.localhost", "localhost"],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert db.data().config.auth_host is None
|
||||||
|
|
||||||
|
rt = runtime_config()
|
||||||
|
assert rt.config.auth_host is None
|
||||||
|
assert rt.site_path == "/auth/"
|
||||||
|
assert "auth.localhost" not in rt.site_url
|
||||||
|
assert hostutil.dedicated_auth_host() is None
|
||||||
|
assert "auth.localhost" not in hostutil.auth_site_url()
|
||||||
|
|
||||||
|
# GET and settings reflect the cleared state
|
||||||
|
r = await client.get(
|
||||||
|
"/auth/api/admin/server-config/",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert r.json()["auth_host"] == ""
|
||||||
|
r = await client.get("/auth/api/settings")
|
||||||
|
assert r.json()["auth_host"] is None
|
||||||
|
assert r.json()["ui_base_path"] == "/auth/"
|
||||||
|
|
||||||
|
# Middleware no longer redirects to the removed auth host
|
||||||
|
r = await client.get(
|
||||||
|
"/auth/admin",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert "auth.localhost" not in r.headers.get("location", "")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_auth_host_without_origins_falls_back_to_rp_id(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
test_credential,
|
||||||
|
restore_runtime_config,
|
||||||
|
):
|
||||||
|
"""With no origins left, site_url must not keep the removed auth host."""
|
||||||
|
headers = await self._set_auth_host(
|
||||||
|
client, session_token, test_user, test_credential
|
||||||
|
)
|
||||||
|
|
||||||
|
r = await client.patch(
|
||||||
|
"/auth/api/admin/server-config/",
|
||||||
|
json={"rp_name": "", "auth_host": "", "origins": []},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
rt = runtime_config()
|
||||||
|
assert rt.config.auth_host is None
|
||||||
|
assert rt.site_path == "/auth/"
|
||||||
|
assert "auth.localhost" not in rt.site_url
|
||||||
|
assert "auth.localhost" not in hostutil.auth_site_url()
|
||||||
|
|||||||
+361
-1
@@ -12,6 +12,8 @@ These tests cover:
|
|||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -19,8 +21,10 @@ import pytest
|
|||||||
from paskia import authcode
|
from paskia import authcode
|
||||||
from paskia.authsession import EXPIRES
|
from paskia.authsession import EXPIRES
|
||||||
from paskia.db import delete_session
|
from paskia.db import delete_session
|
||||||
|
from paskia.db.structs import Client
|
||||||
|
from paskia.util import avatar, hostutil, oidjwt, permutil
|
||||||
from paskia.util.passphrase import generate
|
from paskia.util.passphrase import generate
|
||||||
from tests.conftest import auth_headers, create_test_session
|
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsEndpoint:
|
class TestSettingsEndpoint:
|
||||||
@@ -46,6 +50,41 @@ class TestSettingsEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "ui_base_path" in data
|
assert "ui_base_path" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openid_configuration_includes_picture_claim(
|
||||||
|
self, client: httpx.AsyncClient
|
||||||
|
):
|
||||||
|
"""Discovery document should advertise picture claim support."""
|
||||||
|
response = await client.get("/.well-known/openid-configuration")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "picture" in response.json()["claims_supported"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAvatarUrls:
|
||||||
|
"""Tests for avatar URL helpers."""
|
||||||
|
|
||||||
|
def test_avatar_url_uses_canonical_public_path_in_auth_host_mode(
|
||||||
|
self, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Absolute avatar URLs should preserve /auth/api even with an auth host."""
|
||||||
|
db_root = tmp_path / "test-avatar-db.paskiadb"
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(db_root))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
hostutil,
|
||||||
|
"api_url",
|
||||||
|
lambda path="": f"https://auth.zi.fi/auth/api/{path.lstrip('/')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_uuid = test_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5")
|
||||||
|
path = db_root / "users" / str(test_uuid) / "profile.webp"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"RIFF1234WEBP")
|
||||||
|
|
||||||
|
assert avatar.avatar_url(user_uuid) == (
|
||||||
|
"https://auth.zi.fi/auth/api/user/"
|
||||||
|
"019c6831-84cf-7b88-b66c-c8165890b7c5/profile.webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestValidateEndpoint:
|
class TestValidateEndpoint:
|
||||||
"""Tests for POST /auth/api/validate"""
|
"""Tests for POST /auth/api/validate"""
|
||||||
@@ -194,6 +233,209 @@ class TestForwardEndpoint:
|
|||||||
assert data["auth"]["mode"] == "forbidden"
|
assert data["auth"]["mode"] == "forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermOrSemantics:
|
||||||
|
"""Tests for OR ('|') semantics and strict parsing of the perm argument"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_or_alternative_matches(
|
||||||
|
self, client: httpx.AsyncClient, session_token: str
|
||||||
|
):
|
||||||
|
"""Group is satisfied when any alternative matches."""
|
||||||
|
response = await client.post(
|
||||||
|
"/auth/api/validate?perm=missing:scope|auth:admin",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_or_no_alternative_matches(
|
||||||
|
self, client: httpx.AsyncClient, session_token: str
|
||||||
|
):
|
||||||
|
"""Group fails when no alternative matches."""
|
||||||
|
response = await client.post(
|
||||||
|
"/auth/api/validate?perm=missing:a|missing:b",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_or_combined_with_and_group(
|
||||||
|
self, client: httpx.AsyncClient, session_token: str
|
||||||
|
):
|
||||||
|
"""Space-separated groups are ANDed with OR groups."""
|
||||||
|
response = await client.post(
|
||||||
|
"/auth/api/validate?perm=auth:admin|missing:a+missing:b",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_perm_args_and_semantics(
|
||||||
|
self, client: httpx.AsyncClient, session_token: str
|
||||||
|
):
|
||||||
|
"""Repeated perm arguments remain ANDed."""
|
||||||
|
ok = await client.post(
|
||||||
|
"/auth/api/validate?perm=auth:admin&perm=auth:admin|missing:a",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200
|
||||||
|
denied = await client.post(
|
||||||
|
"/auth/api/validate?perm=auth:admin&perm=missing:a",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert denied.status_code == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forward_or_semantics(
|
||||||
|
self, client: httpx.AsyncClient, session_token: str
|
||||||
|
):
|
||||||
|
"""Forward endpoint supports OR semantics too."""
|
||||||
|
response = await client.get(
|
||||||
|
"/auth/api/forward?perm=missing:a|auth:admin",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extra_spaces_tolerated(
|
||||||
|
self, client: httpx.AsyncClient, session_token: str
|
||||||
|
):
|
||||||
|
"""Leading, trailing and repeated spaces between groups are tolerated."""
|
||||||
|
response = await client.post(
|
||||||
|
"/auth/api/validate?perm=%20auth:admin%20%20",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pipe_in_separate_arg_does_not_weaken(
|
||||||
|
self, client: httpx.AsyncClient, session_token: str
|
||||||
|
):
|
||||||
|
"""perm=auth:admin&perm=|bar is a syntax error, not an OR for auth:admin."""
|
||||||
|
response = await client.post(
|
||||||
|
"/auth/api/validate?perm=auth:admin&perm=|bar",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forward_400_identifies_origin_without_echoing_args(
|
||||||
|
self, client: httpx.AsyncClient, session_token: str
|
||||||
|
):
|
||||||
|
"""Forward 400 names the endpoint and does not echo query args."""
|
||||||
|
response = await client.get(
|
||||||
|
"/auth/api/forward?perm=a||b",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
detail = response.json()["detail"]
|
||||||
|
assert detail.startswith("/auth/api/forward")
|
||||||
|
assert "a||b" not in detail
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
"perm=", # empty value
|
||||||
|
"perm=a||b", # empty alternative
|
||||||
|
"perm=|a", # leading pipe
|
||||||
|
"perm=a|", # trailing pipe
|
||||||
|
"perm=a%20|%20b", # spaces around pipe
|
||||||
|
"perm=a%2Bb", # percent-encoded plus
|
||||||
|
"perm=a,b", # character not allowed in scopes
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_invalid_perm_syntax_returns_400(
|
||||||
|
self, client: httpx.AsyncClient, session_token: str, query: str
|
||||||
|
):
|
||||||
|
"""Out-of-spec perm values are rejected with 400, not guessed at."""
|
||||||
|
for path in ("/auth/api/validate", "/auth/api/forward"):
|
||||||
|
if path.endswith("validate"):
|
||||||
|
response = await client.post(
|
||||||
|
f"{path}?{query}",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = await client.get(
|
||||||
|
f"{path}?{query}",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400, f"{path}?{query}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermParsing:
|
||||||
|
"""Unit tests for permutil.parse_perm_args"""
|
||||||
|
|
||||||
|
def test_single_scope(self):
|
||||||
|
assert permutil.parse_perm_args(["auth:admin"]) == [("auth:admin",)]
|
||||||
|
|
||||||
|
def test_space_separated_groups(self):
|
||||||
|
assert permutil.parse_perm_args(["a b", "c"]) == [("a",), ("b",), ("c",)]
|
||||||
|
|
||||||
|
def test_or_group(self):
|
||||||
|
assert permutil.parse_perm_args(["a|b c"]) == [("a", "b"), ("c",)]
|
||||||
|
|
||||||
|
def test_wildcard_allowed(self):
|
||||||
|
assert permutil.parse_perm_args(["myapp:*|other"]) == [("myapp:*", "other")]
|
||||||
|
|
||||||
|
def test_extra_spaces_tolerated(self):
|
||||||
|
assert permutil.parse_perm_args([" a b ", "c"]) == [("a",), ("b",), ("c",)]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"values",
|
||||||
|
[
|
||||||
|
[""],
|
||||||
|
["a||b"],
|
||||||
|
["a | b"],
|
||||||
|
["a| b"],
|
||||||
|
["a+b"],
|
||||||
|
["a,b"],
|
||||||
|
["a\tb"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_syntax_errors(self, values):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
permutil.parse_perm_args(values)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermWildcards:
|
||||||
|
"""Unit tests for filename-like wildcard semantics in scope patterns"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"pattern,scope,expected",
|
||||||
|
[
|
||||||
|
("myapp:*", "myapp:read", True),
|
||||||
|
("myapp:*", "myapp:read:all", False), # * stays within one element
|
||||||
|
("myapp:**", "myapp:read:all", True), # ** crosses elements
|
||||||
|
("myapp:**", "myapp:", True),
|
||||||
|
("myapp:re*", "myapp:read", True), # partial element, suffix wildcard
|
||||||
|
("myapp:*ad", "myapp:read", True), # prefix wildcard
|
||||||
|
("myapp:r*d", "myapp:read", True), # text on both sides
|
||||||
|
("myapp:r*d", "myapp:redo", False),
|
||||||
|
("*:read", "myapp:read", True),
|
||||||
|
("*", "myapp:read", False),
|
||||||
|
("**", "myapp:read", True),
|
||||||
|
("myapp:*:all", "myapp:read:all", True),
|
||||||
|
("myapp:*:all", "myapp:read:write:all", False),
|
||||||
|
# regex metacharacters valid in scopes are matched literally
|
||||||
|
("my.app:*", "my.app:read", True),
|
||||||
|
("my.app:*", "myXapp:read", False),
|
||||||
|
("myapp:v1.*", "myapp:v1.2", True),
|
||||||
|
("myapp:v1.*", "myapp:v1x2", False),
|
||||||
|
("my-app_*:~*", "my-app_x:~tmp", True),
|
||||||
|
# slash is a literal separator; * crosses neither / nor :
|
||||||
|
("myapp:path:/api/clients:write", "myapp:path:/api/clients:write", True),
|
||||||
|
("myapp:path:*", "myapp:path:/api/clients", False),
|
||||||
|
("myapp:path:**", "myapp:path:/api/clients", True),
|
||||||
|
("myapp:path:/api/*:write", "myapp:path:/api/clients:write", True),
|
||||||
|
("myapp:path:/api/*:write", "myapp:path:/api/v2/clients:write", False),
|
||||||
|
("myapp:path:/api/**:write", "myapp:path:/api/v2/clients:write", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_wildcard_matching(self, pattern, scope, expected):
|
||||||
|
assert permutil.has_all_scopes_groups({scope}, [(pattern,)]) is expected
|
||||||
|
|
||||||
|
|
||||||
class TestLogoutEndpoint:
|
class TestLogoutEndpoint:
|
||||||
"""Tests for POST /auth/api/logout"""
|
"""Tests for POST /auth/api/logout"""
|
||||||
|
|
||||||
@@ -294,6 +536,124 @@ class TestUserInfoEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "permissions" in data
|
assert "permissions" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_info_includes_avatar_url(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""User info should include the canonical avatar URL when present."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
upload = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 200
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/auth/api/user-info",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
avatar_url = data["user"]["avatar_url"]
|
||||||
|
parts = urlsplit(avatar_url)
|
||||||
|
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||||
|
assert parts.query == ""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_avatar_route_returns_304_for_matching_etag(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Avatar route should honor If-None-Match for unchanged avatars."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
upload = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 200
|
||||||
|
parts = urlsplit(upload.json()["avatar_url"])
|
||||||
|
|
||||||
|
first = await client.get(parts.path, headers={"Host": "localhost:4401"})
|
||||||
|
assert first.status_code == 200
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
headers={
|
||||||
|
"Host": "localhost:4401",
|
||||||
|
"If-None-Match": first.headers["etag"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 304
|
||||||
|
assert response.headers["etag"] == first.headers["etag"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOidcUserInfoEndpoint:
|
||||||
|
"""Tests for OIDC userinfo metadata relevant to avatars."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_userinfo_includes_picture_claim(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
test_db,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""OIDC userinfo should expose picture when profile scope is granted."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
upload = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 200
|
||||||
|
avatar_url = upload.json()["avatar_url"]
|
||||||
|
|
||||||
|
oidc_client, _secret = Client.create(
|
||||||
|
name="Test Client",
|
||||||
|
redirect_uris=["https://client.example/callback"],
|
||||||
|
client_secret="topsecret",
|
||||||
|
)
|
||||||
|
store = test_db._store
|
||||||
|
if store is None:
|
||||||
|
raise RuntimeError("Test DB store is not initialized")
|
||||||
|
with store.transaction("create_test_oidc_client"):
|
||||||
|
test_db.oidc.clients[oidc_client.uuid] = oidc_client
|
||||||
|
|
||||||
|
access_token = oidjwt.create_access_token(
|
||||||
|
issuer="http://localhost:4401",
|
||||||
|
subject=test_user.uuid,
|
||||||
|
audience=str(oidc_client.uuid),
|
||||||
|
scope="openid profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/auth/oidc/userinfo",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Host": "localhost:4401",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert urlsplit(data["picture"]).path == urlsplit(avatar_url).path
|
||||||
|
assert data["picture"].startswith("http")
|
||||||
|
|
||||||
|
|
||||||
class TestSetSessionEndpoint:
|
class TestSetSessionEndpoint:
|
||||||
"""Tests for POST /auth/api/set-session"""
|
"""Tests for POST /auth/api/set-session"""
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Tests for the CLI entry point in paskia/__main__.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from kanta import Kanta
|
||||||
|
|
||||||
|
from paskia.__main__ import main
|
||||||
|
from paskia.db.structs import DB, Config
|
||||||
|
from paskia.util.runtime import clear_config_cache
|
||||||
|
from paskia.util.runtime import config as runtime_config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cli_run(monkeypatch):
|
||||||
|
"""Run the CLI main() with the given args and return the RuntimeConfig."""
|
||||||
|
|
||||||
|
def _run(*args: str, db_root: str | None = None) -> Any:
|
||||||
|
env = os.environ.copy()
|
||||||
|
if db_root is not None:
|
||||||
|
env["PASKIA_DB"] = db_root
|
||||||
|
monkeypatch.setattr(os, "environ", env)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys, "argv", ["paskia", *args])
|
||||||
|
monkeypatch.setattr("fastapi_vue.server.run", lambda *_args, **_kw: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"paskia.util.startupbox.print_startup_config", lambda _rt: None
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("logging.basicConfig", lambda **_kw: None)
|
||||||
|
|
||||||
|
clear_config_cache()
|
||||||
|
main()
|
||||||
|
runtime = runtime_config()
|
||||||
|
clear_config_cache()
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
return _run
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_config(db_path: Path, config: Config) -> None:
|
||||||
|
"""Write a Config into a JSONL database file using Kanta.
|
||||||
|
|
||||||
|
The initial root uses a different rp_id so the stored diff includes the
|
||||||
|
target rp_id (required because Config omits defaults when diffing).
|
||||||
|
"""
|
||||||
|
kanta = Kanta(
|
||||||
|
str(db_path),
|
||||||
|
DB(config=Config(rp_id="uninitialized.invalid")),
|
||||||
|
migrations="paskia.db.migrations",
|
||||||
|
)
|
||||||
|
kanta.ctx.rp_id = config.rp_id
|
||||||
|
await kanta.open()
|
||||||
|
with kanta.transaction("test:write_config"):
|
||||||
|
kanta.data.config = config
|
||||||
|
await kanta.close()
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(db_path: Path, config: Config) -> None:
|
||||||
|
"""Synchronous wrapper for _write_config."""
|
||||||
|
asyncio.run(_write_config(db_path, config))
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_defaults(cli_run):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
runtime = cli_run("--rp-id", "localhost", db_root=tmp)
|
||||||
|
|
||||||
|
assert runtime.config.rp_id == "localhost"
|
||||||
|
assert runtime.config.rp_name is None
|
||||||
|
assert runtime.config.auth_host is None
|
||||||
|
assert runtime.config.origins is None
|
||||||
|
assert runtime.site_url == "http://localhost:4401"
|
||||||
|
assert runtime.site_path == "/auth/"
|
||||||
|
assert runtime.save is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_explicit_options(cli_run):
|
||||||
|
runtime = cli_run(
|
||||||
|
"--rp-id",
|
||||||
|
"example.com",
|
||||||
|
"--rp-name",
|
||||||
|
"Example Corp",
|
||||||
|
"--auth-host",
|
||||||
|
"auth.example.com",
|
||||||
|
"--origin",
|
||||||
|
"https://app.example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert runtime.config.rp_id == "example.com"
|
||||||
|
assert runtime.config.rp_name == "Example Corp"
|
||||||
|
assert runtime.config.auth_host == "https://auth.example.com"
|
||||||
|
assert runtime.config.origins == [
|
||||||
|
"https://auth.example.com",
|
||||||
|
"https://app.example.com",
|
||||||
|
]
|
||||||
|
assert runtime.site_url == "https://auth.example.com"
|
||||||
|
assert runtime.site_path == "/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_loads_stored_config(cli_run):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
db_path = Path(tmp) / "main.db"
|
||||||
|
write_config(
|
||||||
|
db_path,
|
||||||
|
Config(
|
||||||
|
rp_id="example.com",
|
||||||
|
rp_name="Stored Name",
|
||||||
|
origins=["https://stored.example.com"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
runtime = cli_run("--rp-id", "example.com", db_root=tmp)
|
||||||
|
|
||||||
|
assert runtime.config.rp_name == "Stored Name"
|
||||||
|
assert runtime.config.origins == ["https://stored.example.com"]
|
||||||
|
assert runtime.site_url == "https://stored.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_overrides_stored_config(cli_run):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
db_path = Path(tmp) / "main.db"
|
||||||
|
write_config(db_path, Config(rp_id="example.com", rp_name="Stored Name"))
|
||||||
|
runtime = cli_run(
|
||||||
|
"--rp-id", "example.com", "--rp-name", "Overridden", db_root=tmp
|
||||||
|
)
|
||||||
|
|
||||||
|
assert runtime.config.rp_name == "Overridden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_save_flag(cli_run):
|
||||||
|
runtime = cli_run("--save")
|
||||||
|
assert runtime.save is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_invalid_auth_host(cli_run):
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cli_run("--rp-id", "example.com", "--auth-host", "notsub.example.org")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_help():
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "paskia", "--help"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "Paskia authentication server" in result.stdout
|
||||||
+161
-1
@@ -3,16 +3,20 @@ Tests for the user API endpoints (/auth/api/user/).
|
|||||||
|
|
||||||
These tests cover user self-service operations:
|
These tests cover user self-service operations:
|
||||||
- Display name update
|
- Display name update
|
||||||
|
- Avatar upload/delete
|
||||||
- Logout all sessions
|
- Logout all sessions
|
||||||
- Session management (delete specific session)
|
- Session management (delete specific session)
|
||||||
- Credential management (delete credential)
|
- Credential management (delete credential)
|
||||||
- Device addition link creation
|
- Device addition link creation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.conftest import auth_headers
|
from paskia.db.paths import db_file_path, users_root_path
|
||||||
|
from tests.conftest import auth_headers, create_test_image_bytes
|
||||||
|
|
||||||
|
|
||||||
class TestUserDisplayName:
|
class TestUserDisplayName:
|
||||||
@@ -67,6 +71,162 @@ class TestUserDisplayName:
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserAvatar:
|
||||||
|
"""Tests for PUT/DELETE /auth/api/user/{user_uuid}/profile.webp"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_avatar_requires_auth(self, client: httpx.AsyncClient):
|
||||||
|
"""Uploading avatar without auth should return 401."""
|
||||||
|
response = await client.put(
|
||||||
|
"/auth/api/user/00000000-0000-0000-0000-000000000000/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
)
|
||||||
|
assert response.status_code in (401, 404)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_avatar_success(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Uploading a WebP avatar should store and expose the canonical URL."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
upload_bytes = create_test_image_bytes()
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", upload_bytes, "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
avatar_url = data["avatar_url"]
|
||||||
|
parts = urlsplit(avatar_url)
|
||||||
|
assert parts.query == ""
|
||||||
|
|
||||||
|
avatar_response = await client.get(
|
||||||
|
parts.path,
|
||||||
|
headers={"Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert avatar_response.status_code == 200
|
||||||
|
assert avatar_response.headers["cache-control"] == "public, max-age=300"
|
||||||
|
assert avatar_response.headers["content-type"] == "image/webp"
|
||||||
|
assert "etag" in avatar_response.headers
|
||||||
|
assert avatar_response.content == upload_bytes
|
||||||
|
|
||||||
|
not_modified = await client.get(
|
||||||
|
parts.path,
|
||||||
|
headers={
|
||||||
|
"Host": "localhost:4401",
|
||||||
|
"If-None-Match": avatar_response.headers["etag"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert not_modified.status_code == 304
|
||||||
|
assert not_modified.headers["etag"] == avatar_response.headers["etag"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_avatar_rejects_non_webp(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Avatar uploads must already be browser-prepared WebP."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={
|
||||||
|
"file": (
|
||||||
|
"avatar.png",
|
||||||
|
create_test_image_bytes(image_format="PNG"),
|
||||||
|
"image/png",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "Avatar upload must be WebP"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_avatar_success(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Deleting avatar should clear the user avatar URL."""
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||||
|
|
||||||
|
await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.delete(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
info = await client.get(
|
||||||
|
"/auth/api/user-info",
|
||||||
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert info.status_code == 200
|
||||||
|
assert info.json()["user"].get("avatar_url") is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_regular_user_cannot_upload_another_users_avatar(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
regular_session_token: str,
|
||||||
|
session_token: str,
|
||||||
|
test_user,
|
||||||
|
):
|
||||||
|
"""A non-admin user should not be able to upload another user's avatar."""
|
||||||
|
response = await client.put(
|
||||||
|
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||||
|
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||||
|
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_paskia_db_legacy_file_is_migrated_to_root_dir(tmp_path, monkeypatch):
|
||||||
|
legacy_path = tmp_path / "legacy.paskiadb"
|
||||||
|
legacy_bytes = b'{"v":0}\n'
|
||||||
|
legacy_path.write_bytes(legacy_bytes)
|
||||||
|
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(legacy_path))
|
||||||
|
|
||||||
|
db_path = db_file_path(create_root=True)
|
||||||
|
|
||||||
|
assert legacy_path.is_dir()
|
||||||
|
assert db_path == legacy_path / "main.db"
|
||||||
|
assert db_path.read_bytes() == legacy_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_paskia_db_root_uses_users_directory(tmp_path, monkeypatch):
|
||||||
|
root_path = tmp_path / "instance-root"
|
||||||
|
monkeypatch.setenv("PASKIA_DB", str(root_path))
|
||||||
|
|
||||||
|
users_path = users_root_path(create_root=True)
|
||||||
|
|
||||||
|
assert users_path == root_path / "users"
|
||||||
|
assert users_path.parent == root_path
|
||||||
|
|
||||||
|
|
||||||
class TestUserLogoutAll:
|
class TestUserLogoutAll:
|
||||||
"""Tests for POST /auth/api/user/logout-all"""
|
"""Tests for POST /auth/api/user/logout-all"""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user