diff --git a/Data/Engine/Unit_Tests/test_tokens_api.py b/Data/Engine/Unit_Tests/test_tokens_api.py index 09b6f8f6..6d7c1ea7 100644 --- a/Data/Engine/Unit_Tests/test_tokens_api.py +++ b/Data/Engine/Unit_Tests/test_tokens_api.py @@ -77,14 +77,16 @@ def test_refresh_token_success(engine_harness: EngineTestHarness) -> None: with sqlite3.connect(str(harness.db_path)) as conn: cur = conn.cursor() cur.execute( - "SELECT last_used_at, revoked_at FROM refresh_tokens WHERE guid = ?", + "SELECT last_used_at, revoked_at, expires_at FROM refresh_tokens WHERE guid = ?", (guid,), ) row = cur.fetchone() assert row is not None - last_used_at, revoked_at = row + last_used_at, revoked_at, bumped_expires_at = row assert last_used_at is not None assert revoked_at is None + refreshed_expiry = datetime.fromisoformat(bumped_expires_at) + assert refreshed_expiry > now + timedelta(days=80) def test_refresh_token_requires_payload(engine_harness: EngineTestHarness) -> None: diff --git a/Data/Engine/services/API/enrollment/routes.py b/Data/Engine/services/API/enrollment/routes.py index 42688e06..df7ac424 100644 --- a/Data/Engine/services/API/enrollment/routes.py +++ b/Data/Engine/services/API/enrollment/routes.py @@ -312,9 +312,11 @@ def register( return hashlib.sha256(token.encode("utf-8")).hexdigest() def _issue_refresh_token(cur: sqlite3.Cursor, guid: str) -> Dict[str, Any]: + # Sliding window expiration; refreshed on each successful token refresh call. + REFRESH_TOKEN_TTL_DAYS = 90 token = secrets.token_urlsafe(48) now = _now() - expires_at = now.replace(microsecond=0) + timedelta(days=30) + expires_at = now.replace(microsecond=0) + timedelta(days=REFRESH_TOKEN_TTL_DAYS) cur.execute( """ INSERT INTO refresh_tokens (id, guid, token_hash, created_at, expires_at) diff --git a/Data/Engine/services/API/tokens/routes.py b/Data/Engine/services/API/tokens/routes.py index 784093b1..41863101 100644 --- a/Data/Engine/services/API/tokens/routes.py +++ b/Data/Engine/services/API/tokens/routes.py @@ -12,7 +12,7 @@ from __future__ import annotations import hashlib import sqlite3 -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from typing import Callable from flask import Blueprint, current_app, jsonify, request @@ -28,6 +28,7 @@ def register( dpop_validator: DPoPValidator, ) -> None: blueprint = Blueprint("tokens", __name__) + REFRESH_TOKEN_TTL_DAYS = 90 def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() @@ -70,7 +71,8 @@ def register( return jsonify({"error": "refresh_token_revoked"}), 401 if expires_at: try: - if _parse_iso(expires_at) <= datetime.now(tz=timezone.utc): + parsed_expiry = _parse_iso(expires_at) + if parsed_expiry <= datetime.now(tz=timezone.utc): return jsonify({"error": "refresh_token_expired"}), 401 except Exception: pass @@ -124,10 +126,16 @@ def register( """ UPDATE refresh_tokens SET last_used_at = ?, + expires_at = ?, dpop_jkt = COALESCE(NULLIF(?, ''), dpop_jkt) WHERE id = ? """, - (_iso_now(), jkt, record_id), + ( + _iso_now(), + _iso(datetime.now(tz=timezone.utc) + timedelta(days=REFRESH_TOKEN_TTL_DAYS)), + jkt, + record_id, + ), ) conn.commit() finally: