Compare commits

..
5 Commits
Author SHA1 Message Date
LeoVasanko 2456730f70 Check max-age only after checking permissions: if neither is passing, we want a 403 error; simply authenticating again won't fix it so don't bother reauth flow. After forbidden flow e.g. account change we are already good with max-age too. 2026-08-11 00:56:01 +00:00
LeoVasanko f74bf3ebe6 Require Python 3.14, ruff formatting for simpler typing. 2026-08-11 00:46:45 +00:00
LeoVasanko 79074dd4f1 OR semantics in perm query arg, strict parsing, segment-aware wildcards
perm=a|b+c now means (a or b) and c; repeated perm args remain ANDed.
Out-of-spec values (empty alternatives, chars outside the scope charset,
stray %2B) are rejected with 400 instead of being silently misparsed;
extra spaces between groups are tolerated. Forward endpoint 400/500
details name /auth/api/forward as origin without echoing query args.
Wildcards are now filename-like: * stays within a :- or /-separated
segment, ** spans segments, partial segments allowed. Slash added to
allowed scope characters for path-based permissions.
2026-08-11 00:44:58 +00:00
LeoVasanko 00ef0ae2e7 Update API and proxy docs 2026-08-10 21:35:46 +00:00
LeoVasanko 6f5287e070 Avoid clearing session.user_agent if a validation request lack this header. Backend-initiated session validations may not have the data. 2026-08-10 14:09:42 +00:00
31 changed files with 1506 additions and 170 deletions
+2 -2
View File
@@ -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.
@@ -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 -71
View File
@@ -1,102 +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 |
|---:|---|---|---| |---:|---|---|---|
| PATCH | `/auth/api/user/display-name` | Update the users display name | Body: JSON `{ "display_name": "..." }` | | PATCH | /auth/api/user/display-name | Update the user's display name | 200/401 |
| GET | `/auth/api/user/{uuid}/profile.webp` | Canonical avatar image URL | Public on the auth host; serves `image/webp` with `ETag` and short-lived cache headers | | POST | /auth/api/user/logout-all | Terminate all user sessions | 200/401 |
| PUT | `/auth/api/user/{uuid}/profile.webp` | Upload or replace a user avatar | Multipart form with `file`; upload must already be square WebP prepared in the browser | | DELETE | /auth/api/user/session/{session_id} | Terminate one session | 200/401 |
| DELETE | `/auth/api/user/{uuid}/profile.webp` | Remove a user avatar | Allowed for the user, master admin, or org admin for users in the same org | | DELETE | /auth/api/user/credential/{uuid} | Delete a credential; requires recent authentication | 200/401/403 |
| POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie | | POST | /auth/api/user/create-link | Create a device-add link; requires recent authentication | 200/401/403 |
| DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued | | GET | /auth/api/user/{uuid}/profile.webp | Canonical avatar image URL, public on the auth host | 200/304/404 |
| DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication | | PUT | /auth/api/user/{uuid}/profile.webp | Upload or replace an avatar; square WebP prepared in the browser | 200/401/403 |
| POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication | | 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. The avatar route is also used by admins when managing other users. These are used mostly from the user profile panel by the user himself, but the profile pictures are public for all to read.
`GET /auth/api/user-info` includes `user.avatar_url` when the user has an uploaded avatar, using the same canonical `/auth/api/user/{uuid}/profile.webp` path.
### Admin API: `/auth/api/admin/*` ### 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 |
Admins edit user avatars through the same canonical `/auth/api/user/{uuid}/profile.webp` PUT and DELETE endpoints. ### 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
View File
@@ -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
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
# GET /auth/api/forward
[API overview](../API.md) · [Proxy guides](../proxy/index.md)
Forward-auth validation for reverse proxies. The proxy calls this endpoint for every incoming request; Paskia validates the session and either authorizes the request by returning 204 with `Remote-*` headers, or rejects it with 401/403 error responses with an HTML login page if `text/html` was requested (i.e. it's a browser viewing the page), or otherwise JSON with details on the error and a URL to initiate API authentication flow.
The proxy server follows the 204 response with the original request to protected service, adding those remote headers to original user request, sent to the service. Any error response is sent directly back to client, never connecting to the protected service.
See [Forward-Auth Proxy Guides](../proxy/index.md) for Caddy, Nginx, Traefik, Apache APISIX, Envoy and HAProxy configuration examples. Caddy users can also start from the dedicated [Caddy configuration guide](../proxy/caddy.md).
## Query parameters
| Parameter | Description |
|-----------|-------------|
| perm | Required permissions. See the [perm argument](perm.md). |
| max_age | Require recent passkey use. See the [max_age argument](max-age.md). |
## Request headers
| Header | Expected value / note | How Paskia uses it |
|---|---|---|
| Host | Forwarded directly from the client | Verifying the session's bound host |
| Cookie | Forwarded directly or just cookie `__Host-paskia` | Session ID |
| X-Forwarded-Method | The HTTP method, e.g. POST | Logging of original request |
| X-Forwarded-Uri | Request path and query, e.g. /reports?foo=bar | Logging of original request |
| Accept | text/html or anything else | Determines whether failures return an HTML page or JSON |
Connection hop-by-hop headers (Connection, Upgrade, Transfer-Encoding, etc.) must not be forwarded.
## Response
### Success (204)
No response body. Only [Remote headers](../Headers.md) are set on the response and your proxy should forward them to the backend request. The headers, not a response body, are the whole point of this endpoint: they are how the authenticated identity reaches the protected service, which can trust them because it is only reachable through the proxy.
### Failure (400 / 401 / 403)
| Status | Meaning |
|---|---|
| 400 | Malformed perm argument (see [perm](perm.md#syntax-errors)). The error detail names `/auth/api/forward` as the origin and never echoes query arguments. |
| 401 | Session missing or expired, or max_age not satisfied — the user needs to (re)authenticate. |
| 403 | Requested permissions are missing — the forbidden flow allows signing in with another account. |
Failure responses come in two flavors, chosen by the Accept header, because the audience differs:
- A browser asking for a page (Accept includes text/html) gets a full authentication page it can show directly — the proxy simply passes the response through and the user can sign in without any application involvement.
- Any other request (fetch, img, ...) gets JSON intended for programmatic handling. Besides the error detail, it carries an auth section whose iframe URL points to a ready-made authentication dialog your frontend can embed, so the user can sign in without leaving your app:
```json
{
"detail": "Additional authentication required",
"auth": {
"mode": "reauth",
"iframe": "/auth/restricted/iframe#mode=reauth&theme=dark"
}
}
```
The mode field:
- login: no valid session, need to sign in
- reauth: additional authentication required (using same passkey)
- forbidden: lacking required permissions
Extra metadata such as the user's theme override may appear as additional fields and iframe fragment parameters.
Note: we provide a JavaScript package [paskia](https://www.npmjs.com/package/paskia) with helpers for the embedding, fetch 401/403 handling, session renewals and more.
## Session renewal
The forward endpoint **does not renew** the session because in the forward-auth mechanism it could not send the client a renewed session cookie. It only validates the current cookie and returns the trusted headers. Use [/auth/api/validate](validate.md) when you need to refresh the session lifetime. Otherwise the user will have to sign in again every 24h even if they are actively using the service.
+47
View File
@@ -0,0 +1,47 @@
# The max_age argument
[API overview](../API.md) · [`/auth/api/validate`](validate.md) · [`/auth/api/forward`](../forward.md)
The max_age argument is used by endpoints that validate sessions to require a recent passkey use. It is supported by:
- [POST /auth/api/validate](validate.md)
- [GET /auth/api/forward](forward.md)
## What it checks
max_age limits how long ago the user last authenticated with their passkey. It is intended for high-risk actions where you want to be sure the user recently proved possession of their credential, not just that they still have a valid session cookie.
The check compares the elapsed time since the credential was last used against the given limit:
- If the credential has a last_used timestamp, that time is used.
- Otherwise, the session's validated timestamp is used as a fallback.
If the authentication is older than max_age, the endpoint returns 401 with mode reauth, prompting the user to re-authenticate.
## Time units
max_age accepts a number followed by one of these units:
| Unit | Meaning |
|---|---|
| s | seconds |
| m / min | minutes |
| h | hours |
| d | days |
Examples:
```text
?max_age=30s
?max_age=5m
?max_age=5min
?max_age=1h
?max_age=1d
```
An invalid format is logged as a warning but does not cause the request to fail; the requirement is simply ignored in that case.
## Important notes
- Session renewal by /auth/api/validate does **not** count as fresh authentication. The check is based on the credential's last_used time, not on how recently the session cookie was renewed.
- max_age is independent of perm. You can use either or both at the same time.
+96
View File
@@ -0,0 +1,96 @@
# The perm argument
[API overview](../API.md) · [`/auth/api/validate`](validate.md) · [`/auth/api/forward`](../forward.md)
The perm argument is used by endpoints that validate sessions to require one or more permission scopes. It is supported by:
- [POST /auth/api/validate](validate.md)
- [GET /auth/api/forward](forward.md)
## Passing the argument
Repeat the query parameter for each required scope:
```text
?perm=myapp:read&perm=myapp:write
```
You can also pass multiple scopes in one parameter by separating them with whitespace (a literal space, i.e. `+` or `%20` in the query string):
```text
?perm=myapp:read%20myapp:write
```
Both forms produce the same result.
## Semantics
The perm argument uses **AND** semantics between groups: **every** listed scope group must be satisfied by the effective permissions for the request to succeed. If any required group is not satisfied, the endpoint returns 403.
Within a single group, use `|` to list alternatives with **OR** semantics — the group is satisfied when **any one** of the alternatives is present:
```text
?perm=myapp:read|myapp:write+myapp:login
```
This requires `myapp:login` **and** (`myapp:read` **or** `myapp:write`). No whitespace is allowed around the `|` operator.
## Syntax errors
Parsing is strict: anything out of spec is rejected with **400 Bad Request** rather than guessed at. This includes:
- Empty values or alternatives (`?perm=`, `?perm=a||b`, `?perm=|a`, `?perm=a|`)
- Whitespace around `|` (`?perm=a+|+b`)
- Characters not allowed in scopes other than the operators (space and `|`); scopes match `^[A-Za-z0-9:._~/-]+$` plus the `*` wildcard. In particular a literal `+` in the decoded value (from a `%2B` in the query string) is rejected — use `+` or `%20` to encode a space, never `%2B`.
Extra spaces between groups (leading, trailing, or repeated) are tolerated, since they can easily result from URL formatting and carry no ambiguity — they only ever add required permissions, never remove them. The `|` operator is parsed strictly: `?perm=foo&perm=|bar` is an error, never a way to make `foo` optional.
## Wildcards
Wildcards work like filenames, with `:` and `/` acting as path separators:
- `*` matches any sequence of characters **within a single segment** (it never crosses a `:` or `/`)
- `**` matches any sequence of characters, **across separators**
- Part of a segment can be wildcarded, with required text on either or both sides
```text
?perm=myapp:*
```
This matches myapp:read and myapp:write, but **not** myapp:read:all — use `myapp:**` for that. Partial wildcards like `myapp:re*` or `myapp:r*d` match myapp:read. The same applies to path-based scopes: `myapp:path:/api/*` matches myapp:path:/api/clients but not myapp:path:/api/v2/clients — use `myapp:path:/api/**` to span path segments.
## Effective permissions
The permissions available to a session are determined as follows:
1. **Role permissions** — the role assigned to the user contains a set of permission UUIDs.
2. **Org grantable permissions** — only permissions that the user's organization is allowed to grant are effective.
3. **Domain filtering** — a permission can be restricted to a specific domain via its domain field. If the request's Host header does not match that domain, the permission is excluded.
The result is the set of effective permission scopes used for the perm check. Domain-restricted permissions let you grant a scope only for a specific site or subdomain without making it global.
## Examples
Require a single permission:
```text
?perm=myapp:login
```
Require two permissions:
```text
?perm=myapp:login&perm=myapp:api
```
Require any scope under myapp:
```text
?perm=myapp:*
```
Require myapp:login and either myapp:read or myapp:write:
```text
?perm=myapp:login&perm=myapp:read|myapp:write
```
+66
View File
@@ -0,0 +1,66 @@
# POST /auth/api/validate
[API overview](../API.md) · [`/auth/api/forward`](../forward.md) · [max_age](max-age.md) · [perm](perm.md)
Validate a session and renew its lifetime when needed. This endpoint is normally called by the browser/session validator, but backends may also call it directly when not behind a forward-auth proxy.
See also the [API overview](../API.md) and the [integration guide](../Integration.md).
## Query parameters
| Parameter | Description |
|-----------|-------------|
| perm | Required permissions. See the [perm argument](perm.md). |
| max_age | Require recent passkey use. See the [max_age argument](max-age.md). |
## Request headers
| Header | Expected value / note | How Paskia uses it |
|---|---|---|
| Host | Forwarded directly from the client | Verifying the session's bound host |
| Cookie | Forwarded directly or just cookie `__Host-paskia` | Session ID |
| X-Forwarded-For | Real client IP | Recorded in session data instead of the backend/proxy IP; requires FORWARDED_ALLOW_IPS to trust the immediate peer |
| User-Agent | Forward the original client UA if available; do not let your backend client add its own default | Recorded in session data only when the header is present; omitting it preserves the existing value |
See [integration documentation for backend validate requests](../Integration.md) for more detailed instructions, in particular for forwarding of client-provided headers.
## Response
The endpoint always responds with JSON.
### Success (200)
```json
{
"valid": true,
"renewed": false,
"ctx": {
"user": {
"uuid": "...",
"display_name": "John Smith",
"theme": "dark"
},
"org": {
"uuid": "...",
"display_name": "The Company Ltd."
},
"role": {
"uuid": "...",
"display_name": "Employee"
},
"permissions": ["auth:admin", "myapp:login"]
}
}
```
If the response includes a Set-Cookie header, the session has been renewed (renewed is true) and you should forward that cookie to the client so the browser updates its expiry. Not forwarding it means the session lifetime is not extended, so the user may need to re-authenticate sooner. Renewals are throttled, so frequent calls usually return renewed: false with no Set-Cookie.
### Failure (400 / 401 / 403)
| Status | Meaning |
|---|---|
| 400 | Malformed perm argument (see [perm](perm.md#syntax-errors)). |
| 401 | Session missing, expired, or max_age not satisfied. The response body includes auth metadata for the login/reauth iframe. |
| 403 | Session is valid but one or more requested permissions are missing. |
A failure response never contains a refreshed Set-Cookie.
+163
View File
@@ -0,0 +1,163 @@
# Apache APISIX Forward-Auth
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
This guide uses the Apache APISIX [`forward-auth`](https://apisix.apache.org/docs/apisix/plugins/forward-auth/) plugin to ask Paskia whether each request is allowed.
## Overview
APISIX adds the standard `X-Forwarded-*` headers automatically, but we still tell the plugin to forward `Host`, `Cookie`, and `Accept` from the client request. On a `204` response from Paskia we copy the `Remote-*` headers to the backend request.
## Admin API example
```sh
# Route that proxies /auth/ to Paskia without forward-auth.
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${admin_key}" \
-H 'Content-Type: application/json' \
-d '{
"id": "paskia-auth-ui",
"uri": "/auth/*",
"upstream": {
"nodes": { "localhost:4401": 1 },
"type": "roundrobin"
}
}'
# Protected route that uses Paskia forward-auth.
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${admin_key}" \
-H 'Content-Type: application/json' \
-d '{
"id": "app-protected",
"uri": "/*",
"priority": 10,
"plugins": {
"forward-auth": {
"uri": "http://localhost:4401/auth/api/forward?perm=myapp:login",
"request_headers": [
"Host",
"Cookie",
"Accept",
"X-Forwarded-Method",
"X-Forwarded-Uri",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Forwarded-For"
],
"upstream_headers": [
"Remote-User",
"Remote-Name",
"Remote-Groups",
"Remote-Org",
"Remote-Org-Name",
"Remote-Role",
"Remote-Role-Name",
"Remote-Session-Expires",
"Remote-Credential"
]
}
},
"upstream": {
"nodes": { "localhost:3000": 1 },
"type": "roundrobin"
}
}'
```
The `paskia-auth-ui` route has a higher priority (`priority` defaults to the same value for both routes; you can also rely on the more specific `/auth/*` URI matching first). Because it does not use the `forward-auth` plugin, users can reach the login/profile pages without already being authenticated.
## ADC / declarative example
```yaml
services:
- name: paskia-auth-ui
routes:
- name: auth-route
uris:
- /auth/*
upstream:
type: roundrobin
nodes:
- host: localhost
port: 4401
weight: 1
- name: app-protected
routes:
- name: app-route
uris:
- /*
plugins:
forward-auth:
uri: http://localhost:4401/auth/api/forward?perm=myapp:login
request_headers:
- Host
- Cookie
- Accept
- X-Forwarded-Method
- X-Forwarded-Uri
- X-Forwarded-Host
- X-Forwarded-Proto
- X-Forwarded-For
upstream_headers:
- Remote-User
- Remote-Name
- Remote-Groups
- Remote-Org
- Remote-Org-Name
- Remote-Role
- Remote-Role-Name
- Remote-Session-Expires
- Remote-Credential
upstream:
type: roundrobin
nodes:
- host: localhost
port: 3000
weight: 1
```
Apply it with:
```sh
adc sync -f paskia.yaml
```
## What APISIX sends to Paskia
APISIX automatically adds these headers to the auth request:
| Header | Value |
|---|---|
| `X-Forwarded-Method` | Original HTTP method |
| `X-Forwarded-Proto` | Request scheme (`http`/`https`) |
| `X-Forwarded-Host` | Original host |
| `X-Forwarded-Uri` | Original request URI |
| `X-Forwarded-For` | Client IP address |
We list them again in `request_headers` to make sure they are not accidentally filtered out when the list is explicit.
## Response headers
`upstream_headers` lists the `Remote-*` headers that APISIX copies from the auth response to the backend request. The plugin does not support a wildcard here, so each header must be named.
If you want Paskia's failure-response headers (such as `Content-Type` or `Set-Cookie`) to reach the client, list them in `client_headers`. For Paskia this is usually not needed; the response body already contains the JSON auth URL or the HTML login page.
## Adjusting requirements
Change the `uri` query string to require different permissions or recent authentication:
```yaml
uri: http://localhost:4401/auth/api/forward?perm=myapp:login
uri: http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min
uri: http://localhost:4401/auth/api/forward
```
The last form requires only authentication. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md).
## Notes
- The auth request is `GET` by default. Since the `forward-auth` plugin does not forward the request body unless `request_method` is set to `POST`, the default `GET` is the right choice for Paskia.
- Hop-by-hop headers are handled by APISIX when it builds the auth request, so no extra configuration is needed for `Connection`/`Upgrade`.
- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (`--auth-host`), route `auth.example.com` to Paskia instead of `/auth/`.
+4 -2
View File
@@ -1,13 +1,15 @@
# Paskia Caddy Configuration # 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:
+170
View File
@@ -0,0 +1,170 @@
# Envoy External Authorization (ext_authz)
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
This guide uses Envoy's [external authorization filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) (`ext_authz`) to ask Paskia whether each request is allowed.
## Overview
Envoy's `ext_authz` HTTP filter calls an external HTTP service before forwarding a request to the upstream. The filter needs to know which headers from the original request to send to Paskia, and which headers from Paskia's response to add to the upstream request or to the client response.
A minimal static configuration looks like this:
```yaml
static_resources:
listeners:
- name: app_listener
address:
socket_address:
address: 0.0.0.0
port_value: 8080
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
use_remote_address: true
route_config:
name: local_route
virtual_hosts:
- name: app
domains: ["*"]
routes:
# Pass /auth/ straight to Paskia, bypassing ext_authz.
- match:
prefix: "/auth/"
route:
cluster: paskia
typed_per_filter_config:
envoy.filters.http.ext_authz:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
disabled: true
# Protect everything else.
- match:
prefix: "/"
route:
cluster: app_backend
http_filters:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: v3
http_service:
server_uri:
uri: localhost:4401
cluster: paskia
timeout: 0.5s
# Send every auth check to /auth/api/forward with the
# required permission, regardless of the original path.
path_override: "/auth/api/forward?perm=myapp:login"
authorization_request:
allowed_headers:
patterns:
- exact: Host
- exact: Cookie
- exact: Accept
# Add the headers Paskia logs / expects.
headers_to_add:
- key: X-Forwarded-Method
value: "%REQ(:METHOD)%"
- key: X-Forwarded-Uri
value: "%REQ(:PATH)%"
- key: X-Forwarded-Proto
value: "%REQ(:SCHEME)%"
- key: X-Forwarded-For
value: "%REQ(X-Forwarded-For)%"
authorization_response:
# Forward every Remote-* header to the backend.
allowed_upstream_headers:
patterns:
- prefix: Remote-
# Forward the response Content-Type to the client on 401/403.
allowed_client_headers:
patterns:
- exact: Content-Type
failure_mode_allow: false
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: app_backend
connect_timeout: 0.25s
type: logical_dns
lb_policy: round_robin
load_assignment:
cluster_name: app_backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: localhost
port_value: 3000
- name: paskia
connect_timeout: 0.25s
type: logical_dns
lb_policy: round_robin
load_assignment:
cluster_name: paskia
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: localhost
port_value: 4401
```
## Important configuration details
- **`path_override`** — the auth request always goes to `/auth/api/forward?perm=myapp:login`, no matter which path the client requested. The original path is sent in `X-Forwarded-Uri` for Paskia to log.
- **`authorization_request.allowed_headers`** — Envoy only forwards the headers you explicitly allow. We allow `Host`, `Cookie`, and `Accept`. The `X-Forwarded-*` headers are added via `headers_to_add` so they are based on Envoy's view of the request, not spoofed client values.
- **`headers_to_add`** — Envoy supports substitution format strings such as `%REQ(:METHOD)%` and `%REQ(:PATH)%`. These set the headers Paskia uses for logging and host validation.
- **`authorization_response.allowed_upstream_headers`** — `prefix: Remote-` tells Envoy to copy every response header starting with `Remote-` to the upstream request. This also removes any client-supplied `Remote-*` headers, so the backend can trust them.
- **`authorization_response.allowed_client_headers`** — on a 401/403 response, Envoy forwards only the allowed response headers to the client. Paskia returns HTML or JSON with a `Content-Type` header, so we allow that. (Paskia does not set cookies on the forward-auth response.)
- **`typed_per_filter_config`** on the `/auth/` route disables `ext_authz` so users can reach the login/profile pages without already being authenticated.
## Per-route requirements
Different routes often need different permissions or `max_age` values. Use `typed_per_filter_config` on each route to override the `http_service.path_override`:
```yaml
routes:
- match:
prefix: "/reports"
route:
cluster: app_backend
typed_per_filter_config:
envoy.filters.http.ext_authz:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
check_settings:
http_service:
path_override: "/auth/api/forward?perm=myapp:reports&max_age=5min"
- match:
prefix: "/"
route:
cluster: app_backend
typed_per_filter_config:
envoy.filters.http.ext_authz:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
check_settings:
http_service:
path_override: "/auth/api/forward?perm=myapp:login"
```
See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for query parameter syntax.
## WebSocket support for `/auth/`
If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them.
## Notes
- Envoy's `ext_authz` filter does not send the request body to the auth server by default. For Paskia this is fine.
- If Paskia is running behind TLS, use `https://` in `server_uri.uri` and configure the cluster's transport socket.
- The `failure_mode_allow: false` setting means that if Paskia cannot be reached, Envoy will reject the request. In testing you may prefer `true`, but use `false` in production so a failed auth service cannot accidentally allow traffic.
+114
View File
@@ -0,0 +1,114 @@
# HAProxy Forward-Auth
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
HAProxy does not have a built-in forward-auth primitive, but the community [`haproxy-auth-request`](https://github.com/TimWolla/haproxy-auth-request) Lua script provides an `auth-intercept` action that works very similarly to Nginx's `auth_request`. It makes an internal HTTP request to Paskia and copies the response headers to the backend request.
## Requirements
- HAProxy 2.2 or newer (2.0+ may work but 2.2+ supports all features shown here).
- Compiled with `USE_LUA=1`.
- The [`haproxy-auth-request`](https://github.com/TimWolla/haproxy-auth-request) Lua script loaded.
- The [`haproxy-lua-http`](https://github.com/haproxytech/haproxy-lua-http) dependency in the Lua path.
## Overview
```haproxy
global
lua-load /usr/share/haproxy/auth-request.lua
defaults
mode http
timeout connect 5s
timeout client 30s
timeout server 30s
# Backend that runs the Paskia auth check.
backend paskia_auth
server paskia 127.0.0.1:4401
# Backend that runs the Paskia UI / WebSocket / API.
backend paskia_ui
server paskia 127.0.0.1:4401
# Your protected application.
backend app_backend
server app 127.0.0.1:3000
frontend app
bind *:80
# 1. Route /auth/ straight to Paskia, bypassing the auth check.
acl is_auth path_beg /auth/
use_backend paskia_ui if is_auth
# 2. Add the headers Paskia logs / expects. These are then copied to the
# auth subrequest by auth-intercept.
http-request set-header X-Forwarded-Method %[method]
http-request set-header X-Forwarded-Uri %[url]
# 3. Run the auth check. The parameters are:
# backend path method
# req-headers success-headers failure-headers
#
# - req-headers: headers copied from client to Paskia.
# - success-headers: headers copied from Paskia response to backend request.
# - failure-headers: headers copied from Paskia response to client response.
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:login GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
# 4. If the subrequest was not successful, deny the request.
http-request deny if ! { var(txn.auth_response_successful) -m bool }
default_backend app_backend
```
## What the configuration does
1. **Route `/auth/` to Paskia** — users must be able to reach the login/profile UI without already being authenticated. HAProxy will proxy WebSocket upgrade headers automatically for this backend when the client requests them.
2. **Set `X-Forwarded-Method` and `X-Forwarded-Uri`** — HAProxy adds these headers to the incoming request so the Lua script can copy them to the auth subrequest. `%[method]` returns the HTTP method and `%[url]` returns the path and query string.
3. **`lua.auth-intercept`** — sends a `GET` request to `/auth/api/forward?perm=myapp:login` on the `paskia_auth` backend. It copies the listed request headers (including the dynamic ones we just set) to the auth subrequest.
4. **On success (`2xx`)** — copies every response header matching `Remote-*` from Paskia to the backend request. This overrides any client-supplied `Remote-*` headers, so the backend can trust them.
5. **On failure (`4xx`)** — copies all response headers (`*`) to the client response and uses Paskia's response body, so the browser gets the login HTML or the JSON auth URL.
6. **Deny if auth failed** — the final `http-request deny` rule is a safety net. In practice, `auth-intercept` with `*` as the failure-headers already terminates the transaction with Paskia's response.
## Backend definition for the auth subrequest
The `paskia_auth` backend can be the same physical server as `paskia_ui`, but using a separate backend is convenient because the Lua script will use the first available server in the backend. The auth subrequest is a plain HTTP request, so no special WebSocket options are needed here.
```haproxy
backend paskia_auth
server paskia 127.0.0.1:4401
```
## Per-route permissions
You can run different auth checks for different paths by using HAProxy ACLs. Place the more specific rules before the generic one:
```haproxy
frontend app
bind *:80
acl is_auth path_beg /auth/
use_backend paskia_ui if is_auth
acl is_reports path_beg /reports
http-request set-header X-Forwarded-Method %[method] if is_reports
http-request set-header X-Forwarded-Uri %[url] if is_reports
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:reports&max_age=5min GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* * if is_reports
http-request set-header X-Forwarded-Method %[method]
http-request set-header X-Forwarded-Uri %[url]
http-request lua.auth-intercept paskia_auth /auth/api/forward?perm=myapp:login GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
http-request deny if ! { var(txn.auth_response_successful) -m bool }
default_backend app_backend
```
See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for query parameter syntax.
## Notes
- The Lua script strips the request body from the auth subrequest, so Paskia's `/auth/api/forward` will only see the headers.
- HAProxy variables are limited to alphanumeric characters, dots, and underscores, but the script already normalizes header names for you (e.g. `Remote-User` becomes `req.auth_response_header.remote_user`). The `Remote-*` glob pattern in the success-headers argument handles this automatically.
- The auth backend must be reachable without TLS. If you need TLS to Paskia, run a local TCP forwarder or use HAProxy's Lua HTTP support directly (not covered by this script).
- If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia backend and start Paskia with `--auth-host auth.example.com` instead of exposing `/auth/` on every site.
+47
View File
@@ -0,0 +1,47 @@
# Forward-Auth Proxy Guides
[`/auth/api/forward`](../api/forward.md) · [Trusted headers](../Headers.md)
These guides show how to protect a backend application with Paskia using the forward-auth (also called "external authentication") mechanism. The reverse proxy asks Paskia whether a request is allowed before forwarding it to the protected service.
For details about the endpoint the proxy calls, see [`/auth/api/forward`](../api/forward.md). For the headers your backend receives on successful requests, see [Trusted Headers](../Headers.md).
## Available guides
- [Caddy](caddy.md) — fully supported with ready-to-use snippets (`auth/setup` and `auth/require`).
- [Nginx](nginx.md) — using the `auth_request` module.
- [Traefik](traefik.md) — using the `ForwardAuth` middleware.
- [Apache APISIX](apisix.md) — using the `forward-auth` plugin.
- [Envoy](envoy.md) — using the `ext_authz` HTTP filter.
- [HAProxy](haproxy.md) — using a Lua auth request.
## Common requirements
No matter which proxy you use, the auth subrequest must:
1. Be sent to `GET /auth/api/forward` on the Paskia backend. By default Paskia listens on `localhost:4401`; set the `AUTH_UPSTREAM` environment variable in our Caddy snippets, or point your proxy at wherever Paskia is running.
2. Include the query parameters Paskia needs for access control:
- `perm` — required permission scope, repeatable (e.g. `perm=myapp:login`). See [perm argument](../api/perm.md).
- `max_age` — how recently the user must have authenticated (e.g. `max_age=5min`). See [max_age argument](../api/max-age.md).
3. Forward these request headers from the original client request:
- `Host` — the site the user is visiting.
- `Cookie` — the session cookie, normally `__Host-paskia`.
- `X-Forwarded-Method` — the original HTTP method (e.g. `GET`, `POST`).
- `X-Forwarded-Uri` — the original path and query string (e.g. `/reports?foo=bar`).
- `Accept` — decides whether a 401/403 response should be HTML (browser) or JSON (API/fetch).
4. Strip hop-by-hop headers (`Connection`, `Upgrade`, `Transfer-Encoding`, `Keep-Alive`, `Proxy-Connection`, `TE`) from the auth subrequest. The auth check is a plain HTTP request and must not carry WebSocket/body framing headers.
5. On a `204 No Content` response, copy the `Remote-*` response headers to the request that is forwarded to the protected backend. The headers are the whole point of the auth check.
6. On a 401/403 response, send Paskia's response back to the client without contacting the protected backend.
7. Also proxy the `/auth/` path prefix to Paskia so the login/profile UI, API endpoints, and WebSockets are reachable. Paskia's WebSocket endpoints need `Upgrade` and `Connection` headers passed through for that path.
## Backend usage
After the proxy forwards the request, your backend can read the trusted headers. For example, in Python/FastAPI:
```python
user_id = request.headers.get("Remote-User")
org_id = request.headers.get("Remote-Org")
permissions = request.headers.get("Remote-Groups", "").split(",")
```
Only trust headers that come from the proxy; never trust `Remote-*` headers that arrive directly from the internet. Your proxy configuration should strip any client-supplied `Remote-*` headers before the auth check.
+130
View File
@@ -0,0 +1,130 @@
# Nginx Forward-Auth
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
This guide uses the [Nginx `auth_request`](http://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module to ask Paskia whether each request is allowed before proxying it to your backend.
## Overview
```nginx
server {
listen 80;
server_name app.example.com;
# 1. Proxy /auth/ to Paskia (HTTP + WebSocket).
location /auth/ {
proxy_pass http://localhost:4401;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# 2. All other paths are protected.
location / {
auth_request /auth-internal;
# 3. Capture the Remote-* headers from the auth response and
# pass them to the backend request.
auth_request_set $remote_user $upstream_http_remote_user;
auth_request_set $remote_name $upstream_http_remote_name;
auth_request_set $remote_groups $upstream_http_remote_groups;
auth_request_set $remote_org $upstream_http_remote_org;
auth_request_set $remote_org_name $upstream_http_remote_org_name;
auth_request_set $remote_role $upstream_http_remote_role;
auth_request_set $remote_role_name $upstream_http_remote_role_name;
auth_request_set $remote_session_exp $upstream_http_remote_session_expires;
auth_request_set $remote_credential $upstream_http_remote_credential;
proxy_set_header Remote-User $remote_user;
proxy_set_header Remote-Name $remote_name;
proxy_set_header Remote-Groups $remote_groups;
proxy_set_header Remote-Org $remote_org;
proxy_set_header Remote-Org-Name $remote_org_name;
proxy_set_header Remote-Role $remote_role;
proxy_set_header Remote-Role-Name $remote_role_name;
proxy_set_header Remote-Session-Expires $remote_session_exp;
proxy_set_header Remote-Credential $remote_credential;
# 4. The proxy_set_header lines above override any client-supplied
# Remote-* headers, so the backend receives only the values from
# Paskia's auth response.
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 5. Internal endpoint used by auth_request.
location = /auth-internal {
internal;
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:login;
proxy_http_version 1.1;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Forwarded-Method $request_method;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header Accept $http_accept;
# Drop hop-by-hop headers that must not reach the auth subrequest.
proxy_set_header Connection "";
proxy_set_header Upgrade "";
proxy_set_header Transfer-Encoding "";
proxy_set_header Keep-Alive "";
proxy_set_header Proxy-Connection "";
proxy_set_header TE "";
}
}
# WebSocket upgrade map.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
## What the configuration does
1. **`/auth/`** is proxied straight to Paskia. The `Upgrade` and `Connection` headers are passed through so WebSocket endpoints such as `/auth/ws/authenticate` work.
2. **`/`** is protected by `auth_request /auth-internal`. Nginx makes an internal subrequest to that location before proxying the original request to the app.
3. **`auth_request_set`** captures each `Remote-*` header from the Paskia response. Nginx does not have wildcard capture, so every header must be listed explicitly. The captured values are then attached to the backend request with `proxy_set_header`.
4. The `proxy_set_header` lines override any `Remote-*` headers the client might have sent, so the backend can trust the headers that come from Paskia.
5. **`/auth-internal`** is the actual forward-auth call. It must:
- point to `/auth/api/forward`,
- not forward the request body (`proxy_pass_request_body off;`),
- pass `Host`, `Cookie`, `Accept`, `X-Forwarded-Method`, and `X-Forwarded-Uri`,
- strip hop-by-hop headers.
## Adjusting requirements
Change the query string on the `proxy_pass` line inside `/auth-internal` to require different permissions or recent authentication:
```nginx
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:login;
proxy_pass http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min;
proxy_pass http://localhost:4401/auth/api/forward?"";
```
The last form (`?""`) requires only authentication and no specific permission. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for details.
## Public paths
To leave some paths unprotected (for example `/.well-known/` or `/static/`), add `location` blocks before the protected `location /` block:
```nginx
location /.well-known/ {
root /var/www;
}
location /static/ {
root /var/www;
}
```
## Notes
- Nginx `auth_request` always makes the auth subrequest with the same HTTP method as the original request, but the body is suppressed by the configuration above. Paskia uses the `X-Forwarded-Method` and `X-Forwarded-Uri` headers for logging.
- The `auth_request_set` variables are empty when the auth request fails, so on 401/403 the backend is never contacted; Nginx returns Paskia's response directly.
- For HTTPS, add `listen 443 ssl;` and your certificate configuration as usual.
+126
View File
@@ -0,0 +1,126 @@
# Traefik ForwardAuth
[`/auth/api/forward`](../api/forward.md) · [Proxy guides](index.md)
This guide uses Traefik's [ForwardAuth middleware](https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/forwardauth/) to ask Paskia whether each request is allowed.
## Overview
A typical dynamic (YAML) configuration looks like this:
```yaml
http:
routers:
app:
rule: "Host(`app.example.com`)"
service: app-backend
middlewares:
- paskia-auth
# Route /auth/ straight to Paskia, bypassing the auth middleware.
auth:
rule: "Host(`app.example.com`) && PathPrefix(`/auth/`)"
service: paskia
middlewares: []
middlewares:
paskia-auth:
forwardAuth:
address: "http://localhost:4401/auth/api/forward?perm=myapp:login"
# Forward every Remote-* header from the auth response to the backend.
authResponseHeadersRegex: "^Remote-"
# Explicitly pass the headers Paskia needs. If left empty, all headers
# are forwarded; being explicit avoids accidentally leaking hop-by-hop
# headers to the auth server.
authRequestHeaders:
- Host
- Cookie
- Accept
- X-Forwarded-Method
- X-Forwarded-Uri
- X-Forwarded-Host
- X-Forwarded-Proto
- X-Forwarded-For
services:
app-backend:
loadBalancer:
servers:
- url: "http://localhost:3000"
paskia:
loadBalancer:
servers:
- url: "http://localhost:4401"
```
## What Traefik sends automatically
Traefik's ForwardAuth middleware sends the auth request to the configured `address` and includes the following headers derived from the original request:
| Header | Value |
|---|---|
| `X-Forwarded-Method` | Original HTTP method |
| `X-Forwarded-Proto` | Original protocol (`http`/`https`) |
| `X-Forwarded-Host` | Original host |
| `X-Forwarded-Uri` | Original request URI (path and query) |
| `X-Forwarded-For` | Client IP address |
These are exactly the headers Paskia logs. You should still include them in `authRequestHeaders` if you set that list explicitly, to make sure they are not filtered out.
## Response headers
`authResponseHeadersRegex: "^Remote-"` tells Traefik to copy every response header starting with `Remote-` from Paskia's `204` response and add it to the request that is forwarded to your backend. It also strips any `Remote-*` headers that the client may have sent, so the backend can trust the values.
For stricter control, you can list the headers explicitly instead of using the regex:
```yaml
authResponseHeaders:
- Remote-User
- Remote-Name
- Remote-Groups
- Remote-Org
- Remote-Org-Name
- Remote-Role
- Remote-Role-Name
- Remote-Session-Expires
- Remote-Credential
```
## Proxying `/auth/` to Paskia
The `/auth/` router above forwards all authentication UI, API, and WebSocket traffic to Paskia. Because this router does **not** use the `paskia-auth` middleware, users can reach the login page and profile UI without being authenticated first. Traefik handles WebSocket upgrades automatically when the client requests them.
If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and start Paskia with `--auth-host auth.example.com`.
## Adjusting requirements
Change the `address` query string to require different permissions or recent authentication:
```yaml
address: "http://localhost:4401/auth/api/forward?perm=myapp:login"
address: "http://localhost:4401/auth/api/forward?perm=myapp:admin&max_age=5min"
address: "http://localhost:4401/auth/api/forward"
```
The last form requires only authentication, no specific permission. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md).
## Docker labels example
When using Traefik with Docker Compose, you can define the middleware with labels:
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
- "traefik.http.routers.myapp.middlewares=paskia-auth"
- "traefik.http.middlewares.paskia-auth.forwardauth.address=http://localhost:4401/auth/api/forward?perm=myapp:login"
- "traefik.http.middlewares.paskia-auth.forwardauth.authResponseHeadersRegex=^Remote-"
- "traefik.http.middlewares.paskia-auth.forwardauth.authRequestHeaders=Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri"
```
## Notes
- By default ForwardAuth sends a request without the original body. If you need to forward the body for logging/validation, set `forwardBody: true` and a sensible `maxBodySize`, but for Paskia this is not required.
- Paskia does not set cookies on the forward-auth response; it only returns `Remote-*` headers. Use the JavaScript helpers from the [paskia](https://www.npmjs.com/package/paskia) package for session renewal in the frontend.
- For HTTPS, use `https://` in the `address` and configure TLS options (`tls.insecureSkipVerify: true` only for testing).
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -44,7 +44,7 @@ def log_reset_link(passphrase: str, message: str | None = None) -> str:
def bootstrap( def bootstrap(
data: "DB", 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,
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+31 -9
View File
@@ -79,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")
@@ -94,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,
) )
@@ -111,7 +124,7 @@ 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,
) )
@@ -137,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).
@@ -168,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),
@@ -192,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.
@@ -212,10 +227,17 @@ async def forward_authentication(
) )
_set_log_extra(request, forwarded) _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,
) )
+30 -19
View File
@@ -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
+1 -1
View File
@@ -223,7 +223,7 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
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)
+1 -1
View File
@@ -422,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)
+91 -3
View File
@@ -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
+2 -2
View File
@@ -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"]
+2 -2
View File
@@ -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,7 +31,7 @@ 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": def config() -> RuntimeConfig | None:
"""Return cached runtime config loaded from PASKIA_CONFIG.""" """Return cached runtime config loaded from PASKIA_CONFIG."""
return _load_config() return _load_config()
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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."""
+2 -2
View File
@@ -72,7 +72,7 @@ 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 a temporary JSONL database for testing using kanta. """Create a temporary JSONL database for testing using kanta.
Uses a kanta bootstrap callback to properly initialize the database with: Uses a kanta bootstrap callback to properly initialize the database with:
@@ -248,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
+204 -1
View File
@@ -22,7 +22,7 @@ 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.db.structs import Client
from paskia.util import avatar, hostutil, oidjwt 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_image_bytes, create_test_session from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
@@ -233,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"""