File size: 11,367 Bytes
fae5228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
import os
import time
import uuid
import json
import jwt
from typing import Optional, Dict, Any, List
from threading import Lock

from backend.services.redis_lock import RedisLockManager

JWT_SECRET = os.getenv("JWT_SECRET", "hyperflow_enterprise_jwt_secret_key_2026")
JWT_ALGORITHM = "HS256"

ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))


class TokenManager:
    """
    Enterprise Dual-Token & JTI Revocation Blacklist Manager.
    Uses Redis for real-time O(1) blacklist lookups with TTL auto-purge.
    Falls back gracefully to a thread-safe in-memory store if Redis is unavailable.
    """

    def __init__(self, redis_client=None):
        self.secret_key = JWT_SECRET
        self.algorithm = JWT_ALGORITHM
        self.lock_manager = RedisLockManager(redis_client=redis_client)
        self.redis = getattr(self.lock_manager, 'client', None)
        self.use_fallback = getattr(self.lock_manager, 'use_fallback', True)

        # In-memory fallbacks when Redis is offline
        self._memory_blacklist: Dict[str, float] = {}  # {jti: exp_timestamp}
        self._memory_user_jtis: Dict[str, List[tuple[str, float]]] = {}  # {sub: [(jti, exp_timestamp)]}
        self._mem_lock = Lock()

    # ---------------------------------------------------------------------------
    # Token Generation
    # ---------------------------------------------------------------------------
    def create_access_token(
        self,
        sub: str,
        role: str = "user",
        scopes: Optional[List[str]] = None,
        custom_jti: Optional[str] = None
    ) -> tuple[str, str, float]:
        """Generates a short-lived access token (15 mins) with a unique JTI."""
        now = time.time()
        exp = now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60)
        jti = custom_jti or str(uuid.uuid4())

        payload = {
            "sub": sub,
            "jti": jti,
            "token_type": "access",
            "role": role,
            "scopes": scopes or ["read"],
            "iat": int(now),
            "exp": int(exp)
        }

        token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
        self.track_user_jti(sub, jti, exp)
        return token, jti, exp

    def create_refresh_token(
        self,
        sub: str,
        role: str = "user",
        parent_jti: Optional[str] = None,
        custom_jti: Optional[str] = None
    ) -> tuple[str, str, float]:
        """Generates a long-lived refresh token (7 days) with a unique JTI."""
        now = time.time()
        exp = now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400)
        jti = custom_jti or str(uuid.uuid4())

        payload = {
            "sub": sub,
            "jti": jti,
            "token_type": "refresh",
            "role": role,
            "parent_jti": parent_jti,
            "iat": int(now),
            "exp": int(exp)
        }

        token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
        self.track_user_jti(sub, jti, exp)
        return token, jti, exp

    def issue_token_pair(
        self,
        sub: str,
        role: str = "user",
        scopes: Optional[List[str]] = None
    ) -> Dict[str, Any]:
        """Issues an Access + Refresh dual token pair."""
        access_token, access_jti, access_exp = self.create_access_token(sub, role, scopes)
        refresh_token, refresh_jti, refresh_exp = self.create_refresh_token(sub, role, parent_jti=access_jti)

        return {
            "access_token": access_token,
            "refresh_token": refresh_token,
            "token_type": "bearer",
            "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
            "access_jti": access_jti,
            "refresh_jti": refresh_jti,
            "access_exp": int(access_exp),
            "refresh_exp": int(refresh_exp)
        }

    # ---------------------------------------------------------------------------
    # Token Verification
    # ---------------------------------------------------------------------------
    def decode_token(self, token_str: str) -> Dict[str, Any]:
        """Decodes and validates token signature and expiration."""
        try:
            payload = jwt.decode(token_str, self.secret_key, algorithms=[self.algorithm])
            return payload
        except jwt.ExpiredSignatureError:
            raise ValueError("Token signature has expired")
        except jwt.InvalidTokenError as e:
            raise ValueError(f"Invalid token: {str(e)}")

    def verify_access_token(self, token_str: str) -> Dict[str, Any]:
        """Decodes access token and ensures it is valid, type=='access', and not blacklisted."""
        payload = self.decode_token(token_str)

        if payload.get("token_type") != "access":
            raise ValueError("Invalid token type. Expected access token.")

        jti = payload.get("jti")
        if not jti:
            raise ValueError("Token payload missing JTI identifier.")

        if self.is_jti_blacklisted(jti):
            raise ValueError("Access token has been revoked (blacklisted JTI).")

        return payload

    def verify_refresh_token(self, token_str: str) -> Dict[str, Any]:
        """Decodes refresh token and ensures type=='refresh' and not blacklisted."""
        payload = self.decode_token(token_str)

        if payload.get("token_type") != "refresh":
            raise ValueError("Invalid token type. Expected refresh token.")

        jti = payload.get("jti")
        if not jti:
            raise ValueError("Token payload missing JTI identifier.")

        if self.is_jti_blacklisted(jti):
            raise ValueError("Refresh token has been revoked (blacklisted JTI).")

        return payload

    # ---------------------------------------------------------------------------
    # Redis JTI Blacklisting Engine
    # ---------------------------------------------------------------------------
    def revoke_jti(self, jti: str, exp_timestamp: float) -> bool:
        """
        Blacklists a JTI in Redis with TTL set to remaining token lifetime.
        Automatically deleted by Redis once token naturally expires.
        """
        now = time.time()
        remaining_ttl = int(exp_timestamp - now)
        if remaining_ttl <= 0:
            return True  # Already naturally expired

        if not self.use_fallback and self.redis:
            try:
                key = f"blacklist:jti:{jti}"
                self.redis.set(key, "revoked", ex=remaining_ttl)
                return True
            except Exception as e:
                print(f"[TokenManager] Redis error during revoke_jti: {e}. Using in-memory fallback.")

        # Fallback to thread-safe in-memory store
        with self._mem_lock:
            self._memory_blacklist[jti] = exp_timestamp
            self._purge_expired_memory_blacklist()
        return True

    def is_jti_blacklisted(self, jti: str) -> bool:
        """Checks whether a JTI exists in the Redis or in-memory blacklist."""
        if not jti:
            return False

        if not self.use_fallback and self.redis:
            try:
                key = f"blacklist:jti:{jti}"
                return bool(self.redis.exists(key))
            except Exception as e:
                print(f"[TokenManager] Redis error during is_jti_blacklisted: {e}. Using in-memory fallback.")

        # Fallback check
        now = time.time()
        with self._mem_lock:
            exp = self._memory_blacklist.get(jti)
            if exp and exp > now:
                return True
            elif exp and exp <= now:
                del self._memory_blacklist[jti]
        return False

    def get_jti_status(self, jti: str) -> Dict[str, Any]:
        """Returns details on whether a JTI is blacklisted and its remaining TTL."""
        if not self.use_fallback and self.redis:
            try:
                key = f"blacklist:jti:{jti}"
                ttl = self.redis.ttl(key)
                is_blacklisted = ttl > 0 or self.redis.exists(key) == 1
                return {
                    "jti": jti,
                    "is_blacklisted": is_blacklisted,
                    "ttl_seconds": max(0, ttl) if is_blacklisted else 0,
                    "storage": "redis"
                }
            except Exception:
                pass

        now = time.time()
        with self._mem_lock:
            exp = self._memory_blacklist.get(jti)
            if exp and exp > now:
                return {
                    "jti": jti,
                    "is_blacklisted": True,
                    "ttl_seconds": int(exp - now),
                    "storage": "in_memory"
                }
        return {
            "jti": jti,
            "is_blacklisted": False,
            "ttl_seconds": 0,
            "storage": "in_memory"
        }

    # ---------------------------------------------------------------------------
    # Session Tracking & Mass Revocation
    # ---------------------------------------------------------------------------
    def track_user_jti(self, sub: str, jti: str, exp_timestamp: float):
        """Registers active JTI under user's session list for bulk logout capability."""
        if not self.use_fallback and self.redis:
            try:
                user_key = f"user:{sub}:jtis"
                val = json.dumps({"jti": jti, "exp": exp_timestamp})
                self.redis.sadd(user_key, val)
                # Keep user session set alive for 7 days
                self.redis.expire(user_key, REFRESH_TOKEN_EXPIRE_DAYS * 86400)
                return
            except Exception:
                pass

        with self._mem_lock:
            if sub not in self._memory_user_jtis:
                self._memory_user_jtis[sub] = []
            self._memory_user_jtis[sub].append((jti, exp_timestamp))

    def revoke_all_user_jtis(self, sub: str) -> int:
        """Mass revokes all active JTIs for a specified user (Logout-All)."""
        revoked_count = 0
        now = time.time()

        if not self.use_fallback and self.redis:
            try:
                user_key = f"user:{sub}:jtis"
                members = self.redis.smembers(user_key)
                for member in members:
                    try:
                        data = json.loads(member)
                        jti = data.get("jti")
                        exp = data.get("exp", now + 3600)
                        if jti and exp > now:
                            self.revoke_jti(jti, exp)
                            revoked_count += 1
                    except Exception:
                        continue
                self.redis.delete(user_key)
                return revoked_count
            except Exception:
                pass

        with self._mem_lock:
            jtis = self._memory_user_jtis.pop(sub, [])
            for jti, exp in jtis:
                if exp > now:
                    self._memory_blacklist[jti] = exp
                    revoked_count += 1
        return revoked_count

    def _purge_expired_memory_blacklist(self):
        """Internal helper to prune expired JTIs from memory dictionary."""
        now = time.time()
        expired = [jti for jti, exp in self._memory_blacklist.items() if exp <= now]
        for jti in expired:
            del self._memory_blacklist[jti]