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/* ### 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/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 | | 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/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| | 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/* ### 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 - Background jobs that need to verify a stored session
- Check extra permissions, get user context or renew 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. 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). | | perm | Required permissions. See the [perm argument](perm.md). |
| max_age | Require recent passkey use. See the [max_age argument](max-age.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 ## 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 | | Host | Forwarded directly from the client | Verifying the session's bound host |
| Cookie | Forwarded directly or just cookie `__Host-paskia` | Session ID | | 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 | | 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. 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, response: Response,
perm: list[str] = Query([]), perm: list[str] = Query([]),
max_age: str | None = Query(None), max_age: str | None = Query(None),
renew: bool = Query(True),
auth=AUTH_COOKIE, 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) perm_groups = _parse_perm(perm)
try: try:
ctx = await authz.verify( ctx = await authz.verify(
@@ -118,7 +119,7 @@ async def validate_token(
# Global handler will clear cookie if 401 # Global handler will clear cookie if 401
raise raise
renewed = False renewed = False
if auth: if auth and renew:
consumed = datetime.now(UTC) - ctx.session.validated consumed = datetime.now(UTC) - ctx.session.validated
if not timedelta(0) < consumed < _REFRESH_INTERVAL: if not timedelta(0) < consumed < _REFRESH_INTERVAL:
db.update_session( db.update_session(
@@ -128,16 +129,18 @@ async def validate_token(
validated=datetime.now(UTC), validated=datetime.now(UTC),
ctx=ctx, ctx=ctx,
) )
session.set_session_cookie(response, auth)
renewed = True renewed = True
_set_log_extra(request, ctx.session.key) _set_log_extra(request, ctx.session.key)
return MsgspecResponse( resp = MsgspecResponse(
ApiValidateResponse( ApiValidateResponse(
valid=True, valid=True,
renewed=renewed, renewed=renewed,
ctx=userinfo.build_session_context(ctx), ctx=userinfo.build_session_context(ctx),
) )
) )
if renewed:
session.set_session_cookie(resp, auth)
return resp
@app.get("/check") @app.get("/check")
+53 -1
View File
@@ -18,11 +18,13 @@ from uuid import UUID
import httpx import httpx
import pytest import pytest
from paskia import authcode from paskia import authcode, db
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.fastapi.api import _REFRESH_INTERVAL
from paskia.util import avatar, hostutil, oidjwt, permutil from paskia.util import avatar, hostutil, oidjwt, permutil
from paskia.util.crypto import hash_secret
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
@@ -924,3 +926,53 @@ class TestValidateWithMaxAge:
# This exercises the max_age path - but isn't defined in validate # 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 # Actually validate doesn't have max_age - this tests that unknown params are ignored
assert response.status_code == 200 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