Files
Juan Denis f724cf0067 Keep live sockets alive past access-token expiry
Every emit re-validated the socket's saved JWT including exp, and
access tokens last 15 minutes, so metrics, terminals, logs and
notifications were cut a quarter-hour after connecting; clients do
not reconnect after a server-side disconnect. Re-checks now skip exp
only; revocation, auth_version and deactivation still end the socket,
and connecting still needs an unexpired token.
2026-10-26 00:44:28 -04:00

67 lines
2.7 KiB
Python

"""Browser-session policy shared by HTTP, sockets, and long-running work."""
import time
from functools import wraps
from flask import g, jsonify
from flask_jwt_extended import get_jwt, verify_jwt_in_request
def issue_session_tokens(user_id):
"""Issue one browser's access/refresh pair with an explicit shared id."""
import secrets
from flask_jwt_extended import create_access_token, create_refresh_token
claims = {'session_id': secrets.token_hex(16)}
return (create_access_token(identity=user_id, additional_claims=claims),
create_refresh_token(identity=user_id, additional_claims=claims))
def validate_session_claims(claims, *, allow_pending=False, token_type='access',
check_expiry=True):
"""Return the live user only for an unexpired, unrevoked session JWT.
check_expiry=False is for re-checking an already-authenticated live
connection: its token was unexpired at connect, and revocation, the
auth_version bump and deactivation still end it."""
from app.models import User, RevokedSession
if not isinstance(claims, dict) or claims.get('type') != token_type:
return None
if claims.get('2fa_pending') and not allow_pending:
return None
session_id = claims.get('session_id')
if not isinstance(session_id, str) or len(session_id) != 32:
return None
if RevokedSession.query.filter_by(session_id=session_id).first() is not None:
return None
expiration = claims.get('exp')
if not isinstance(expiration, (int, float)) or (check_expiry and expiration <= time.time()):
return None
try:
user_id = int(claims['sub'])
except (KeyError, TypeError, ValueError):
return None
user = User.query.populate_existing().filter_by(id=user_id).first()
if (not user or not user.is_active or not claims.get('auth_version')
or claims['auth_version'] != user.auth_version):
return None
return user
def session_required(fn):
"""Explicit JWT-only policy for actions on the caller's browser sessions.
Credential/session administration must never turn a scoped API key into
unrestricted browser credentials, even when its owner is an admin.
"""
@wraps(fn)
def wrapper(*args, **kwargs):
if getattr(g, 'api_key_user', None):
return jsonify({'error': 'Browser authentication required'}), 403
verify_jwt_in_request()
g.session_user = validate_session_claims(get_jwt())
if not g.session_user:
return jsonify({'error': 'Session is no longer valid'}), 401
return fn(*args, **kwargs)
wrapper._sk_authz = ('session_required',)
return wrapper