Skip to content

Commit d365594

Browse files
authored
Merge pull request #28 from auth0/tokenvault-at-sync
Add token vault subject_token_type access_token to api sdk
2 parents 8f41c09 + 3ac35ae commit d365594

File tree

5 files changed

+501
-9
lines changed

5 files changed

+501
-9
lines changed

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,34 @@ asyncio.run(main())
8585

8686
In this example, the returned dictionary contains the decoded claims (like `sub`, `scope`, etc.) from the verified token.
8787

88+
### 4. Get an access token for a connection
89+
90+
If you need to get an access token for an upstream idp via a connection, you can use the `get_access_token_for_connection` method:
91+
92+
```python
93+
import asyncio
94+
95+
from auth0_api_python import ApiClient, ApiClientOptions
96+
97+
async def main():
98+
api_client = ApiClient(ApiClientOptions(
99+
domain="<AUTH0_DOMAIN>",
100+
audience="<AUTH0_AUDIENCE>",
101+
client_id="<AUTH0_CLIENT_ID>",
102+
client_secret="<AUTH0_CLIENT_SECRET>",
103+
))
104+
connection = "my-connection" # The Auth0 connection to the upstream idp
105+
access_token = "..." # The Auth0 access token to exchange
106+
107+
connection_access_token = await api_client.get_access_token_for_connection({"connection": connection, "access_token": access_token})
108+
# The returned token is the access token for the upstream idp
109+
print(connection_access_token)
110+
111+
asyncio.run(main())
112+
```
113+
114+
More info https://auth0.com/docs/secure/tokens/token-vault
115+
88116
#### Requiring Additional Claims
89117

