From 291e0eae0b0451f37d5880c3706621ca86e58473 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 23 Jan 2026 01:39:59 +0000 Subject: [PATCH] Replace session.renewed with .expiry for consistency with other expiring items. Fix migration script. --- paskia/authsession.py | 17 +++++--------- paskia/db/json.py | 31 ++++++++++++------------- paskia/fastapi/admin.py | 9 ++++---- paskia/fastapi/api.py | 10 ++++----- paskia/migrate/__init__.py | 46 +++++++++++++++++++------------------- paskia/migrate/sql.py | 19 +++++++++++++--- paskia/util/sessionutil.py | 5 +++-- paskia/util/userinfo.py | 4 ++-- tests/conftest.py | 6 +++-- tests/test_admin.py | 7 +++--- tests/test_api.py | 7 +++--- 11 files changed, 84 insertions(+), 77 deletions(-) diff --git a/paskia/authsession.py b/paskia/authsession.py index ad68580..0023664 100644 --- a/paskia/authsession.py +++ b/paskia/authsession.py @@ -31,12 +31,6 @@ def reset_expires() -> datetime: return datetime.now(timezone.utc) + RESET_LIFETIME -def session_expiry(session: Session) -> datetime: - """Calculate the expiration timestamp for a session (UTC aware).""" - # After migration all renewed timestamps are timezone-aware UTC - return session.renewed + EXPIRES - - async def create_session( user_uuid: UUID, credential_uuid: UUID, @@ -54,7 +48,6 @@ async def create_session( if not (hostname == rp_id or hostname.endswith(f".{rp_id}")): raise ValueError(f"Host must be the same as or a subdomain of {rp_id}") token = create_token() - now = datetime.now(timezone.utc) db.create_session( user_uuid=user_uuid, credential_uuid=credential_uuid, @@ -62,15 +55,15 @@ async def create_session( host=normalized_host, ip=ip, user_agent=user_agent, - renewed=now, + expiry=expires(), ) return token async def get_reset(token: str) -> ResetToken: - """Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token).""" + """Validate a credential reset token.""" record = db.get_reset_token(reset_key(token)) - if record and record.expiry >= datetime.now(timezone.utc): + if record: return record raise ValueError("This authentication link is no longer valid.") @@ -81,7 +74,7 @@ async def get_session(token: str, host: str | None = None) -> Session: if not host: raise ValueError("Invalid host") session = db.get_session(session_key(token)) - if session and session_expiry(session) >= datetime.now(timezone.utc): + if session: if session.host is None: # First time binding: store exact host:port (or IPv6 form) now. db.set_session_host(session.key, host) @@ -101,7 +94,7 @@ async def refresh_session_token(token: str, *, ip: str, user_agent: str): session_key(token), ip=ip, user_agent=user_agent, - renewed=datetime.now(timezone.utc), + expiry=expires(), ) if not updated: raise ValueError("Session not found or expired") diff --git a/paskia/db/json.py b/paskia/db/json.py index 136c6aa..08c2d92 100644 --- a/paskia/db/json.py +++ b/paskia/db/json.py @@ -99,14 +99,14 @@ class Session(msgspec.Struct): host: str | None ip: str | None user_agent: str | None - renewed: datetime + expiry: datetime def metadata(self) -> dict: """Return session metadata for backwards compatibility.""" return { "ip": self.ip, "user_agent": self.user_agent, - "renewed": self.renewed.isoformat(), + "expiry": self.expiry.isoformat(), } @@ -176,7 +176,7 @@ class _SessionData(msgspec.Struct): host: str | None ip: str | None user_agent: str | None - renewed: datetime + expiry: datetime class _ResetTokenData(msgspec.Struct): @@ -519,7 +519,7 @@ class DB: host=s.host, ip=s.ip, user_agent=s.user_agent, - renewed=s.renewed, + expiry=s.expiry, ) # ------------------------------------------------------------------------- @@ -664,7 +664,7 @@ class DB: host: str, ip: str, user_agent: str, - renewed: datetime, + expiry: datetime, actor: str = "system", ) -> None: with self.session(actor): @@ -675,7 +675,7 @@ class DB: host=host, ip=ip, user_agent=user_agent, - renewed=renewed, + expiry=expiry, ) def get_session(self, key: bytes) -> Session | None: @@ -697,7 +697,7 @@ class DB: *, ip: str, user_agent: str, - renewed: datetime, + expiry: datetime, actor: str = "system", ) -> Session | None: with self.session(actor): @@ -707,7 +707,7 @@ class DB: s = self._data.sessions[key_b64] s.ip = ip s.user_agent = user_agent - s.renewed = renewed + s.expiry = expiry return self._build_session(key_b64) def set_session_host(self, key: bytes, host: str, actor: str = "system") -> None: @@ -727,8 +727,8 @@ class DB: key_bytes = _str_to_bytes(key_b64) if key_bytes and key_bytes.startswith(b"sess"): sessions.append(self._build_session(key_b64)) - # Sort by renewed desc - sessions.sort(key=lambda x: x.renewed, reverse=True) + # Sort by expiry desc (most recent expiry first) + sessions.sort(key=lambda x: x.expiry, reverse=True) return sessions def delete_sessions_for_user(self, user_uuid: UUID, actor: str = "system") -> None: @@ -1186,7 +1186,7 @@ class DB: if display_name and user_key in self._data.users: self._data.users[user_key].display_name = display_name - # New session + # New session - compute expiry from credential.last_used sess_key_b64 = _bytes_to_str(session_key) self._data.sessions[sess_key_b64] = _SessionData( user=user_key, @@ -1194,7 +1194,7 @@ class DB: host=host, ip=ip, user_agent=user_agent, - renewed=credential.last_used, + expiry=credential.last_used + SESSION_LIFETIME, ) # Login side-effects @@ -1208,13 +1208,11 @@ class DB: """Remove expired sessions and reset tokens.""" with self.session("expiry"): current_time = datetime.now(timezone.utc) - session_threshold = current_time - SESSION_LIFETIME # Clean expired sessions to_delete_sessions = [] for k, s in self._data.sessions.items(): - renewed = s.renewed - if renewed and renewed < session_threshold: + if s.expiry < current_time: to_delete_sessions.append(k) for k in to_delete_sessions: del self._data.sessions[k] @@ -1222,8 +1220,7 @@ class DB: # Clean expired reset tokens to_delete_tokens = [] for k, t in self._data.reset_tokens.items(): - expiry = t.expiry - if expiry and expiry < current_time: + if t.expiry < current_time: to_delete_tokens.append(k) for k in to_delete_tokens: del self._data.reset_tokens[k] diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 3edb432..b5a14f3 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -5,7 +5,7 @@ from uuid import UUID, uuid4 from fastapi import Body, FastAPI, HTTPException, Request, Response from fastapi.responses import JSONResponse -from paskia.authsession import reset_expires +from paskia.authsession import EXPIRES, reset_expires from paskia.fastapi import authz from paskia.fastapi.session import AUTH_COOKIE from paskia import db @@ -556,6 +556,7 @@ async def admin_get_user_detail( current_session_key = session_key(auth) sessions_payload: list[dict] = [] for entry in session_records: + renewed = entry.expiry - EXPIRES sessions_payload.append( { "id": encode_session_key(entry.key), @@ -564,11 +565,11 @@ async def admin_get_user_detail( "ip": entry.ip, "user_agent": useragent.compact_user_agent(entry.user_agent), "last_renewed": ( - entry.renewed.astimezone(timezone.utc) + renewed.astimezone(timezone.utc) .isoformat() .replace("+00:00", "Z") - if entry.renewed.tzinfo - else entry.renewed.replace(tzinfo=timezone.utc) + if renewed.tzinfo + else renewed.replace(tzinfo=timezone.utc) .isoformat() .replace("+00:00", "Z") ), diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 321cf2e..92cd474 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -18,7 +18,6 @@ from paskia.authsession import ( get_reset, get_session, refresh_session_token, - session_expiry, ) from paskia.fastapi import authz, session, user from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME @@ -98,8 +97,7 @@ async def validate_token( raise renewed = False if auth: - current_expiry = session_expiry(ctx.session) - consumed = EXPIRES - (current_expiry - datetime.now(timezone.utc)) + consumed = EXPIRES - (ctx.session.expiry - datetime.now(timezone.utc)) if not timedelta(0) < consumed < _REFRESH_INTERVAL: try: await refresh_session_token( @@ -160,12 +158,12 @@ async def forward_authentication( "Remote-Role": str(ctx.role.uuid), "Remote-Role-Name": ctx.role.display_name, "Remote-Session-Expires": ( - session_expiry(ctx.session) + ctx.session.expiry .astimezone(timezone.utc) .isoformat() .replace("+00:00", "Z") - if session_expiry(ctx.session).tzinfo - else session_expiry(ctx.session) + if ctx.session.expiry.tzinfo + else ctx.session.expiry .replace(tzinfo=timezone.utc) .isoformat() .replace("+00:00", "Z") diff --git a/paskia/migrate/__init__.py b/paskia/migrate/__init__.py index 558a82e..b345def 100644 --- a/paskia/migrate/__init__.py +++ b/paskia/migrate/__init__.py @@ -16,6 +16,8 @@ from datetime import datetime, timezone import base64url +from paskia.authsession import EXPIRES + from .sql import ( DB as SQLDB, ) @@ -56,15 +58,13 @@ async def migrate_from_sql( from paskia.db.json import ( DB as JSONDB, - ) - from paskia.db.json import ( - CredentialData, - OrgData, - PermissionData, - ResetTokenData, - RoleData, - SessionData, - UserData, + _CredentialData, + _OrgData, + _PermissionData, + _ResetTokenData, + _RoleData, + _SessionData, + _UserData, ) # Initialize source SQL database @@ -73,16 +73,16 @@ async def migrate_from_sql( # Initialize destination JSON database json_db = JSONDB(json_db_path) - await json_db.init_db() + json_db.load() print(f"Migrating from {sql_db_path} to {json_db_path}...") # Build all data directly without saving (we'll save once at the end) - async with json_db._lock: + with json_db._lock: # Migrate permissions permissions = await sql_db.list_permissions() for perm in permissions: - json_db._data.permissions[perm.id] = PermissionData( + json_db._data.permissions[perm.id] = _PermissionData( display_name=perm.display_name, orgs={}, ) @@ -92,7 +92,7 @@ async def migrate_from_sql( orgs = await sql_db.list_organizations() for org in orgs: key = str(org.uuid) - json_db._data.orgs[key] = OrgData( + json_db._data.orgs[key] = _OrgData( display_name=org.display_name, ) # Update permissions to allow this org to grant them @@ -106,7 +106,7 @@ async def migrate_from_sql( for org in orgs: for role in org.roles: key = str(role.uuid) - json_db._data.roles[key] = RoleData( + json_db._data.roles[key] = _RoleData( org=str(role.org_uuid), display_name=role.display_name, permissions={p: True for p in role.permissions} @@ -123,7 +123,7 @@ async def migrate_from_sql( for um in user_models: user = um.as_dataclass() key = str(user.uuid) - json_db._data.users[key] = UserData( + json_db._data.users[key] = _UserData( display_name=user.display_name, role=str(user.role_uuid), created_at=user.created_at or datetime.now(timezone.utc), @@ -139,7 +139,7 @@ async def migrate_from_sql( for cm in cred_models: cred = cm.as_dataclass() key = str(cred.uuid) - json_db._data.credentials[key] = CredentialData( + json_db._data.credentials[key] = _CredentialData( credential_id=cred.credential_id, user=str(cred.user_uuid), aaguid=str(cred.aaguid), @@ -158,13 +158,13 @@ async def migrate_from_sql( for sm in session_models: sess = sm.as_dataclass() key_b64 = _bytes_to_str(sess.key) - json_db._data.sessions[key_b64] = SessionData( + json_db._data.sessions[key_b64] = _SessionData( user=str(sess.user_uuid), credential=str(sess.credential_uuid), host=sess.host, ip=sess.ip, user_agent=sess.user_agent, - renewed=sess.renewed, + expiry=sess.renewed + EXPIRES, # Convert renewed to expiry ) print(f" Migrated {len(session_models)} sessions") @@ -175,17 +175,17 @@ async def migrate_from_sql( for tm in token_models: token = tm.as_dataclass() key_b64 = _bytes_to_str(token.key) - json_db._data.reset_tokens[key_b64] = ResetTokenData( + json_db._data.reset_tokens[key_b64] = _ResetTokenData( user=str(token.user_uuid), expiry=token.expiry, token_type=token.token_type, ) print(f" Migrated {len(token_models)} reset tokens") - # Save all changes as a single diff with actor "migrate" - # Start from empty {} so diff shows pure insertions - json_db._previous_builtins = {} - await json_db._save(actor="migrate") + # Queue and flush all changes with actor "migrate" + json_db._current_actor = "migrate" + json_db._queue_change() + json_db.flush() print("Migration complete!") diff --git a/paskia/migrate/sql.py b/paskia/migrate/sql.py index c06c32e..added82 100644 --- a/paskia/migrate/sql.py +++ b/paskia/migrate/sql.py @@ -8,6 +8,7 @@ DO NOT use this module for new code. Use paskia.db.json instead. """ from contextlib import asynccontextmanager +from dataclasses import dataclass from datetime import datetime, timezone from uuid import UUID @@ -30,13 +31,25 @@ from paskia.db import ( Permission, ResetToken, Role, - Session, User, ) DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite" +# Local Session class for SQL schema (uses 'renewed' not 'expiry') +@dataclass +class _SqlSession: + """Session as stored in the old SQL schema with renewed timestamp.""" + key: bytes + user_uuid: UUID + credential_uuid: UUID + host: str + ip: str + user_agent: str + renewed: datetime + + def _normalize_dt(value: datetime | None) -> datetime | None: if value is None: return None @@ -187,7 +200,7 @@ class SessionModel(Base): ) def as_dataclass(self): - return Session( + return _SqlSession( key=self.key, user_uuid=UUID(bytes=self.user_uuid), credential_uuid=UUID(bytes=self.credential_uuid), @@ -198,7 +211,7 @@ class SessionModel(Base): ) @staticmethod - def from_dataclass(session: Session): + def from_dataclass(session: _SqlSession): return SessionModel( key=session.key, user_uuid=session.user_uuid.bytes, diff --git a/paskia/util/sessionutil.py b/paskia/util/sessionutil.py index fbac790..3dce947 100644 --- a/paskia/util/sessionutil.py +++ b/paskia/util/sessionutil.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone +from paskia.authsession import EXPIRES from paskia.db import SessionContext from paskia.util.timeutil import parse_duration @@ -27,11 +28,11 @@ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool: max_age_delta = parse_duration(max_age) - # Use credential's last_used time if available, fall back to session renewed + # Use credential's last_used time if available, fall back to session renewed time if ctx.credential and ctx.credential.last_used: auth_time = ctx.credential.last_used else: - auth_time = ctx.session.renewed + auth_time = ctx.session.expiry - EXPIRES time_since_auth = datetime.now(timezone.utc) - auth_time return time_since_auth <= max_age_delta diff --git a/paskia/util/userinfo.py b/paskia/util/userinfo.py index 279179a..d330fce 100644 --- a/paskia/util/userinfo.py +++ b/paskia/util/userinfo.py @@ -3,7 +3,7 @@ from datetime import timezone from paskia import aaguid -from paskia.authsession import session_key +from paskia.authsession import EXPIRES, session_key from paskia import db from paskia.util import hostutil, permutil, tokens, useragent @@ -110,7 +110,7 @@ async def format_user_info( "host": entry.host, "ip": entry.ip, "user_agent": useragent.compact_user_agent(entry.user_agent), - "last_renewed": _format_datetime(entry.renewed), + "last_renewed": _format_datetime(entry.expiry - EXPIRES), "is_current": entry.key == current_session_key, "is_current_host": bool( normalized_request_host diff --git a/tests/conftest.py b/tests/conftest.py index 87e5a3d..13035fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,8 @@ from uuid import UUID import httpx import pytest + +from paskia.authsession import expires import pytest_asyncio import uuid7 @@ -190,7 +192,7 @@ async def session_token( host="localhost:4401", ip="127.0.0.1", user_agent="pytest", - renewed=datetime.now(timezone.utc), + expiry=expires(), ) return token @@ -208,7 +210,7 @@ async def regular_session_token( host="localhost:4401", ip="127.0.0.1", user_agent="pytest", - renewed=datetime.now(timezone.utc), + expiry=expires(), ) return token diff --git a/tests/test_admin.py b/tests/test_admin.py index 472d413..dc823ef 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -19,6 +19,7 @@ import pytest import pytest_asyncio import uuid7 +from paskia.authsession import expires from paskia.db import Credential, Org, Permission, Role, User from paskia.db.json import DB from paskia.util.tokens import create_token, encode_session_key, session_key @@ -101,7 +102,7 @@ async def second_org_session_token( host="localhost:4401", ip="127.0.0.1", user_agent="pytest", - renewed=datetime.now(timezone.utc), + expiry=expires(), ) return token @@ -167,7 +168,7 @@ async def org_admin_session_token( host="localhost:4401", ip="127.0.0.1", user_agent="pytest", - renewed=datetime.now(timezone.utc), + expiry=expires(), ) return token @@ -1181,7 +1182,7 @@ class TestAdminSessions: host="other.host:4401", ip="192.168.1.1", user_agent="other-agent", - renewed=datetime.now(timezone.utc), + expiry=expires(), ) encoded_key = encode_session_key(extra_key) diff --git a/tests/test_api.py b/tests/test_api.py index 444cb6a..b5b35c7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -520,11 +520,12 @@ class TestValidateSessionRefresh: """Validate should return 401 if session disappears during refresh.""" from datetime import timedelta + from paskia.authsession import EXPIRES from paskia.util.tokens import create_token, session_key - # Create a session with an old renewed time to trigger refresh + # Create a session with an old expiry time to trigger refresh token = create_token() - old_time = datetime.now(timezone.utc) - timedelta(minutes=10) + old_expiry = datetime.now(timezone.utc) + EXPIRES - timedelta(minutes=10) test_db.create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, @@ -532,7 +533,7 @@ class TestValidateSessionRefresh: host="localhost:4401", ip="127.0.0.1", user_agent="pytest", - renewed=old_time, + expiry=old_expiry, ) # Delete the session right before validate tries to refresh