From 383c9f472e1fa67115833f3a8b694b0002dadb21 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 5 Sep 2026 16:06:32 +0000 Subject: [PATCH] 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. --- caddy/auth/require | 2 + docs/Headers.md | 11 +++++ docs/Integration.md | 21 +++++++++ docs/api/forward.md | 13 ++++++ docs/proxy/apisix.md | 15 ++++++- docs/proxy/caddy.md | 13 ++++++ docs/proxy/envoy.md | 11 +++++ docs/proxy/haproxy.md | 12 +++++ docs/proxy/index.md | 5 ++- docs/proxy/nginx.md | 13 ++++++ docs/proxy/traefik.md | 12 +++++ paskia/fastapi/api.py | 55 +++++++++++++++-------- paskia/fastapi/authz.py | 6 ++- tests/test_api.py | 98 +++++++++++++++++++++++++++++++++++++++++ 14 files changed, 265 insertions(+), 22 deletions(-) diff --git a/caddy/auth/require b/caddy/auth/require index 657ed3d..98f6786 100644 --- a/caddy/auth/require +++ b/caddy/auth/require @@ -2,11 +2,13 @@ # Argument is mandatory and provides a query string to /auth/api/forward # "" means just authentication # perm=yourservice:login to require specific permission +# public=1 to allow public access (backend must check Remote-Public) forward_auth {$AUTH_UPSTREAM:localhost:4401} { uri /auth/api/forward?{args[0]} header_up Connection keep-alive # Much higher performance header_up -Upgrade # Disable Upgrade: WebSocket copy_headers { + Remote-Public Remote-User Remote-Name Remote-Groups diff --git a/docs/Headers.md b/docs/Headers.md index 6ff75eb..6c669b0 100644 --- a/docs/Headers.md +++ b/docs/Headers.md @@ -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. diff --git a/docs/Integration.md b/docs/Integration.md index 65c0c22..b531554 100644 --- a/docs/Integration.md +++ b/docs/Integration.md @@ -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. diff --git a/docs/api/forward.md b/docs/api/forward.md index f4ddf07..c89da94 100644 --- a/docs/api/forward.md +++ b/docs/api/forward.md @@ -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 diff --git a/docs/proxy/apisix.md b/docs/proxy/apisix.md index e1e7b9d..5361952 100644 --- a/docs/proxy/apisix.md +++ b/docs/proxy/apisix.md @@ -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. diff --git a/docs/proxy/caddy.md b/docs/proxy/caddy.md index 1d8792b..bab71c2 100644 --- a/docs/proxy/caddy.md +++ b/docs/proxy/caddy.md @@ -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: diff --git a/docs/proxy/envoy.md b/docs/proxy/envoy.md index df317c7..e409103 100644 --- a/docs/proxy/envoy.md +++ b/docs/proxy/envoy.md @@ -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. diff --git a/docs/proxy/haproxy.md b/docs/proxy/haproxy.md index e235495..39f0156 100644 --- a/docs/proxy/haproxy.md +++ b/docs/proxy/haproxy.md @@ -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. diff --git a/docs/proxy/index.md b/docs/proxy/index.md index 1a420c4..84cd3b4 100644 --- a/docs/proxy/index.md +++ b/docs/proxy/index.md @@ -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 diff --git a/docs/proxy/nginx.md b/docs/proxy/nginx.md index 9619481..1052011 100644 --- a/docs/proxy/nginx.md +++ b/docs/proxy/nginx.md @@ -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. diff --git a/docs/proxy/traefik.md b/docs/proxy/traefik.md index 7b4ef29..180c0d8 100644 --- a/docs/proxy/traefik.md +++ b/docs/proxy/traefik.md @@ -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. diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index b3b155a..9e3c3ee 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -198,12 +198,31 @@ async def check_user( return MsgspecResponse(ApiCheckUserResponse(valid=valid, ctx=ctx)) +def _remote_headers(ctx) -> dict[str, str]: + """Build the Remote-* identity headers for a verified session context.""" + role_permissions = {p.scope for p in ctx.permissions} if ctx.permissions else set() + return { + "Remote-User": str(ctx.user.uuid), + "Remote-Name": ctx.user.display_name, + "Remote-Groups": ",".join(sorted(role_permissions)), + "Remote-Org": str(ctx.org.uuid), + "Remote-Org-Name": ctx.org.display_name, + "Remote-Role": str(ctx.role.uuid), + "Remote-Role-Name": ctx.role.display_name, + "Remote-Session-Expires": ( + (ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z") + ), + "Remote-Credential": str(ctx.session.credential), + } + + @app.get("/forward") async def forward_authentication( request: Request, response: Response, perm: list[str] = Query([]), max_age: str | None = Query(None), + public: bool = Query(False), auth=AUTH_COOKIE, ): """Forward auth validation for Caddy/Nginx. @@ -213,6 +232,11 @@ async def forward_authentication( required; separate alternatives with '|' for OR semantics within a group). - max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session is older than this, user must re-authenticate. + - public: allow public access — instead of 401 (no/expired session) or 403 + (permission denied), return 204 with a Remote-Public header + (anonymous/forbidden) so the backend can decide. Reauth (max_age) + still requires the auth flow. Successful checks are marked + Remote-Public: authenticated. Success: 204 No Content with Remote-* headers describing the authenticated user. Failure (unauthenticated / unauthorized): 4xx response. @@ -245,26 +269,21 @@ async def forward_authentication( max_age=max_age, ) _set_log_extra(request, forwarded, ctx.session.key) - # Build permission scopes for Remote-Groups header - role_permissions = ( - {p.scope for p in ctx.permissions} if ctx.permissions else set() - ) - - remote_headers: dict[str, str] = { - "Remote-User": str(ctx.user.uuid), - "Remote-Name": ctx.user.display_name, - "Remote-Groups": ",".join(sorted(role_permissions)), - "Remote-Org": str(ctx.org.uuid), - "Remote-Org-Name": ctx.org.display_name, - "Remote-Role": str(ctx.role.uuid), - "Remote-Role-Name": ctx.role.display_name, - "Remote-Session-Expires": ( - (ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z") - ), - "Remote-Credential": str(ctx.session.credential), - } + remote_headers = _remote_headers(ctx) + if public: + remote_headers["Remote-Public"] = "authenticated" return Response(status_code=204, headers=remote_headers) except authz.AuthException as e: + # Public access: pass the request through instead of an auth flow. + # Reauth is never soft-passed: an authenticated user was explicitly asked + # for fresh verification (log out first to use the public mode). + if public and e.mode in ("login", "forbidden"): + _set_log_extra(request, forwarded, f"public:{e.mode}") + if e.mode == "forbidden" and e.ctx is not None: + headers = {**_remote_headers(e.ctx), "Remote-Public": "forbidden"} + else: + headers = {"Remote-Public": "anonymous"} + return Response(status_code=204, headers=headers) # Clear cookie only if session is invalid (not for reauth) if e.clear_session: session.clear_session_cookie(response) diff --git a/paskia/fastapi/authz.py b/paskia/fastapi/authz.py index 97c55ca..71cec70 100644 --- a/paskia/fastapi/authz.py +++ b/paskia/fastapi/authz.py @@ -15,9 +15,10 @@ class AuthException(HTTPException): Attributes: status_code: HTTP status code (401 for auth, 403 for authz) detail: Error message - mode: UI mode ('login' or 'reauth') + mode: UI mode ('login', 'reauth' or 'forbidden') clear_session: Whether to clear the session cookie (True for invalid sessions) metadata: Additional data to pass to the frontend + ctx: Session context, set only for 403 (session valid, permission missing) """ def __init__( @@ -26,11 +27,13 @@ class AuthException(HTTPException): detail: str, mode: str, clear_session: bool = False, + ctx=None, **metadata, ): super().__init__(status_code=status_code, detail=detail) self.mode = mode self.clear_session = clear_session + self.ctx = ctx self.metadata = metadata @@ -108,6 +111,7 @@ async def verify( status_code=403, mode="forbidden", detail="Permission required", + ctx=ctx, theme=user_theme, ) diff --git a/tests/test_api.py b/tests/test_api.py index 1aa2d87..70cd10b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -235,6 +235,104 @@ class TestForwardEndpoint: assert data["auth"]["mode"] == "forbidden" +class TestForwardPublicAccess: + """Tests for GET /auth/api/forward with public=1 (public access mode)""" + + @pytest.mark.asyncio + async def test_public_without_session_returns_204_anonymous( + self, client: httpx.AsyncClient + ): + """Public access without session should pass as anonymous.""" + response = await client.get("/auth/api/forward?public=1") + assert response.status_code == 204 + assert response.headers["Remote-Public"] == "anonymous" + assert "Remote-User" not in response.headers + assert "Remote-Groups" not in response.headers + + @pytest.mark.asyncio + async def test_public_with_expired_session_returns_204_anonymous( + self, client: httpx.AsyncClient + ): + """Public access with invalid session should pass as anonymous.""" + fake_token = "aaaaaaaaaaaaaaaa" # Exactly 16 characters + response = await client.get( + "/auth/api/forward?public=1", + headers={**auth_headers(fake_token), "Host": "localhost:4401"}, + ) + assert response.status_code == 204 + assert response.headers["Remote-Public"] == "anonymous" + assert "Remote-User" not in response.headers + # Cookie must not be cleared on public pass-through + assert "set-cookie" not in response.headers + + @pytest.mark.asyncio + async def test_public_permission_denied_returns_204_forbidden( + self, client: httpx.AsyncClient, regular_session_token: str + ): + """Public access with missing permission should pass as forbidden with identity.""" + response = await client.get( + "/auth/api/forward?public=1&perm=auth:admin", + headers={ + **auth_headers(regular_session_token), + "Host": "localhost:4401", + }, + ) + assert response.status_code == 204 + assert response.headers["Remote-Public"] == "forbidden" + # Identity is known and sent, including (trustworthy) groups + assert "Remote-User" in response.headers + assert "Remote-Groups" in response.headers + + @pytest.mark.asyncio + async def test_public_authorized_returns_204_authenticated( + self, client: httpx.AsyncClient, session_token: str + ): + """Public access with full authorization should be marked authenticated.""" + response = await client.get( + "/auth/api/forward?public=1&perm=auth:admin", + headers={**auth_headers(session_token), "Host": "localhost:4401"}, + ) + assert response.status_code == 204 + assert response.headers["Remote-Public"] == "authenticated" + assert "Remote-User" in response.headers + assert "Remote-Groups" in response.headers + + @pytest.mark.asyncio + async def test_public_reauth_still_returns_401( + self, client: httpx.AsyncClient, session_token: str + ): + """Reauth (max_age) is never soft-passed, even with public=1.""" + response = await client.get( + "/auth/api/forward?public=1&max_age=0s", + headers={ + **auth_headers(session_token), + "Host": "localhost:4401", + "Accept": "application/json", + }, + ) + assert response.status_code == 401 + data = response.json() + assert data["auth"]["mode"] == "reauth" + + @pytest.mark.asyncio + async def test_public_malformed_perm_returns_400(self, client: httpx.AsyncClient): + """Malformed perm remains a hard error with public=1.""" + response = await client.get("/auth/api/forward?public=1&perm=a||b") + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_without_public_no_remote_public_header( + self, client: httpx.AsyncClient, session_token: str + ): + """Without public=1, Remote-Public is absent on success.""" + response = await client.get( + "/auth/api/forward", + headers={**auth_headers(session_token), "Host": "localhost:4401"}, + ) + assert response.status_code == 204 + assert "Remote-Public" not in response.headers + + class TestPermOrSemantics: """Tests for OR ('|') semantics and strict parsing of the perm argument"""