Compare commits

...
1 Commits
5 changed files with 65 additions and 9 deletions
+2 -2
View File
@@ -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/*
+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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.
+7 -4
View File
@@ -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")
+53 -1
View File
@@ -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