1- import asyncio
21import contextvars
32import hashlib
43import inspect
1615from auth0_ai .stores import Store , SubStore , InMemoryStore
1716from auth0_ai .utils import omit
1817
18+ # Subject / requested token type constants
19+ SUBJECT_TYPE_REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_token"
20+ SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"
21+ REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "http://auth0.com/oauth/token-type/federated-connection-access-token"
22+
1923class AsyncStorageValue (TypedDict ):
2024 context : Any
2125 connection : str
@@ -65,16 +69,12 @@ def __init__(
6569 Callable [ToolInput , Union [str | None , Awaitable [str | None ]]],
6670 str | None ,
6771 ]] = None ,
68- subject_access_token : Optional [Union [
72+ access_token : Optional [Union [
6973 AuthorizerToolParameter [ToolInput , str | None ],
7074 Callable [ToolInput , Union [str | None , Awaitable [str | None ]]],
7175 str | None ,
7276 ]] = None ,
73- access_token : Optional [Union [
74- AuthorizerToolParameter [ToolInput , TokenResponse | None ],
75- Callable [ToolInput , Union [TokenResponse | None , Awaitable [TokenResponse | None ]]],
76- TokenResponse | None
77- ]] = None ,
77+ login_hint : Optional [str ] = None ,
7878 store : Optional [Store ] = None ,
7979 credentials_context : Optional [AuthContext ] = "thread"
8080 ):
@@ -87,31 +87,26 @@ def __init__(
8787 refresh_token: Optional. The Auth0 refresh token to exchange for a federated connection access token. Can be:
8888 - A string or None
8989 - A callable that receives the tool input and returns the user refresh token (sync or async)
90- subject_access_token: Optional. The Auth0 *access token* (for the logged-in user) to exchange
91- for a federated connection access token via Token Vault. Can be:
90+ access_token: Optional. The Auth0 user access token (subject token) to exchange instead of a refresh token. Can be:
9291 - A string or None
9392 - A callable that receives the tool input and returns the user access token (sync or async)
94- access_token: Optional. The *federated connection* access token if available in the tool context. Can be:
95- - A `TokenResponse`
96- - A callable that receives the tool input and returns a `TokenResponse` (sync or async)
97- store: Optional. An store used to temporarly store the authorization response data while the user is completing the authorization in another device (default: InMemoryStore).
93+ login_hint: Optional string hint (e.g. subject/sub) to direct which linked account to use when multiple exist.
94+ store: Optional. A store used to temporarily store the authorization response data while the user completes authorization on another device (default: InMemoryStore).
9895 credentials_context: Optional. Defines the scope of credential sharing. Can be:
9996 - "thread" (default): Credentials are shared across all tools using the same authorizer within the current thread.
10097 - "agent": Credentials are shared globally across all threads and tools in the agent.
10198 - "tool": Credentials are shared across multiple calls to the same tool within the same thread.
10299 - "tool-call": Credentials are valid only for a single invocation of the tool.
103100 """
104-
105101 def wrap (val , result_type ):
106102 if isinstance (val , AuthorizerToolParameter ):
107103 return val
108104 return AuthorizerToolParameter [ToolInput , result_type ](val )
109-
110105 self .scopes = scopes
111106 self .connection = connection
112107 self .refresh_token = wrap (refresh_token , str | None )
113- self .subject_access_token = wrap (subject_access_token , str | None )
114- self .access_token = wrap ( access_token , TokenResponse | None )
108+ self .access_token = wrap (access_token , str | None )
109+ self .login_hint = login_hint
115110 self .store = store
116111 self .credentials_context = credentials_context
117112
@@ -139,22 +134,27 @@ def __init__(
139134
140135 # TODO: consider moving this to Auth0AI classes
141136 sub_store = SubStore (params .store or InMemoryStore ()).create_sub_store ("AUTH0_AI_FEDERATED_CONNECTION" )
142-
143137 instance_id = self ._get_instance_id ()
144-
138+
145139 self .credentials_store = SubStore [TokenResponse ](sub_store , {
146140 "base_namespace" : [instance_id , "credentials" ],
147141 "get_ttl" : lambda credential : credential ["expires_in" ] * 1000 if "expires_in" in credential else None
148142 })
149143
150- provided = sum ([
151- 1 if params .refresh_token .value is not None else 0 ,
152- 1 if params .subject_access_token .value is not None else 0 ,
153- 1 if params .access_token .value is not None else 0 ,
154- ])
155- if provided != 1 :
144+ has_refresh = params .refresh_token .value is not None
145+ has_access = params .access_token .value is not None
146+
147+ # Normalize empty strings to None before validation
148+ if has_refresh and isinstance (params .refresh_token .value , str ) and params .refresh_token .value .strip () == "" :
149+ params .refresh_token .value = None
150+ has_refresh = False
151+ if has_access and isinstance (params .access_token .value , str ) and params .access_token .value .strip () == "" :
152+ params .access_token .value = None
153+ has_access = False
154+
155+ if (has_refresh and has_access ) or (not has_refresh and not has_access ):
156156 raise ValueError (
157- "Exactly one of refresh_token, subject_access_token, or access_token must be provided to initialize the Authorizer."
157+ "Exactly one of refresh_token or access_token must be provided to initialize the Authorizer."
158158 )
159159
160160 def _handle_authorization_interrupts (self , err : Auth0Interrupt ) -> None :
@@ -163,7 +163,7 @@ def _handle_authorization_interrupts(self, err: Auth0Interrupt) -> None:
163163 def _get_instance_id (self ) -> str :
164164 props = {
165165 "auth0" : omit (self .auth0 , ["client_secret" , "client_assertion_signing_key" ]),
166- "params" : omit (self .params , ["store" , "refresh_token" , "subject_access_token " , "access_token " ])
166+ "params" : omit (self .params , ["store" , "refresh_token" , "access_token " , "login_hint " ])
167167 }
168168 sh = json .dumps (props , sort_keys = True , separators = ("," , ":" ))
169169 return hashlib .md5 (sh .encode ("utf-8" )).hexdigest ()
@@ -186,38 +186,44 @@ def validate_token(self, token_response: Optional[TokenResponse] = None):
186186 _update_local_storage ({"current_scopes" : current_scopes })
187187
188188 if missing_scopes :
189+ granted_union = sorted (set (current_scopes ) | set (scopes ))
189190 raise FederatedConnectionInterrupt (
190191 f"Authorization required to access the Federated Connection API: { connection } . Missing scopes: { ', ' .join (missing_scopes )} " ,
191192 connection ,
192193 scopes ,
193- current_scopes + scopes
194+ granted_union
194195 )
195196
196197 async def get_access_token_impl (self , * args : ToolInput .args , ** kwargs : ToolInput .kwargs ) -> TokenResponse | None :
197198 store = _get_local_storage ()
198-
199199 connection = store ["connection" ]
200- subject_token_type : str | None = None
201- subject_token : str | None = None
202200
203- if self .params .refresh_token .value is not None :
201+ refresh_supplied = self .params .refresh_token .value is not None
202+ access_supplied = self .params .access_token .value is not None
203+
204+ subject_token_type : str
205+ if refresh_supplied :
206+ subject_token_type = SUBJECT_TYPE_REFRESH_TOKEN
204207 subject_token = await self .get_refresh_token (* args , ** kwargs )
205- subject_token_type = "urn:ietf:params:oauth:token-type:refresh_token"
206208 else :
207- subject_token = await self . get_subject_access_token ( * args , ** kwargs )
208- subject_token_type = "urn:ietf:params:oauth:token-type:access_token"
209+ subject_token_type = SUBJECT_TYPE_ACCESS_TOKEN
210+ subject_token = await self . get_user_access_token ( * args , ** kwargs )
209211
210212 if not subject_token :
211213 return None
212214
215+ # login_hint optionally applied to both refresh and access token exchange paths
216+ login_hint = self .params .login_hint
213217 try :
214- response = self . get_token . access_token_for_connection (
218+ request_kwargs = dict (
215219 subject_token_type = subject_token_type ,
216220 subject_token = subject_token ,
217- requested_token_type = "http://auth0.com/oauth/token-type/federated-connection-access-token" ,
221+ requested_token_type = REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN ,
218222 connection = connection ,
219223 )
220-
224+ if login_hint :
225+ request_kwargs ["login_hint" ] = login_hint
226+ response = self .get_token .access_token_for_connection (** request_kwargs )
221227 return TokenResponse (
222228 access_token = response ["access_token" ],
223229 expires_in = response ["expires_in" ],
@@ -229,20 +235,17 @@ async def get_access_token_impl(self, *args: ToolInput.args, **kwargs: ToolInput
229235 except Auth0Error as err :
230236 raise FederatedConnectionError (err .message ) if 400 <= err .status_code <= 499 else err
231237
232- async def get_access_token (self , * args : ToolInput .args , ** kwargs : ToolInput .kwargs ) -> TokenResponse | None :
233- if self .params .refresh_token .value is not None or self .params .subject_access_token .value is not None :
234- token_response = await self .get_access_token_impl (* args , ** kwargs )
235- else :
236- token_response = await self .params .access_token .resolve (* args , ** kwargs )
237-
238- self .validate_token (token_response )
239- return token_response
240-
241238 async def get_refresh_token (self , * args : ToolInput .args , ** kwargs : ToolInput .kwargs ):
242- return await self .params .refresh_token .resolve (* args , ** kwargs )
239+ token = await self .params .refresh_token .resolve (* args , ** kwargs )
240+ if token is not None and isinstance (token , str ) and token .strip () == "" :
241+ return None
242+ return token
243243
244- async def get_subject_access_token (self , * args : ToolInput .args , ** kwargs : ToolInput .kwargs ):
245- return await self .params .subject_access_token .resolve (* args , ** kwargs )
244+ async def get_user_access_token (self , * args : ToolInput .args , ** kwargs : ToolInput .kwargs ):
245+ token = await self .params .access_token .resolve (* args , ** kwargs )
246+ if token is not None and isinstance (token , str ) and token .strip () == "" :
247+ return None
248+ return token
246249
247250 def protect (
248251 self ,
@@ -286,4 +289,4 @@ async def wrapped_execute(*args: ToolInput.args, **kwargs: ToolInput.kwargs):
286289 self .credentials_store .delete (credentials_ns , "credential" )
287290 return self ._handle_authorization_interrupts (err )
288291
289- return wrapped_execute
292+ return wrapped_execute
0 commit comments