Add public access mode (public=1) to forward auth
/auth/api/forward?public=1 passes requests through with a Remote-Public header (anonymous/forbidden/authenticated) instead of 401/403, so routes can allow anonymous visitors while still identifying logged-in users. Reauth (max_age) still requires the auth flow. Documented in Headers.md, api/forward.md, Integration.md and all proxy guides.
This commit is contained in:
@@ -13,6 +13,17 @@
|
||||
| Remote-Groups | Permissions the user has, comma separated | **auth:admin,yourapp:reports** |
|
||||
| Remote-Session-Expires | Session expiry timestamp (ISO 8601 UTC) | **2030-12-31T23:59:59Z** |
|
||||
| Remote-Credential | Credential UUID | Identifier for the sign-in passkey (string) |
|
||||
| Remote-Public | Public-access marker, only present on routes using [`public=1`](api/forward.md#public-access) | **authenticated**, **forbidden** or **anonymous** |
|
||||
|
||||
### Public access
|
||||
|
||||
On routes configured with `public=1`, every forwarded request carries `Remote-Public` and the backend must check it before treating the request as authorized:
|
||||
|
||||
- `authenticated` — the user has everything the route asked for; full `Remote-*` headers.
|
||||
- `forbidden` — the user is logged in but the route's `perm` check failed. Full identity headers are sent, including `Remote-Groups` — it is trustworthy, it just lacks the requested permission.
|
||||
- `anonymous` — no valid session; no identity headers are sent.
|
||||
|
||||
Without `public=1` the header is absent and every request reaching the backend is fully authorized.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -132,6 +132,27 @@ Be sure to REMOVE connection hop-by-hop headers (these will break WebSockets amo
|
||||
"Connection", "Keep-Alive", "Proxy-Connection", "TE", "Transfer-Encoding", "Upgrade"
|
||||
```
|
||||
|
||||
## Public access
|
||||
|
||||
For apps where authentication is optional, configure the proxy route with `public=1` (see your [proxy guide](proxy/index.md)). The auth check then always lets the request through, and your backend branches on the `Remote-Public` header:
|
||||
|
||||
- `anonymous` — no valid session; no `Remote-*` identity headers are present.
|
||||
- `forbidden` — the user is logged in (identity headers are present and trustworthy) but the route's `perm` was not granted.
|
||||
- `authenticated` — session valid and all requested permissions met.
|
||||
|
||||
```python
|
||||
# Example: Python/FastAPI
|
||||
@app.get("/api/reports")
|
||||
def reports(request: Request):
|
||||
public = request.headers.get("Remote-Public")
|
||||
if public != "authenticated":
|
||||
raise HTTPException(401) # or serve a limited public view
|
||||
user_id = request.headers.get("Remote-User")
|
||||
# ...
|
||||
```
|
||||
|
||||
Login-on-demand still works unchanged: any 401 your app itself returns for privileged operations carries the `auth.iframe` URL that the [paskia](https://www.npmjs.com/package/paskia) module handles automatically (see [API Fetch with Automatic Auth](#api-fetch-with-automatic-auth)). A `max_age` reauth requirement on the route still returns the 401 auth flow directly from the proxy. See [public access](api/forward.md#public-access) and [Headers](Headers.md#public-access).
|
||||
|
||||
## Proxying /auth/ to Paskia
|
||||
|
||||
Your app server needs to proxy `/auth/` paths to Paskia. This can be done by your application but is much easier done by a reverse proxy. The [Forward-Auth Proxy Guides](proxy/index.md) cover Caddy, Nginx, Traefik, Apache APISIX, Envoy and HAProxy.
|
||||
|
||||
@@ -15,6 +15,19 @@ See [Forward-Auth Proxy Guides](../proxy/index.md) for Caddy, Nginx, Traefik, Ap
|
||||
|-----------|-------------|
|
||||
| perm | Required permissions. See the [perm argument](perm.md). |
|
||||
| max_age | Require recent passkey use. See the [max_age argument](max-age.md). |
|
||||
| public | `public=1` allows public access: instead of 401 (no/expired session) or 403 (permission denied), the request passes with a `Remote-Public` header marking the bypass. Reauth (`max_age`) still requires the auth flow. |
|
||||
|
||||
## Public access
|
||||
|
||||
With `public=1` the endpoint returns 204 in every case except reauth and malformed arguments, and always sets `Remote-Public`:
|
||||
|
||||
| Value | Meaning | Identity headers |
|
||||
|---|---|---|
|
||||
| `authenticated` | Session valid, all requested permissions met | Full `Remote-*` set |
|
||||
| `forbidden` | Session valid, but the `perm` check failed | Full `Remote-*` set (including `Remote-Groups` — it is trustworthy, it just lacks the requested permission) |
|
||||
| `anonymous` | No valid session | None |
|
||||
|
||||
The backend must check `Remote-Public` before treating the request as authorized. See [Trusted Headers](../Headers.md) and the "Public access" section in the [proxy guides](../proxy/index.md).
|
||||
|
||||
## Request headers
|
||||
|
||||
|
||||
+14
-1
@@ -54,7 +54,8 @@ curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
|
||||
"Remote-Role",
|
||||
"Remote-Role-Name",
|
||||
"Remote-Session-Expires",
|
||||
"Remote-Credential"
|
||||
"Remote-Credential",
|
||||
"Remote-Public"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -110,6 +111,7 @@ services:
|
||||
- Remote-Role-Name
|
||||
- Remote-Session-Expires
|
||||
- Remote-Credential
|
||||
- Remote-Public
|
||||
upstream:
|
||||
type: roundrobin
|
||||
nodes:
|
||||
@@ -156,6 +158,17 @@ 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).
|
||||
|
||||
## Public access
|
||||
|
||||
For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to the `forward-auth` URI:
|
||||
|
||||
```yaml
|
||||
uri: http://localhost:4401/auth/api/forward?public=1
|
||||
uri: http://localhost:4401/auth/api/forward?public=1&perm=myapp:reports
|
||||
```
|
||||
|
||||
The auth check then always returns 204 (except reauth with `max_age`, which still returns the 401 auth flow), and the `Remote-Public` header — included in the `upstream_headers` lists above — marks each request as `anonymous`, `forbidden` or `authenticated`. The backend always runs and must check `Remote-Public` before treating the request as authorized. See [public access](../api/forward.md#public-access) and [trusted headers](../Headers.md#public-access).
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -58,6 +58,19 @@ app.example.com {
|
||||
|
||||
The above setup allows unauthenticated access to certain files, then implements two different access controls for your backend app depending on which path is accessed. Note that the perm and max-age options may be combined, e.g. `perm=myapp:admin&max-age=5min` on a very sensitive endpoint. This will require additional authentication if the passkey hasn't been used in the last 5 minutes (automatic session renewals don't affect this). Use `""` if you only want the user to be authenticated with no time or perm requirements.
|
||||
|
||||
### Public access (public=1)
|
||||
|
||||
For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to the same snippet:
|
||||
|
||||
```caddyfile
|
||||
handle {
|
||||
import auth/require "public=1"
|
||||
reverse_proxy :3000
|
||||
}
|
||||
```
|
||||
|
||||
The auth check then always passes (204): anonymous requests and users lacking a requested `perm` reach your backend marked with a `Remote-Public` header (`anonymous`, `forbidden` or `authenticated`) instead of getting a 401/403. Your backend must check `Remote-Public` before treating the request as authorized — see [trusted headers](../Headers.md#public-access). A `max_age` reauth requirement still renders the authentication page, even on public routes.
|
||||
|
||||
### Dedicated Authentication Site
|
||||
|
||||
When you setup a separate subdomain for the authentication site, just add to your config another section for the auth host:
|
||||
|
||||
@@ -163,6 +163,17 @@ See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) fo
|
||||
|
||||
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.
|
||||
|
||||
## Public access
|
||||
|
||||
For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to `path_override` (globally or per route):
|
||||
|
||||
```yaml
|
||||
path_override: "/auth/api/forward?public=1"
|
||||
path_override: "/auth/api/forward?public=1&perm=myapp:reports"
|
||||
```
|
||||
|
||||
The auth check then always returns 204 (except reauth with `max_age`, which still returns the 401 auth flow), and the `Remote-Public` header — matched by the `prefix: Remote-` rule in `allowed_upstream_headers` — marks each request as `anonymous`, `forbidden` or `authenticated`. The backend always runs and must check `Remote-Public` before treating the request as authorized. See [public access](../api/forward.md#public-access) and [trusted headers](../Headers.md#public-access).
|
||||
|
||||
## Notes
|
||||
|
||||
- Envoy's `ext_authz` filter does not send the request body to the auth server by default. For Paskia this is fine.
|
||||
|
||||
@@ -106,6 +106,18 @@ frontend app
|
||||
|
||||
See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for query parameter syntax.
|
||||
|
||||
## Public access
|
||||
|
||||
For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to the auth subrequest path:
|
||||
|
||||
```haproxy
|
||||
http-request lua.auth-intercept paskia_auth /auth/api/forward?public=1 GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
|
||||
# or with a permission the backend will check itself:
|
||||
http-request lua.auth-intercept paskia_auth /auth/api/forward?public=1&perm=myapp:reports GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* *
|
||||
```
|
||||
|
||||
The `Remote-*` success-headers glob already copies the `Remote-Public` header that marks each request as `anonymous`, `forbidden` or `authenticated`. With `public=1` the backend always runs and must check `Remote-Public` before treating the request as authorized; only reauth (`max_age`) still returns the 401 auth flow, so the `http-request deny` safety net simply never triggers on public routes. See [public access](../api/forward.md#public-access) and [trusted headers](../Headers.md#public-access).
|
||||
|
||||
## Notes
|
||||
|
||||
- The Lua script strips the request body from the auth subrequest, so Paskia's `/auth/api/forward` will only see the headers.
|
||||
|
||||
+3
-2
@@ -23,6 +23,7 @@ No matter which proxy you use, the auth subrequest must:
|
||||
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).
|
||||
- `public=1` — optional; allow public access (anonymous visitors and users missing `perm` pass through, marked with a `Remote-Public` header instead of a 401/403). See [public access](../api/forward.md#public-access).
|
||||
3. Forward these request headers from the original client request:
|
||||
- `Host` — the site the user is visiting.
|
||||
- `Cookie` — the session cookie, normally `__Host-paskia`.
|
||||
@@ -30,8 +31,8 @@ No matter which proxy you use, the auth subrequest must:
|
||||
- `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.
|
||||
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. With `public=1`, also copy `Remote-Public` — it marks whether the request is `authenticated`, `forbidden` or `anonymous`, and the backend must check it.
|
||||
6. On a 401/403 response, send Paskia's response back to the client without contacting the protected backend. (With `public=1` these only occur for reauth requirements.)
|
||||
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
|
||||
|
||||
@@ -35,6 +35,7 @@ server {
|
||||
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;
|
||||
auth_request_set $remote_public $upstream_http_remote_public;
|
||||
|
||||
proxy_set_header Remote-User $remote_user;
|
||||
proxy_set_header Remote-Name $remote_name;
|
||||
@@ -45,6 +46,7 @@ server {
|
||||
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;
|
||||
proxy_set_header Remote-Public $remote_public;
|
||||
|
||||
# 4. The proxy_set_header lines above override any client-supplied
|
||||
# Remote-* headers, so the backend receives only the values from
|
||||
@@ -123,6 +125,17 @@ location /static/ {
|
||||
}
|
||||
```
|
||||
|
||||
## Public access
|
||||
|
||||
For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to the auth subrequest URI inside `/auth-internal`:
|
||||
|
||||
```nginx
|
||||
proxy_pass http://localhost:4401/auth/api/forward?public=1;
|
||||
proxy_pass http://localhost:4401/auth/api/forward?public=1&perm=myapp:reports;
|
||||
```
|
||||
|
||||
The auth check then always returns 204 (except reauth with `max_age`, which still returns the 401 auth flow), and `Remote-Public` marks each request as `anonymous`, `forbidden` or `authenticated`. It is captured and forwarded by the `auth_request_set $remote_public` / `proxy_set_header Remote-Public` lines added in the overview above — the backend always runs and must check `Remote-Public` before treating the request as authorized. See [public access](../api/forward.md#public-access) and [trusted headers](../Headers.md#public-access).
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -85,6 +85,7 @@ authResponseHeaders:
|
||||
- Remote-Role-Name
|
||||
- Remote-Session-Expires
|
||||
- Remote-Credential
|
||||
- Remote-Public
|
||||
```
|
||||
|
||||
## Proxying `/auth/` to Paskia
|
||||
@@ -119,6 +120,17 @@ labels:
|
||||
- "traefik.http.middlewares.paskia-auth.forwardauth.authRequestHeaders=Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri"
|
||||
```
|
||||
|
||||
## Public access
|
||||
|
||||
For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to the middleware `address`:
|
||||
|
||||
```yaml
|
||||
address: "http://localhost:4401/auth/api/forward?public=1"
|
||||
address: "http://localhost:4401/auth/api/forward?public=1&perm=myapp:reports"
|
||||
```
|
||||
|
||||
The auth check then always returns 204 (except reauth with `max_age`, which still returns the 401 auth flow), and the `Remote-Public` header — copied by `authResponseHeadersRegex: "^Remote-"` — marks each request as `anonymous`, `forbidden` or `authenticated`. The backend always runs and must check `Remote-Public` before treating the request as authorized. See [public access](../api/forward.md#public-access) and [trusted headers](../Headers.md#public-access).
|
||||
|
||||
## 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.
|
||||
|
||||
Reference in New Issue
Block a user