11import json
22import uuid
33import os
4+ import time
5+ import base64
46import logging
57
6- from typing import List , Dict
8+ from typing import List , Dict , Optional
79
810from langchain_core .messages import ToolMessage , SystemMessage , RemoveMessage , AIMessage , trim_messages
911from langchain_core .runnables import RunnableConfig
@@ -507,14 +509,70 @@ def _find_tool_call_for_message(messages: List, tool_call_id: str) -> Dict | Non
507509 return None
508510
509511
512+ def _jwt_exp (jwt : str ) -> Optional [int ]:
513+ """Decode the `exp` claim from a JWT (Unix seconds). Returns None on any failure.
514+ Used to cache a Consent-JWT for its real lifetime instead of a fixed safety TTL."""
515+ try :
516+ parts = jwt .split ("." )
517+ if len (parts ) < 2 :
518+ return None
519+ payload_b64 = parts [1 ] + "=" * (- len (parts [1 ]) % 4 )
520+ payload = json .loads (base64 .urlsafe_b64decode (payload_b64 ))
521+ exp = payload .get ("exp" )
522+ return int (exp ) if exp is not None else None
523+ except Exception :
524+ return None
525+
526+
527+ def _consent_cache_key (required_roles : list , bank_id : Optional [str ]) -> str :
528+ """Stable, order-independent cache key from the SET of required entitlements.
529+
530+ OBP consents are scoped to roles/entitlements, not operations — so two different
531+ operations needing the same role(s) can share one Consent-JWT. Keying by the set
532+ of `(role, bank?)` pairs lets the cache reuse the JWT across distinct operations.
533+ Bank scope is attached only to roles that declare `requires_bank_id`; system-level
534+ roles ignore the per-call bank.
535+ """
536+ if not required_roles :
537+ return f"@{ bank_id or '' } "
538+ parts = sorted ({
539+ f"{ r .get ('role' , '' )} ::{ (bank_id or '' ) if r .get ('requires_bank_id' ) else '-' } "
540+ for r in required_roles
541+ if r .get ("role" )
542+ })
543+ return "," .join (parts ) if parts else f"@{ bank_id or '' } "
544+
545+
546+ def _consent_retry_ok (result ) -> bool :
547+ """Best-effort check that a consent retry actually succeeded — i.e. the Consent-JWT
548+ was accepted. Returns False on a fresh consent_required or a 401/403, which means a
549+ (cached) JWT no longer works and should be evicted."""
550+ try :
551+ parsed = json .loads (result ) if isinstance (result , str ) else result
552+ except Exception :
553+ return True # non-JSON output — assume the call went through
554+ if not isinstance (parsed , dict ):
555+ return True
556+ if parsed .get ("error" ) == "consent_required" :
557+ return False
558+ status_code = parsed .get ("status_code" )
559+ if isinstance (status_code , int ) and status_code in (401 , 403 ):
560+ return False
561+ return True
562+
563+
510564async def _retry_tool_with_consent (
511565 tool_call_id : str ,
512566 error_msg_id : str ,
513567 original_tc : Dict ,
514568 consent_jwt : str ,
515569 tools_by_name : Dict ,
516- ) -> ToolMessage :
517- """Retry a single tool call with an injected Consent-JWT. Returns a replacement ToolMessage."""
570+ ) -> tuple [ToolMessage , bool ]:
571+ """Retry a single tool call with an injected Consent-JWT.
572+
573+ Returns (replacement ToolMessage, ok) — ok is False if the retry still looks like
574+ an auth/consent failure, so the caller can evict a stale cached JWT.
575+ """
518576 tool_name = original_tc ["name" ]
519577 tool_fn = tools_by_name .get (tool_name )
520578 if not tool_fn :
@@ -524,7 +582,7 @@ async def _retry_tool_with_consent(
524582 tool_call_id = tool_call_id ,
525583 id = error_msg_id ,
526584 status = "error" ,
527- )
585+ ), False
528586
529587 original_args = dict (original_tc .get ("args" , {}))
530588 existing_headers = original_args .get ("headers" , {}) or {}
@@ -538,15 +596,15 @@ async def _retry_tool_with_consent(
538596 tool_call_id = tool_call_id ,
539597 id = error_msg_id ,
540598 status = "success" ,
541- )
599+ ), _consent_retry_ok ( result )
542600 except Exception as e :
543601 logger .error (f"🔐 CONSENT_FLOW: Consent retry failed for '{ tool_name } ': { e } " , exc_info = True )
544602 return ToolMessage (
545603 content = f"Consent retry failed: { str (e )} " ,
546604 tool_call_id = tool_call_id ,
547605 id = error_msg_id ,
548606 status = "error" ,
549- )
607+ ), False
550608
551609
552610async def consent_check_node (state : OpeyGraphState , config : RunnableConfig ):
@@ -627,38 +685,84 @@ async def consent_check_node(state: OpeyGraphState, config: RunnableConfig):
627685 configurable = config .get ("configurable" , {}) if config else {}
628686 tools_by_name = configurable .get ("tools_by_name" , {})
629687
688+ # Consent-JWT cache, keyed by "operation_id::bank_id". Reusing a still-valid JWT
689+ # lets a repeated operation skip the consent prompt entirely.
690+ consent_cache : Dict [str , dict ] = dict (state .get ("consent_jwts" ) or {})
691+ now = time .time ()
692+ # Fallback safety TTL — only used if the JWT's own `exp` claim can't be decoded.
693+ # Real consent lifetime comes from the JWT itself, so this is purely a guard rail.
694+ CONSENT_JWT_FALLBACK_TTL_SECONDS = 3300
695+
630696 all_replacements = []
631697
632698 for op_id , group in groups .items ():
633699 first = group [0 ]
634700 required_roles = first ["consent_info" ].get ("required_roles" , [])
701+ bank_id = first ["consent_info" ].get ("bank_id" )
635702 tool_call_ids = [ce ["error_msg" ].tool_call_id for ce in group ]
636703
637- # Build the interrupt payload — one per distinct operation
638- consent_payload = {
639- "consent_type" : "consent_required" ,
640- # Primary fields (used by stream_manager to emit ConsentRequestEvent)
641- "tool_call_id" : first ["error_msg" ].tool_call_id ,
642- "tool_name" : first ["original_tc" ]["name" ],
643- "operation_id" : op_id if op_id != "__unknown__" else None ,
644- "required_roles" : required_roles ,
645- "bank_id" : first ["consent_info" ].get ("bank_id" ),
646- # Batch context so the frontend can show "N tool calls need this consent"
647- "tool_call_count" : len (group ),
648- "tool_call_ids" : tool_call_ids ,
649- }
704+ # Only cache real operations — an unknown operation_id can't be keyed safely.
705+ # Key by the SET of required entitlements (not operation_id) so two different
706+ # operations that need the same role(s) reuse one consent. Skip caching only if
707+ # there are no roles AND no bank scope — that key would be too generic.
708+ cache_key = _consent_cache_key (required_roles , bank_id )
709+ if cache_key == "@" :
710+ cache_key = None
711+
712+ # Reuse a cached, still-valid Consent-JWT for this operation if we have one.
713+ consent_jwt = None
714+ used_cached_jwt = False
715+ if cache_key :
716+ cached = consent_cache .get (cache_key )
717+ if cached :
718+ expires_at = cached .get ("expires_at" )
719+ cache_valid = False
720+ if expires_at :
721+ # Use the JWT's real expiry (decoded `exp` claim) with a 60s margin.
722+ cache_valid = expires_at > now + 60
723+ else :
724+ # JWT exp unknown — fall back to a conservative safety TTL.
725+ cache_valid = (now - cached .get ("created_at" , 0 )) < CONSENT_JWT_FALLBACK_TTL_SECONDS
726+ if cache_valid :
727+ consent_jwt = cached .get ("jwt" )
728+ used_cached_jwt = True
729+ logger .info (
730+ f"🔐 CONSENT_FLOW: Reusing cached Consent-JWT for operation '{ op_id } ' "
731+ f"(cache_key={ cache_key } ) — skipping consent prompt"
732+ )
650733
651- logger .info (
652- f"🔐 CONSENT_FLOW: Interrupting for operation '{ op_id } ' "
653- f"({ len (group )} tool call(s): { tool_call_ids } )"
654- )
655- user_response = interrupt (consent_payload )
656- logger .info (
657- f"🔐 CONSENT_FLOW: Resumed for operation '{ op_id } ', "
658- f"response keys: { list (user_response .keys ()) if isinstance (user_response , dict ) else type (user_response )} "
659- )
734+ # No usable cached JWT → prompt the user (interrupt → consent card → resume).
735+ if not consent_jwt :
736+ consent_payload = {
737+ "consent_type" : "consent_required" ,
738+ # Primary fields (used by stream_manager to emit ConsentRequestEvent)
739+ "tool_call_id" : first ["error_msg" ].tool_call_id ,
740+ "tool_name" : first ["original_tc" ]["name" ],
741+ "operation_id" : op_id if op_id != "__unknown__" else None ,
742+ "required_roles" : required_roles ,
743+ "bank_id" : bank_id ,
744+ # Batch context so the frontend can show "N tool calls need this consent"
745+ "tool_call_count" : len (group ),
746+ "tool_call_ids" : tool_call_ids ,
747+ }
660748
661- consent_jwt = user_response .get ("consent_jwt" )
749+ logger .info (
750+ f"🔐 CONSENT_FLOW: Interrupting for operation '{ op_id } ' "
751+ f"({ len (group )} tool call(s): { tool_call_ids } )"
752+ )
753+ user_response = interrupt (consent_payload )
754+ logger .info (
755+ f"🔐 CONSENT_FLOW: Resumed for operation '{ op_id } ', "
756+ f"response keys: { list (user_response .keys ()) if isinstance (user_response , dict ) else type (user_response )} "
757+ )
758+
759+ consent_jwt = user_response .get ("consent_jwt" )
760+ if consent_jwt and cache_key :
761+ consent_cache [cache_key ] = {
762+ "jwt" : consent_jwt ,
763+ "created_at" : now ,
764+ "expires_at" : _jwt_exp (consent_jwt ),
765+ }
662766
663767 if not consent_jwt :
664768 logger .warning (f"🔐 CONSENT_FLOW: Consent denied for operation '{ op_id } ' — denying { len (group )} tool call(s)" )
@@ -672,18 +776,29 @@ async def consent_check_node(state: OpeyGraphState, config: RunnableConfig):
672776 else :
673777 jwt_preview = consent_jwt [:50 ] + "..." if len (consent_jwt ) > 50 else consent_jwt
674778 logger .info (
675- f"🔐 CONSENT_FLOW: Consent approved for operation ' { op_id } ' (JWT preview: { jwt_preview } ) "
676- f"— retrying { len (group )} tool call(s)"
779+ f"🔐 CONSENT_FLOW: Consent { '(cached) ' if used_cached_jwt else '' } available for operation "
780+ f"' { op_id } ' (JWT preview: { jwt_preview } ) — retrying { len (group )} tool call(s)"
677781 )
782+ op_retry_failed = False
678783 for ce in group :
679- replacement = await _retry_tool_with_consent (
784+ replacement , ok = await _retry_tool_with_consent (
680785 tool_call_id = ce ["error_msg" ].tool_call_id ,
681786 error_msg_id = ce ["error_msg" ].id ,
682787 original_tc = ce ["original_tc" ],
683788 consent_jwt = consent_jwt ,
684789 tools_by_name = tools_by_name ,
685790 )
686791 all_replacements .append (replacement )
792+ if not ok :
793+ op_retry_failed = True
794+
795+ # A cached JWT that no longer works (expired/revoked server-side) must be
796+ # evicted so the next attempt re-prompts instead of looping on a dead JWT.
797+ if used_cached_jwt and op_retry_failed and cache_key :
798+ logger .warning (
799+ f"🔐 CONSENT_FLOW: Cached Consent-JWT for '{ op_id } ' failed on retry — evicting from cache"
800+ )
801+ consent_cache .pop (cache_key , None )
687802
688- return {"messages" : all_replacements }
803+ return {"messages" : all_replacements , "consent_jwts" : consent_cache }
689804
0 commit comments