1717# Set up logger for this module
1818logger = get_logger (__name__ )
1919
20- # TTL cache for token validation (1 hour TTL, max 1000 entries)
21- _token_cache : cachetools .TTLCache [str , "UserInfo" ] = cachetools .TTLCache (
20+ # TTL cache keyed by a user's OIDC subject. Evict entries when roles change. We
21+ # still validate the JWT signature and expiry on every request before reading a
22+ # cached record.
23+ _user_info_cache : cachetools .TTLCache [UUID , "UserInfo" ] = cachetools .TTLCache (
2224 maxsize = 1000 , ttl = 60 * 60
2325)
2426
@@ -41,6 +43,17 @@ async def close_tdei_client() -> None:
4143 _tdei_client = None
4244
4345
46+ def evict_user_from_cache (auth_uid : UUID ) -> None :
47+ """
48+ Evict a user's cached UserInfo object so that their next request re-fetches
49+ permissions.
50+
51+ Call this after modifying a user's roles in the OSM DB to ensure the change
52+ takes effect on their next request rather than after the cache TTL expires.
53+ """
54+ _user_info_cache .pop (auth_uid , None )
55+
56+
4457security = HTTPBearer ()
4558
4659
@@ -72,6 +85,7 @@ class UserInfo:
7285 credentials : str
7386 user_uuid : UUID
7487 user_name : str
88+ token_jti : str # JWT ID used to detect token rotation on cache hits
7589
7690 # workspaceId, role from OSM DB
7791 osmWorkspaceRoles : dict [int , list [WorkspaceUserRoleType ]]
@@ -151,9 +165,13 @@ async def validate_token(
151165 osm_db_session : AsyncSession = Depends (get_osm_db_session ),
152166 task_db_session : AsyncSession = Depends (get_task_db_session ),
153167) -> UserInfo :
154- """Dependency to get current authenticated user from TDEI/KeyCloak token and APIs.
168+ """
169+ Dependency that gets the current authenticated user from the TDEI/KeyCloak
170+ access token and fetches permissions from TDEI APIs.
155171
156- Results are cached by token for 1 hour to avoid repeated validation calls.
172+ We validate the JWT's signature and expiry on every request. The expensive
173+ TDEI API and DB lookups are cached for 1 hour and should be evicted when a
174+ user's role changes via evict_user_from_cache().
157175 """
158176 token = credentials .credentials
159177
@@ -168,27 +186,39 @@ async def validate_token(
168186 except Exception :
169187 raise credentials_exception
170188
171- user_id : str | None = payload .get ("sub" )
172- if user_id is None :
189+ user_id_str : str | None = payload .get ("sub" )
190+ if user_id_str is None :
173191 raise credentials_exception
174192
175- # Check cache first
176- if token in _token_cache :
177- logger . info ( "Token validation cache hit" )
178- return _token_cache [ token ]
193+ try :
194+ user_uuid = UUID ( user_id_str )
195+ except ValueError :
196+ raise credentials_exception from None
179197
180- # Cache miss - perform full validation
198+ # Cache keyed by user UUID. If the token rotated (new "jti") since we
199+ # created the cache entry, evict it so we fetch fresh claims:
200+ #
201+ if user_uuid in _user_info_cache :
202+ cached = _user_info_cache [user_uuid ]
203+ current_jti = payload .get ("jti" , "" )
204+ if cached .token_jti == current_jti :
205+ logger .info ("Token validation cache hit" )
206+ return cached
207+ logger .info ("Token validation cache miss: token rotated" )
208+ del _user_info_cache [user_uuid ]
209+
210+ # Cache miss: fetch TDEI roles and DB data:
181211 user_info = await _validate_token_uncached (
182- token , user_id , payload , osm_db_session , task_db_session
212+ token , user_uuid , payload , osm_db_session , task_db_session
183213 )
184- _token_cache [ token ] = user_info
214+ _user_info_cache [ user_uuid ] = user_info
185215
186216 return user_info
187217
188218
189219async def _validate_token_uncached (
190220 token : str ,
191- user_id : str ,
221+ user_uuid : UUID ,
192222 payload : dict ,
193223 osm_db_session : AsyncSession ,
194224 task_db_session : AsyncSession ,
@@ -207,21 +237,17 @@ async def _validate_token_uncached(
207237 }
208238
209239 r = UserInfo ()
210-
211- try :
212- r .user_uuid = UUID (user_id )
213- except ValueError :
214- raise credentials_exception from None
215-
240+ r .user_uuid = user_uuid
216241 r .credentials = token
242+ r .token_jti = payload .get ("jti" , "" )
217243 r .user_name = payload .get ("preferred_username" , "unknown" )
218244
219245 # get user's project groups and roles from TDEI
220246 pgs = []
221247
222248 try :
223249 response = await _tdei_client .get (
224- f"project-group-roles/{ user_id } " ,
250+ f"project-group-roles/{ user_uuid } " ,
225251 headers = headers ,
226252 params = {"page_no" : 1 , "page_size" : 1000 },
227253 )
0 commit comments