90118
If your application demands extra claims, specify them with `required_claims`:
@@ -98,7 +126,7 @@ decoded_and_verified_token = await api_client.verify_access_token(
98126

99127
If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`.
100128

101-
### 4. DPoP Authentication
129+
### 5. DPoP Authentication
102130

103131
> [!NOTE]
104132
> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant.

src/auth0_api_python/api_client.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import time
22
from typing import Any, Optional
33

4+
import httpx
45
from authlib.jose import JsonWebKey, JsonWebToken
56

67
from .config import ApiClientOptions
78
from .errors import (
9+
ApiError,
810
BaseAuthError,
11+
GetAccessTokenForConnectionError,
912
InvalidAuthSchemeError,
1013
InvalidDpopProofError,
1114
MissingAuthorizationError,
@@ -390,6 +393,114 @@ async def verify_dpop_proof(
390393

391394
return claims
392395

396+
async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict[str, Any]:
397+
"""
398+
Retrieves a token for a connection.
399+
400+
Args:
401+
options: Options for retrieving an access token for a connection.
402+
Must include 'connection' and 'access_token' keys.
403+
May optionally include 'login_hint'.
404+
405+
Raises:
406+
GetAccessTokenForConnectionError: If there was an issue requesting the access token.
407+
ApiError: If the token exchange endpoint returns an error.
408+
409+
Returns:
410+
Dictionary containing the token response with access_token, expires_in, and scope.
411+
"""
412+
# Constants
413+
SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" # noqa S105
414+
REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "http://auth0.com/oauth/token-type/federated-connection-access-token" # noqa S105
415+
GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" # noqa S105
416+
connection = options.get("connection")
417+
access_token = options.get("access_token")
418+
419+
if not connection:
420+
raise MissingRequiredArgumentError("connection")
421+
422+
if not access_token:
423+
raise MissingRequiredArgumentError("access_token")
424+
425+
client_id = self.options.client_id
426+
client_secret = self.options.client_secret
427+
if not client_id or not client_secret:
428+
raise GetAccessTokenForConnectionError("You must configure the SDK with a client_id and client_secret to use get_access_token_for_connection.")
429+
430+
metadata = await self._discover()
431+
432+
token_endpoint = metadata.get("token_endpoint")
433+
if not token_endpoint:
434+
raise GetAccessTokenForConnectionError("Token endpoint missing in OIDC metadata")
435+
436+
# Prepare parameters
437+
params = {
438+
"connection": connection,
439+
"requested_token_type": REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN,
440+
"grant_type": GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN,
441+
"client_id": client_id,
442+
"subject_token": access_token,
443+
"subject_token_type": SUBJECT_TYPE_ACCESS_TOKEN,
444+
}
445+
446+
# Add login_hint if provided
447+
if "login_hint" in options and options["login_hint"]:
448+
params["login_hint"] = options["login_hint"]
449+
450+
try:
451+
async with httpx.AsyncClient() as client:
452+
response = await client.post(
453+
token_endpoint,
454+
data=params,
455+
auth=(client_id, client_secret)
456+
)
457+
458+
if response.status_code != 200:
459+
error_data = response.json() if "json" in response.headers.get(
460+
"content-type", "").lower() else {}
461+
raise ApiError(
462+
error_data.get("error", "connection_token_error"),
463+
error_data.get(
464+
"error_description", f"Failed to get token for connection: {response.status_code}"),
465+
response.status_code
466+
)
467+
468+
try:
469+
token_endpoint_response = response.json()
470+
except Exception:
471+
raise ApiError("invalid_json", "Token endpoint returned invalid JSON.")
472+
473+
access_token = token_endpoint_response.get("access_token")
474+
if not isinstance(access_token, str) or not access_token:
475+
raise ApiError("invalid_response", "Missing or invalid access_token in response.", 502)
476+
477+
expires_in_raw = token_endpoint_response.get("expires_in", 3600)
478+
try:
479+
expires_in = int(expires_in_raw)
480+
except (TypeError, ValueError):
481+
raise ApiError("invalid_response", "expires_in is not an integer.", 502)
482+
483+
return {
484+
"access_token": access_token,
485+
"expires_at": int(time.time()) + expires_in,
486+
"scope": token_endpoint_response.get("scope", "")
487+
}
488+
489+
except httpx.TimeoutException as exc:
490+
raise ApiError(
491+
"timeout_error",
492+
f"Request to token endpoint timed out: {str(exc)}",
493+
504,
494+
exc
495+
)
496+
except httpx.HTTPError as exc:
497+
raise ApiError(
498+
"network_error",
499+
f"Network error occurred: {str(exc)}",
500+
502,
501+
exc
502+
)
503+
393504
# ===== Private Methods =====
394505

395506
async def _discover(self) -> dict[str, Any]:

src/auth0_api_python/config.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,20 @@ class ApiClientOptions:
1717
dpop_required: Whether DPoP is required (default: False, allows both Bearer and DPoP).
1818
dpop_iat_leeway: Leeway in seconds for DPoP proof iat claim (default: 30).
1919
dpop_iat_offset: Maximum age in seconds for DPoP proof iat claim (default: 300).
20+
client_id: Optional required if you want to use get_access_token_for_connection.
21+
client_secret: Optional required if you want to use get_access_token_for_connection.
2022
"""
2123
def __init__(
22-
self,
23-
domain: str,
24-
audience: str,
25-
custom_fetch: Optional[Callable[..., object]] = None,
26-
dpop_enabled: bool = True,
27-
dpop_required: bool = False,
28-
dpop_iat_leeway: int = 30,
29-
dpop_iat_offset: int = 300,
24+
self,
25+
domain: str,
26+
audience: str,
27+
custom_fetch: Optional[Callable[..., object]] = None,
28+
dpop_enabled: bool = True,
29+
dpop_required: bool = False,
30+
dpop_iat_leeway: int = 30,
31+
dpop_iat_offset: int = 300,
32+
client_id: Optional[str] = None,
33+
client_secret: Optional[str] = None,
3034
):
3135
self.domain = domain
3236
self.audience = audience
@@ -35,3 +39,5 @@ def __init__(
3539
self.dpop_required = dpop_required
3640
self.dpop_iat_leeway = dpop_iat_leeway
3741
self.dpop_iat_offset = dpop_iat_offset
42+
self.client_id = client_id
43+
self.client_secret = client_secret

src/auth0_api_python/errors.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,39 @@ def get_status_code(self) -> int:
9494

9595
def get_error_code(self) -> str:
9696
return "invalid_request"
97+
98+
99+
class GetAccessTokenForConnectionError(BaseAuthError):
100+
"""Error raised when getting a token for a connection fails."""
101+
102+
def get_status_code(self) -> int:
103+
return 400
104+
105+
def get_error_code(self) -> str:
106+
return "get_access_token_for_connection_error"
107+
108+
109+
class ApiError(BaseAuthError):
110+
"""
111+
Error raised when an API request to Auth0 fails.
112+
Contains details about the original error from Auth0.
113+
"""
114+
115+
def __init__(self, code: str, message: str, status_code=500, cause=None):
116+
super().__init__(message)
117+
self.code = code
118+
self.status_code = status_code
119+
self.cause = cause
120+
121+
if cause:
122+
self.error = getattr(cause, "error", None)
123+
self.error_description = getattr(cause, "error_description", None)
124+
else:
125+
self.error = None
126+
self.error_description = None
127+
128+
def get_status_code(self) -> int:
129+
return self.status_code
130+
131+
def get_error_code(self) -> str:
132+
return self.code

0 commit comments

Comments
 (0)