3030from nat .data_models .config import Config
3131from nat .front_ends .fastapi .auth_flow_handlers .websocket_flow_handler import WebSocketAuthenticationFlowHandler
3232from nat .front_ends .fastapi .fastapi_front_end_plugin_worker import FastApiFrontEndPluginWorker
33+ from nat .object_store .in_memory_object_store import InMemoryObjectStore
3334from nat .test .functions import EchoFunctionConfig
3435
3536
@@ -396,11 +397,11 @@ def test_token_cache_key_format(noop_handler, minimal_oauth_config):
396397 assert key == f"sess-1:{ minimal_oauth_config .client_id } :{ minimal_oauth_config .token_url } "
397398
398399
399- def test_get_cached_token_miss (noop_handler , minimal_oauth_config ):
400+ async def test_get_cached_token_miss (noop_handler , minimal_oauth_config ):
400401 """_get_cached_token returns None when the cache has no entry for the config."""
401402 noop_handler ._token_store = {}
402403 noop_handler ._session_id = "sess-1"
403- assert noop_handler ._get_cached_token (minimal_oauth_config ) is None
404+ assert await noop_handler ._get_cached_token (minimal_oauth_config ) is None
404405
405406
406407@pytest .mark .parametrize ("expires_at,expect_hit" ,
@@ -410,37 +411,135 @@ def test_get_cached_token_miss(noop_handler, minimal_oauth_config):
410411 pytest .param (time .time () - 1 , False , id = "past" ),
411412 pytest .param (time .time () + 30 , False , id = "within_buffer" ),
412413 ])
413- def test_get_cached_token_expiry (noop_handler , minimal_oauth_config , expires_at , expect_hit ):
414+ async def test_get_cached_token_expiry (noop_handler , minimal_oauth_config , expires_at , expect_hit ):
414415 """_get_cached_token returns the context when valid and evicts it when expired or within the 60s buffer."""
415416 ctx = AuthenticatedContext (headers = {"Authorization" : "Bearer tok" }, metadata = {})
416417 store : dict = {}
417418 noop_handler ._token_store = store
418419 noop_handler ._session_id = "sess-1"
419420 key = noop_handler ._token_cache_key (minimal_oauth_config )
420421 store [key ] = (ctx , expires_at )
421- result = noop_handler ._get_cached_token (minimal_oauth_config )
422+ result = await noop_handler ._get_cached_token (minimal_oauth_config )
422423 if expect_hit :
423424 assert result is ctx
424425 else :
425426 assert result is None
426427 assert key not in store
427428
428429
429- def test_store_token_writes_correctly (noop_handler , minimal_oauth_config ):
430+ async def test_store_token_writes_correctly (noop_handler , minimal_oauth_config ):
430431 """_store_token writes (ctx, expires_at) to the store under the expected key."""
431432 store : dict = {}
432433 noop_handler ._token_store = store
433434 noop_handler ._session_id = "sess-1"
434435 expires = 9999999999.0
435436 ctx = AuthenticatedContext (headers = {"Authorization" : "Bearer tok" }, metadata = {"expires_at" : expires })
436- noop_handler ._store_token (minimal_oauth_config , ctx )
437+ await noop_handler ._store_token (minimal_oauth_config , ctx )
437438 key = noop_handler ._token_cache_key (minimal_oauth_config )
438439 assert key in store
439440 stored_ctx , stored_expires = store [key ]
440441 assert stored_ctx is ctx
441442 assert stored_expires == expires
442443
443444
445+ # --------------------------------------------------------------------------- #
446+ # ObjectStore-backed token cache tests #
447+ # --------------------------------------------------------------------------- #
448+
449+
450+ async def test_get_cached_token_miss_object_store (noop_handler , minimal_oauth_config ):
451+ """_get_cached_token returns None when the ObjectStore has no entry for the config."""
452+ noop_handler ._token_store = InMemoryObjectStore ()
453+ noop_handler ._session_id = "sess-1"
454+ assert await noop_handler ._get_cached_token (minimal_oauth_config ) is None
455+
456+
457+ async def test_store_and_retrieve_token_object_store (noop_handler , minimal_oauth_config ):
458+ """_store_token persists to an ObjectStore and _get_cached_token retrieves it."""
459+ noop_handler ._token_store = InMemoryObjectStore ()
460+ noop_handler ._session_id = "sess-1"
461+ expires = time .time () + 3600
462+ ctx = AuthenticatedContext (
463+ headers = {"Authorization" : "Bearer obj-tok" },
464+ metadata = {
465+ "expires_at" : expires , "raw_token" : {
466+ "access_token" : "obj-tok"
467+ }
468+ },
469+ )
470+ await noop_handler ._store_token (minimal_oauth_config , ctx )
471+ result = await noop_handler ._get_cached_token (minimal_oauth_config )
472+ assert result is not None
473+ assert result .headers ["Authorization" ] == "Bearer obj-tok"
474+
475+
476+ async def test_get_cached_token_evicts_expired_object_store (noop_handler , minimal_oauth_config ):
477+ """_get_cached_token evicts and returns None for an expired entry in an ObjectStore."""
478+ noop_handler ._token_store = InMemoryObjectStore ()
479+ noop_handler ._session_id = "sess-1"
480+ expired_ctx = AuthenticatedContext (
481+ headers = {"Authorization" : "Bearer old" },
482+ metadata = {"expires_at" : time .time () - 1 },
483+ )
484+ await noop_handler ._store_token (minimal_oauth_config , expired_ctx )
485+ result = await noop_handler ._get_cached_token (minimal_oauth_config )
486+ assert result is None
487+
488+
489+ @pytest .mark .usefixtures ("set_nat_config_file_env_var" )
490+ async def test_second_authenticate_uses_object_store_cache (monkeypatch , mock_server ):
491+ """After a successful flow the token is cached in an ObjectStore; a second call must not trigger OAuth again."""
492+
493+ redirect_port = _free_port ()
494+ mock_server .register_client (
495+ client_id = "cid" ,
496+ client_secret = "secret" ,
497+ redirect_base = f"http://localhost:{ redirect_port } " ,
498+ )
499+
500+ cfg_nat = Config (workflow = EchoFunctionConfig ())
501+ worker = FastApiFrontEndPluginWorker (cfg_nat )
502+ message_count = [0 ]
503+
504+ class _DummyWSHandler :
505+
506+ def set_flow_handler (self , _ ):
507+ return
508+
509+ async def create_websocket_message (self , msg ):
510+ message_count [0 ] += 1
511+ await _complete_oauth_redirect (msg .text , mock_server , worker ._outstanding_flows )
512+
513+ object_store = InMemoryObjectStore ()
514+ ws_handler = _AuthHandler (
515+ oauth_server = mock_server ,
516+ add_flow_cb = worker ._add_flow ,
517+ remove_flow_cb = worker ._remove_flow ,
518+ web_socket_message_handler = _DummyWSHandler (),
519+ token_store = object_store ,
520+ session_id = "test-session" ,
521+ )
522+
523+ cfg_flow = OAuth2AuthCodeFlowProviderConfig (
524+ client_id = "cid" ,
525+ client_secret = "secret" ,
526+ authorization_url = "http://testserver/oauth/authorize" ,
527+ token_url = "http://testserver/oauth/token" ,
528+ scopes = ["read" ],
529+ use_pkce = True ,
530+ redirect_uri = f"http://localhost:{ redirect_port } /auth/redirect" ,
531+ )
532+
533+ monkeypatch .setattr ("click.echo" , lambda * _ : None , raising = True )
534+
535+ ctx1 = await ws_handler .authenticate (cfg_flow , AuthFlowType .OAUTH2_AUTHORIZATION_CODE )
536+ assert message_count [0 ] == 1 , "OAuth flow should have run exactly once"
537+
538+ ctx2 = await ws_handler .authenticate (cfg_flow , AuthFlowType .OAUTH2_AUTHORIZATION_CODE )
539+ assert message_count [0 ] == 1 , "Second authenticate() must return from object store cache without triggering OAuth"
540+ assert ctx2 .headers ["Authorization" ] == ctx1 .headers ["Authorization" ]
541+
542+
444543# --------------------------------------------------------------------------- #
445544# Token-cache integration: second authenticate() returns from cache #
446545# --------------------------------------------------------------------------- #
0 commit comments