Skip to content

Commit 04240b2

Browse files
committed
feat: federated connections access tokens, params update
1 parent 2314679 commit 04240b2

3 files changed

Lines changed: 59 additions & 56 deletions

File tree

packages/auth0-ai/auth0_ai/authorizers/federated_connection_authorizer.py

Lines changed: 54 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import asyncio
21
import contextvars
32
import hashlib
43
import inspect
@@ -16,6 +15,11 @@
1615
from auth0_ai.stores import Store, SubStore, InMemoryStore
1716
from 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+
1923
class 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

packages/auth0-ai/poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/auth0-ai/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ readme = "README.md"
1010
[tool.poetry.dependencies]
1111
python = "^3.11"
1212
openfga-sdk = "^0.9.5"
13-
auth0-python = "^4.10.0"
13+
auth0-python = "^4.12.0"
1414

1515
[tool.poetry.group.dev.dependencies]
1616
twine = "^6.1.0"

0 commit comments

Comments
 (0)