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 [str , "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 : str ) -> 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
@@ -172,16 +190,23 @@ async def validate_token(
172190 if user_id 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 ]
179-
180- # Cache miss - perform full validation
193+ # Cache keyed by user ID. If the token rotated (new "jti") since we created
194+ # the cache entry, evict it so we fetch fresh claims:
195+ #
196+ if user_id in _user_info_cache :
197+ cached = _user_info_cache [user_id ]
198+ current_jti = payload .get ("jti" , "" )
199+ if cached .token_jti == current_jti :
200+ logger .info ("Token validation cache hit" )
201+ return cached
202+ logger .info ("Token validation cache miss: token rotated" )
203+ del _user_info_cache [user_id ]
204+
205+ # Cache miss: fetch TDEI roles and DB data:
181206 user_info = await _validate_token_uncached (
182207 token , user_id , payload , osm_db_session , task_db_session
183208 )
184- _token_cache [ token ] = user_info
209+ _user_info_cache [ user_id ] = user_info
185210
186211 return user_info
187212
@@ -214,6 +239,7 @@ async def _validate_token_uncached(
214239 raise credentials_exception from None
215240
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
0 commit comments