diff --git a/paskia/db/lifecycle.py b/paskia/db/lifecycle.py index 26ad70c..661d9f0 100644 --- a/paskia/db/lifecycle.py +++ b/paskia/db/lifecycle.py @@ -7,6 +7,7 @@ import os from datetime import UTC, datetime import paskia.db.operations as _ops +from paskia.authsession import EXPIRES _logger = logging.getLogger(__name__) @@ -27,7 +28,8 @@ def cleanup_expired() -> int: """Remove expired sessions and reset tokens. Returns count removed.""" now = datetime.now(UTC) count = 0 - expired_sessions = [k for k, s in _ops._db.sessions.items() if s.expiry < now] + limit = now - EXPIRES + expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit] if expired_sessions: from paskia import oidc_notify # noqa: PLC0415 diff --git a/paskia/db/operations.py b/paskia/db/operations.py index b273009..8ca7bfa 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -447,7 +447,7 @@ def update_session( host: str | None = None, ip: str | None = None, user_agent: str | None = None, - expiry: datetime | None = None, + validated: datetime | None = None, *, ctx: SessionContext | None = None, ) -> None: @@ -462,8 +462,8 @@ def update_session( s.ip = ip if user_agent is not None: s.user_agent = user_agent - if expiry is not None: - s.expiry = expiry + if validated is not None: + s.validated = validated def set_session_host( @@ -593,7 +593,7 @@ def login( host=host, ip=ip, user_agent=user_agent, - expiry=now + duration, + validated=now, ) user_str = str(user_uuid) with _db.transaction("login", user=user_str): @@ -666,7 +666,7 @@ def create_credential_session( host=host, ip=ip, user_agent=user_agent, - expiry=now + SESSION_LIFETIME, + validated=now, ) user_str = str(user_uuid) with _db.transaction("create_credential_session", user=user_str): diff --git a/paskia/db/structs.py b/paskia/db/structs.py index abdd9ec..5d701ca 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -364,7 +364,7 @@ class Credential(msgspec.Struct, dict=True): class Session(msgspec.Struct, dict=True, omit_defaults=True): """Session data structure. - Mutable fields: expiry (updated on session refresh) + Mutable fields: validated (updated on session refresh) Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid key is the hashed db_key, stored in the dict key, not in the struct. @@ -379,7 +379,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True): host: str ip: str user_agent: str - expiry: datetime + validated: datetime client_uuid: UUID | None = msgspec.field(name="client", default=None) def __post_init__(self): @@ -401,7 +401,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True): return { "ip": self.ip, "user_agent": self.user_agent, - "expiry": self.expiry.isoformat(), + "validated": self.validated.isoformat(), } def store(self, last_seen: datetime) -> None: @@ -428,7 +428,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True): host: str, ip: str, user_agent: str, - expiry: datetime, + validated: datetime, client: UUID | None = None, ) -> Session: """Create a new Session with the provided key. @@ -451,7 +451,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True): host=host, ip=ip, user_agent=user_agent, - expiry=expiry, + validated=validated, client_uuid=client, ) session.key = key diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 075becc..74ffc63 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse from paskia import aaguid as aaguid_mod from paskia import db -from paskia.authsession import EXPIRES, reset_expires +from paskia.authsession import reset_expires from paskia.db import Org as OrgDC from paskia.db import Permission as PermDC from paskia.db import Role as RoleDC @@ -545,7 +545,6 @@ async def admin_get_user_detail( s, current_key=ctx.session.key, normalized_host=normalized_host, - expires_delta=EXPIRES, ) for s in user.sessions } diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index bfbbcd7..ffdd541 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -15,7 +15,7 @@ from fastapi.security import HTTPBearer from paskia import authcode, db from paskia._version import __version__ -from paskia.authsession import EXPIRES, expires, get_reset +from paskia.authsession import EXPIRES, get_reset from paskia.fastapi import authz, session, user from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip @@ -89,13 +89,13 @@ async def validate_token( raise renewed = False if auth: - consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC)) + consumed = datetime.now(UTC) - ctx.session.validated if not timedelta(0) < consumed < _REFRESH_INTERVAL: db.update_session( ctx.session.key, ip=get_client_ip(request), user_agent=request.headers.get("user-agent") or "", - expiry=expires(), + validated=datetime.now(UTC), ctx=ctx, ) session.set_session_cookie(response, auth) @@ -152,11 +152,7 @@ async def forward_authentication( "Remote-Role": str(ctx.role.uuid), "Remote-Role-Name": ctx.role.display_name, "Remote-Session-Expires": ( - ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z") - if ctx.session.expiry.tzinfo - else ctx.session.expiry.replace(tzinfo=UTC) - .isoformat() - .replace("+00:00", "Z") + (ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z") ), "Remote-Credential": str(ctx.session.credential), } diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index 7fbaf94..a693d94 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -21,7 +21,6 @@ from fastapi.responses import JSONResponse from fastapi.security import HTTPBearer from paskia import authcode, db -from paskia.config import SESSION_LIFETIME from paskia.db.structs import Session from paskia.util import oidjwt from paskia.util.crypto import hash_secret @@ -280,14 +279,6 @@ async def _handle_refresh_token( status_code=400, ) - # Check session not expired - now = datetime.now(UTC) - if session.expiry < now: - return JSONResponse( - {"error": "invalid_grant", "error_description": "Refresh token expired"}, - status_code=400, - ) - # Get user user = db.data().users.get(session.user_uuid) if not user: @@ -297,6 +288,7 @@ async def _handle_refresh_token( ) # Refresh the session - extend expiry and record IP/user_agent + now = datetime.now(UTC) ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() if not ip: ip = request.client.host if request.client else "" @@ -306,7 +298,7 @@ async def _handle_refresh_token( session.key, ip=ip, user_agent=user_agent, - expiry=now + SESSION_LIFETIME, + validated=now, ) _logger.info("OIDC session refreshed: %s", session.key) diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index d9c3ec0..aa1d06d 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -8,7 +8,6 @@ from fastapi import FastAPI, WebSocket from paskia import authcode, db from paskia.authcode import CookieCode, OIDCCode from paskia.authsession import get_reset -from paskia.config import SESSION_LIFETIME from paskia.db.structs import Session from paskia.fastapi import authz, remote from paskia.fastapi.session import AUTH_COOKIE, infodict @@ -222,7 +221,7 @@ async def websocket_authenticate( host=normalized_host, ip=metadata["ip"], user_agent=metadata["user_agent"], - expiry=now + SESSION_LIFETIME, + validated=now, client=oidc_client.uuid, ) db.oidc_login( diff --git a/paskia/util/apistructs.py b/paskia/util/apistructs.py index 4ff59d9..5efad0f 100644 --- a/paskia/util/apistructs.py +++ b/paskia/util/apistructs.py @@ -99,7 +99,7 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True): host: str ip: str user_agent: str - expiry: datetime + validated: datetime last_renewed: datetime is_current: bool = False is_current_host: bool = False @@ -113,7 +113,6 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True): *, current_key: str, normalized_host: str | None, - expires_delta, # timedelta ) -> ApiUserSession: client_name = None if s.client_uuid: @@ -124,8 +123,8 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True): host=s.host, ip=s.ip, user_agent=useragent.compact_user_agent(s.user_agent), - expiry=s.expiry, - last_renewed=s.expiry - expires_delta, + validated=s.validated, + last_renewed=s.validated, is_current=s.key == current_key, is_current_host=not s.client_uuid and bool(normalized_host and s.host and s.host == normalized_host), diff --git a/paskia/util/sessionutil.py b/paskia/util/sessionutil.py index 6ccd386..a980141 100644 --- a/paskia/util/sessionutil.py +++ b/paskia/util/sessionutil.py @@ -2,7 +2,6 @@ from datetime import UTC, datetime -from paskia.authsession import EXPIRES from paskia.db import SessionContext from paskia.util.timeutil import parse_duration @@ -32,7 +31,7 @@ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool: if ctx.credential and ctx.credential.last_used: auth_time = ctx.credential.last_used else: - auth_time = ctx.session.expiry - EXPIRES + auth_time = ctx.session.validated time_since_auth = datetime.now(UTC) - auth_time return time_since_auth <= max_age_delta diff --git a/paskia/util/userinfo.py b/paskia/util/userinfo.py index b2822ac..0626ed3 100644 --- a/paskia/util/userinfo.py +++ b/paskia/util/userinfo.py @@ -1,7 +1,6 @@ """User information formatting and retrieval logic.""" from paskia import aaguid, db -from paskia.authsession import EXPIRES from paskia.db import SessionContext from paskia.util import hostutil from paskia.util.apistructs import ( @@ -50,7 +49,6 @@ async def build_user_info( s, current_key=session_key, normalized_host=normalized_host, - expires_delta=EXPIRES, ) for s in user.sessions } diff --git a/tests/conftest.py b/tests/conftest.py index 78a450c..04b7e79 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -277,7 +277,7 @@ def create_test_session( host=host, ip=ip, user_agent=user_agent, - expiry=now + duration, + validated=now, ) if session.key in ops_db._db.sessions: raise ValueError("Session already exists")