From 223429d51c765882232a3a003e9a56d89473fe96 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 11 Aug 2026 01:46:56 +0000 Subject: [PATCH] Add renew=0 query arg on validate, useful when only a permission check is required. --- docs/API.md | 4 ++-- docs/Integration.md | 2 +- docs/api/validate.md | 3 ++- paskia/fastapi/api.py | 11 +++++---- tests/test_api.py | 54 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/docs/API.md b/docs/API.md index f7fab13..cb3fa14 100644 --- a/docs/API.md +++ b/docs/API.md @@ -14,12 +14,12 @@ For integrating Paskia with your app frontend, see [integration](Integration.md) ### Public JSON API: /auth/api/* -| Method | Path | Used for | Responses | +| Method | Path | Used for | Expected responses | |---:|---|---|---| | GET | /auth/api/settings | Paskia configuration: RP info, base paths, session cookie name | 200 | | GET | /auth/api/user-info | Full user profile: info, credentials, sessions, permissions | 200/401 | | POST | /auth/api/logout | Terminate session and delete session cookie on the current host | 200 | -| 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 | +| POST | [/auth/api/validate](api/validate.md) | Validate and renew the session cookie; query [perm](api/perm.md), [max_age](api/max-age.md), [renew](api/validate.md#query-parameters) | 200/401/403 | | 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| ### User JSON API: /auth/api/user/* diff --git a/docs/Integration.md b/docs/Integration.md index 5e28fee..65c0c22 100644 --- a/docs/Integration.md +++ b/docs/Integration.md @@ -122,7 +122,7 @@ This is useful for: - 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. +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. To verify a session without extending its lifetime or updating its IP / user-agent, pass `renew=0`. 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. diff --git a/docs/api/validate.md b/docs/api/validate.md index b49b93f..cb8b67c 100644 --- a/docs/api/validate.md +++ b/docs/api/validate.md @@ -12,6 +12,7 @@ See also the [API overview](../API.md) and the [integration guide](../Integratio |-----------|-------------| | perm | Required permissions. See the [perm argument](perm.md). | | max_age | Require recent passkey use. See the [max_age argument](max-age.md). | +| renew | Pass `renew=0` to skip renewal: does no session updates, auth check only. | ## Request headers @@ -19,7 +20,7 @@ See also the [API overview](../API.md) and the [integration guide](../Integratio |---|---|---| | 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 | +| X-Forwarded-For | Real client IP | Recorded in logs and 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. diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index a02e575..b3b155a 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -103,9 +103,10 @@ async def validate_token( response: Response, perm: list[str] = Query([]), max_age: str | None = Query(None), + renew: bool = Query(True), auth=AUTH_COOKIE, ): - """Validate session and return context. Refreshes session expiry.""" + """Validate session and return context. Refreshes session expiry by default.""" perm_groups = _parse_perm(perm) try: ctx = await authz.verify( @@ -118,7 +119,7 @@ async def validate_token( # Global handler will clear cookie if 401 raise renewed = False - if auth: + if auth and renew: consumed = datetime.now(UTC) - ctx.session.validated if not timedelta(0) < consumed < _REFRESH_INTERVAL: db.update_session( @@ -128,16 +129,18 @@ async def validate_token( validated=datetime.now(UTC), ctx=ctx, ) - session.set_session_cookie(response, auth) renewed = True _set_log_extra(request, ctx.session.key) - return MsgspecResponse( + resp = MsgspecResponse( ApiValidateResponse( valid=True, renewed=renewed, ctx=userinfo.build_session_context(ctx), ) ) + if renewed: + session.set_session_cookie(resp, auth) + return resp @app.get("/check") diff --git a/tests/test_api.py b/tests/test_api.py index ee77d53..1aa2d87 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,11 +18,13 @@ from uuid import UUID import httpx import pytest -from paskia import authcode +from paskia import authcode, db from paskia.authsession import EXPIRES from paskia.db import delete_session from paskia.db.structs import Client +from paskia.fastapi.api import _REFRESH_INTERVAL from paskia.util import avatar, hostutil, oidjwt, permutil +from paskia.util.crypto import hash_secret from paskia.util.passphrase import generate from tests.conftest import auth_headers, create_test_image_bytes, create_test_session @@ -924,3 +926,53 @@ class TestValidateWithMaxAge: # This exercises the max_age path - but isn't defined in validate # Actually validate doesn't have max_age - this tests that unknown params are ignored assert response.status_code == 200 + + +class TestValidateRenewParameter: + """Tests for the renew query parameter on /auth/api/validate.""" + + @pytest.mark.asyncio + async def test_validate_renew_false_skips_renewal( + self, client: httpx.AsyncClient, session_token: str, test_db + ): + """renew=0 should skip session renewal and leave metadata untouched.""" + key = hash_secret("cookie", session_token) + old_validated = datetime.now(UTC) - _REFRESH_INTERVAL - timedelta(minutes=1) + db.update_session(key, validated=old_validated) + original_ua = test_db.sessions[key].user_agent + + response = await client.post( + "/auth/api/validate?renew=0", + headers={ + **auth_headers(session_token), + "Host": "localhost:4401", + "User-Agent": "different-ua", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["valid"] is True + assert data["renewed"] is False + assert "set-cookie" not in response.headers + assert test_db.sessions[key].validated == old_validated + assert test_db.sessions[key].user_agent == original_ua + + @pytest.mark.asyncio + async def test_validate_renew_true_renews_old_session( + self, client: httpx.AsyncClient, session_token: str, test_db + ): + """Explicit renew=1 should renew an aged session and return Set-Cookie.""" + key = hash_secret("cookie", session_token) + old_validated = datetime.now(UTC) - _REFRESH_INTERVAL - timedelta(minutes=1) + db.update_session(key, validated=old_validated) + + response = await client.post( + "/auth/api/validate?renew=1", + headers={**auth_headers(session_token), "Host": "localhost:4401"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["valid"] is True + assert data["renewed"] is True + assert "set-cookie" in response.headers + assert test_db.sessions[key].validated > old_validated