auth0-server-python 1.0.0b5__tar.gz → 1.0.0b7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/PKG-INFO +6 -2
  2. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/README.md +5 -2
  3. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/pyproject.toml +2 -2
  4. auth0_server_python-1.0.0b7/src/auth0_server_python/auth_schemes/__init__.py +3 -0
  5. auth0_server_python-1.0.0b7/src/auth0_server_python/auth_schemes/bearer_auth.py +10 -0
  6. auth0_server_python-1.0.0b7/src/auth0_server_python/auth_server/__init__.py +4 -0
  7. auth0_server_python-1.0.0b7/src/auth0_server_python/auth_server/my_account_client.py +94 -0
  8. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/src/auth0_server_python/auth_server/server_client.py +230 -19
  9. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/src/auth0_server_python/auth_types/__init__.py +42 -0
  10. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/src/auth0_server_python/error/__init__.py +21 -0
  11. auth0_server_python-1.0.0b7/src/auth0_server_python/tests/test_my_account_client.py +160 -0
  12. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/src/auth0_server_python/tests/test_server_client.py +685 -5
  13. auth0_server_python-1.0.0b5/src/auth0_server_python/auth_server/__init__.py +0 -3
  14. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/LICENSE +0 -0
  15. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/src/auth0_server_python/encryption/__init__.py +0 -0
  16. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/src/auth0_server_python/encryption/encrypt.py +0 -0
  17. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/src/auth0_server_python/store/__init__.py +0 -0
  18. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/src/auth0_server_python/store/abstract.py +0 -0
  19. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/src/auth0_server_python/utils/__init__.py +0 -0
  20. {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b7}/src/auth0_server_python/utils/helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth0-server-python
3
- Version: 1.0.0b5
3
+ Version: 1.0.0b7
4
4
  Summary: Auth0 server-side Python SDK
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -25,7 +25,10 @@ Description-Content-Type: text/markdown
25
25
 
26
26
  The Auth0 Server Python SDK is a library for implementing user authentication in Python applications.
27
27
 
28
- ![PyPI](https://img.shields.io/pypi/v/auth0-server-python) ![Downloads](https://img.shields.io/pypi/dw/auth0-server-python) [![License](https://img.shields.io/:license-MIT-blue.svg?style=flat)](https://opensource.org/licenses/MIT)
28
+ ![PyPI](https://img.shields.io/pypi/v/auth0-server-python)
29
+ ![Downloads](https://img.shields.io/pypi/dw/auth0-server-python)
30
+ [![License](https://img.shields.io/:license-MIT-blue.svg?style=flat)](https://opensource.org/licenses/MIT)
31
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/auth0/auth0-server-python)
29
32
 
30
33
  📚 [Documentation](#documentation) - 🚀 [Getting Started](#getting-started) - 💬 [Feedback](#feedback)
31
34
 
@@ -159,3 +162,4 @@ Please do not report security vulnerabilities on the public GitHub issue tracker
159
162
  <p align="center">
160
163
  This project is licensed under the MIT license. See the <a href="https://github.com/auth0/auth0-server-python/blob/main/LICENSE"> LICENSE</a> file for more info.
161
164
  </p>
165
+
@@ -1,6 +1,9 @@
1
1
  The Auth0 Server Python SDK is a library for implementing user authentication in Python applications.
2
2
 
3
- ![PyPI](https://img.shields.io/pypi/v/auth0-server-python) ![Downloads](https://img.shields.io/pypi/dw/auth0-server-python) [![License](https://img.shields.io/:license-MIT-blue.svg?style=flat)](https://opensource.org/licenses/MIT)
3
+ ![PyPI](https://img.shields.io/pypi/v/auth0-server-python)
4
+ ![Downloads](https://img.shields.io/pypi/dw/auth0-server-python)
5
+ [![License](https://img.shields.io/:license-MIT-blue.svg?style=flat)](https://opensource.org/licenses/MIT)
6
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/auth0/auth0-server-python)
4
7
 
5
8
  📚 [Documentation](#documentation) - 🚀 [Getting Started](#getting-started) - 💬 [Feedback](#feedback)
6
9
 
@@ -133,4 +136,4 @@ Please do not report security vulnerabilities on the public GitHub issue tracker
133
136
  </p>
134
137
  <p align="center">
135
138
  This project is licensed under the MIT license. See the <a href="https://github.com/auth0/auth0-server-python/blob/main/LICENSE"> LICENSE</a> file for more info.
136
- </p>
139
+ </p>
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "auth0-server-python"
3
- version = "1.0.0.b5"
3
+ version = "1.0.0.b7"
4
4
  description = "Auth0 server-side Python SDK"
5
5
  readme = "README.md"
6
6
  authors = ["Auth0 <support@okta.com>"]
@@ -22,7 +22,7 @@ jwcrypto = "^1.5.6"
22
22
  [tool.poetry.group.dev.dependencies]
23
23
  pytest = "^7.2"
24
24
  pytest-cov = "^4.0"
25
- pytest-asyncio = "^0.20.3"
25
+ pytest-asyncio = ">=0.20.3,<0.24.0"
26
26
  pytest-mock = "^3.14.0"
27
27
  twine = "^6.1.0"
28
28
  ruff = "^0.1.0"
@@ -0,0 +1,3 @@
1
+ from .bearer_auth import BearerAuth
2
+
3
+ __all__ = ["BearerAuth"]
@@ -0,0 +1,10 @@
1
+ import httpx
2
+
3
+
4
+ class BearerAuth(httpx.Auth):
5
+ def __init__(self, token: str):
6
+ self.token = token
7
+
8
+ def auth_flow(self, request):
9
+ request.headers['Authorization'] = f"Bearer {self.token}"
10
+ yield request
@@ -0,0 +1,4 @@
1
+ from .my_account_client import MyAccountClient
2
+ from .server_client import ServerClient
3
+
4
+ __all__ = ["ServerClient", "MyAccountClient"]
@@ -0,0 +1,94 @@
1
+
2
+ import httpx
3
+ from auth0_server_python.auth_schemes.bearer_auth import BearerAuth
4
+ from auth0_server_python.auth_types import (
5
+ CompleteConnectAccountRequest,
6
+ CompleteConnectAccountResponse,
7
+ ConnectAccountRequest,
8
+ ConnectAccountResponse,
9
+ )
10
+ from auth0_server_python.error import (
11
+ ApiError,
12
+ MyAccountApiError,
13
+ )
14
+
15
+
16
+ class MyAccountClient:
17
+ def __init__(self, domain: str):
18
+ self._domain = domain
19
+
20
+ @property
21
+ def audience(self):
22
+ return f"https://{self._domain}/me/"
23
+
24
+ async def connect_account(
25
+ self,
26
+ access_token: str,
27
+ request: ConnectAccountRequest
28
+ ) -> ConnectAccountResponse:
29
+ try:
30
+ async with httpx.AsyncClient() as client:
31
+ response = await client.post(
32
+ url=f"{self.audience}v1/connected-accounts/connect",
33
+ json=request.model_dump(exclude_none=True),
34
+ auth=BearerAuth(access_token)
35
+ )
36
+
37
+ if response.status_code != 201:
38
+ error_data = response.json()
39
+ raise MyAccountApiError(
40
+ title=error_data.get("title", None),
41
+ type=error_data.get("type", None),
42
+ detail=error_data.get("detail", None),
43
+ status=error_data.get("status", None),
44
+ validation_errors=error_data.get("validation_errors", None)
45
+ )
46
+
47
+ data = response.json()
48
+
49
+ return ConnectAccountResponse.model_validate(data)
50
+
51
+ except Exception as e:
52
+ if isinstance(e, MyAccountApiError):
53
+ raise
54
+ raise ApiError(
55
+ "connect_account_error",
56
+ f"Connected Accounts connect request failed: {str(e) or 'Unknown error'}",
57
+ e
58
+ )
59
+
60
+ async def complete_connect_account(
61
+ self,
62
+ access_token: str,
63
+ request: CompleteConnectAccountRequest
64
+ ) -> CompleteConnectAccountResponse:
65
+ try:
66
+ async with httpx.AsyncClient() as client:
67
+ response = await client.post(
68
+ url=f"{self.audience}v1/connected-accounts/complete",
69
+ json=request.model_dump(exclude_none=True),
70
+ auth=BearerAuth(access_token)
71
+ )
72
+
73
+ if response.status_code != 201:
74
+ error_data = response.json()
75
+ raise MyAccountApiError(
76
+ title=error_data.get("title", None),
77
+ type=error_data.get("type", None),
78
+ detail=error_data.get("detail", None),
79
+ status=error_data.get("status", None),
80
+ validation_errors=error_data.get("validation_errors", None)
81
+ )
82
+
83
+ data = response.json()
84
+
85
+ return CompleteConnectAccountResponse.model_validate(data)
86
+
87
+ except Exception as e:
88
+ if isinstance(e, MyAccountApiError):
89
+ raise
90
+ raise ApiError(
91
+ "connect_account_error",
92
+ f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}",
93
+ e
94
+ )
@@ -7,11 +7,16 @@ import asyncio
7
7
  import json
8
8
  import time
9
9
  from typing import Any, Generic, Optional, TypeVar
10
- from urllib.parse import parse_qs, urlparse
10
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
11
11
 
12
12
  import httpx
13
13
  import jwt
14
+ from auth0_server_python.auth_server.my_account_client import MyAccountClient
14
15
  from auth0_server_python.auth_types import (
16
+ CompleteConnectAccountRequest,
17
+ CompleteConnectAccountResponse,
18
+ ConnectAccountOptions,
19
+ ConnectAccountRequest,
15
20
  LogoutOptions,
16
21
  LogoutTokenClaims,
17
22
  StartInteractiveLoginOptions,
@@ -40,7 +45,7 @@ from pydantic import ValidationError
40
45
  # Generic type for store options
41
46
  TStoreOptions = TypeVar('TStoreOptions')
42
47
  INTERNAL_AUTHORIZE_PARAMS = ["client_id", "redirect_uri", "response_type",
43
- "code_challenge", "code_challenge_method", "state", "nonce"]
48
+ "code_challenge", "code_challenge_method", "state", "nonce", "scope"]
44
49
 
45
50
 
46
51
  class ServerClient(Generic[TStoreOptions]):
@@ -48,6 +53,7 @@ class ServerClient(Generic[TStoreOptions]):
48
53
  Main client for Auth0 server SDK. Handles authentication flows, session management,
49
54
  and token operations using Authlib for OIDC functionality.
50
55
  """
56
+ DEFAULT_AUDIENCE_STATE_KEY = "default"
51
57
 
52
58
  def __init__(
53
59
  self,
@@ -101,6 +107,8 @@ class ServerClient(Generic[TStoreOptions]):
101
107
  client_secret=client_secret,
102
108
  )
103
109
 
110
+ self._my_account_client = MyAccountClient(domain=domain)
111
+
104
112
  async def _fetch_oidc_metadata(self, domain: str) -> dict:
105
113
  metadata_url = f"https://{domain}/.well-known/openid-configuration"
106
114
  async with httpx.AsyncClient() as client:
@@ -152,10 +160,17 @@ class ServerClient(Generic[TStoreOptions]):
152
160
  state = PKCE.generate_random_string(32)
153
161
  auth_params["state"] = state
154
162
 
163
+ #merge any requested scope with defaults
164
+ requested_scope = options.authorization_params.get("scope", None) if options.authorization_params else None
165
+ audience = auth_params.get("audience", None)
166
+ merged_scope = self._merge_scope_with_defaults(requested_scope, audience)
167
+ auth_params["scope"] = merged_scope
168
+
155
169
  # Build the transaction data to store
156
170
  transaction_data = TransactionData(
157
171
  code_verifier=code_verifier,
158
- app_state=options.app_state
172
+ app_state=options.app_state,
173
+ audience=audience,
159
174
  )
160
175
 
161
176
  # Store the transaction data
@@ -290,7 +305,7 @@ class ServerClient(Generic[TStoreOptions]):
290
305
 
291
306
  # Build a token set using the token response data
292
307
  token_set = TokenSet(
293
- audience=token_response.get("audience", "default"),
308
+ audience=transaction_data.audience or self.DEFAULT_AUDIENCE_STATE_KEY,
294
309
  access_token=token_response.get("access_token", ""),
295
310
  scope=token_response.get("scope", ""),
296
311
  expires_at=int(time.time()) +
@@ -509,7 +524,7 @@ class ServerClient(Generic[TStoreOptions]):
509
524
  existing_state_data = await self._state_store.get(self._state_identifier, store_options)
510
525
 
511
526
  audience = self._default_authorization_params.get(
512
- "audience", "default")
527
+ "audience", self.DEFAULT_AUDIENCE_STATE_KEY)
513
528
 
514
529
  state_data = State.update_state_data(
515
530
  audience,
@@ -562,7 +577,12 @@ class ServerClient(Generic[TStoreOptions]):
562
577
  return session_data
563
578
  return None
564
579
 
565
- async def get_access_token(self, store_options: Optional[dict[str, Any]] = None) -> str:
580
+ async def get_access_token(
581
+ self,
582
+ store_options: Optional[dict[str, Any]] = None,
583
+ audience: Optional[str] = None,
584
+ scope: Optional[str] = None,
585
+ ) -> str:
566
586
  """
567
587
  Retrieves the access token from the store, or calls Auth0 when the access token
568
588
  is expired and a refresh token is available in the store.
@@ -579,10 +599,13 @@ class ServerClient(Generic[TStoreOptions]):
579
599
  """
580
600
  state_data = await self._state_store.get(self._state_identifier, store_options)
581
601
 
582
- # Get audience and scope from options or use defaults
583
602
  auth_params = self._default_authorization_params or {}
584
- audience = auth_params.get("audience", "default")
585
- scope = auth_params.get("scope")
603
+
604
+ # Get audience passed in on options or use defaults
605
+ if not audience:
606
+ audience = auth_params.get("audience", None)
607
+
608
+ merged_scope = self._merge_scope_with_defaults(scope, audience)
586
609
 
587
610
  if state_data and hasattr(state_data, "dict") and callable(state_data.dict):
588
611
  state_data_dict = state_data.dict()
@@ -592,10 +615,7 @@ class ServerClient(Generic[TStoreOptions]):
592
615
  # Find matching token set
593
616
  token_set = None
594
617
  if state_data_dict and "token_sets" in state_data_dict:
595
- for ts in state_data_dict["token_sets"]:
596
- if ts.get("audience") == audience and (not scope or ts.get("scope") == scope):
597
- token_set = ts
598
- break
618
+ token_set = self._find_matching_token_set(state_data_dict["token_sets"], audience, merged_scope)
599
619
 
600
620
  # If token is valid, return it
601
621
  if token_set and token_set.get("expires_at", 0) > time.time():
@@ -610,9 +630,14 @@ class ServerClient(Generic[TStoreOptions]):
610
630
 
611
631
  # Get new token with refresh token
612
632
  try:
613
- token_endpoint_response = await self.get_token_by_refresh_token({
614
- "refresh_token": state_data_dict["refresh_token"]
615
- })
633
+ get_refresh_token_options = {"refresh_token": state_data_dict["refresh_token"]}
634
+ if audience:
635
+ get_refresh_token_options["audience"] = audience
636
+
637
+ if merged_scope:
638
+ get_refresh_token_options["scope"] = merged_scope
639
+
640
+ token_endpoint_response = await self.get_token_by_refresh_token(get_refresh_token_options)
616
641
 
617
642
  # Update state data with new token
618
643
  existing_state_data = await self._state_store.get(self._state_identifier, store_options)
@@ -631,6 +656,51 @@ class ServerClient(Generic[TStoreOptions]):
631
656
  f"Failed to get token with refresh token: {str(e)}"
632
657
  )
633
658
 
659
+ def _merge_scope_with_defaults(
660
+ self,
661
+ request_scope: Optional[str],
662
+ audience: Optional[str]
663
+ ) -> Optional[str]:
664
+ audience = audience or self.DEFAULT_AUDIENCE_STATE_KEY
665
+ default_scopes = ""
666
+ if self._default_authorization_params and "scope" in self._default_authorization_params:
667
+ auth_param_scope = self._default_authorization_params.get("scope")
668
+ # For backwards compatibility, allow scope to be a single string
669
+ # or dictionary by audience for MRRT
670
+ if isinstance(auth_param_scope, dict) and audience in auth_param_scope:
671
+ default_scopes = auth_param_scope[audience]
672
+ elif isinstance(auth_param_scope, str):
673
+ default_scopes = auth_param_scope
674
+
675
+ default_scopes_list = default_scopes.split()
676
+ request_scopes_list = (request_scope or "").split()
677
+
678
+ merged_scopes = list(dict.fromkeys(default_scopes_list + request_scopes_list))
679
+ return " ".join(merged_scopes) if merged_scopes else None
680
+
681
+
682
+ def _find_matching_token_set(
683
+ self,
684
+ token_sets: list[dict[str, Any]],
685
+ audience: Optional[str],
686
+ scope: Optional[str]
687
+ ) -> Optional[dict[str, Any]]:
688
+ audience = audience or self.DEFAULT_AUDIENCE_STATE_KEY
689
+ requested_scopes = set(scope.split()) if scope else set()
690
+ matches: list[tuple[int, dict]] = []
691
+ for token_set in token_sets:
692
+ token_set_audience = token_set.get("audience")
693
+ token_set_scopes = set(token_set.get("scope", "").split())
694
+ if token_set_audience == audience and token_set_scopes == requested_scopes:
695
+ # short-circuit if exact match
696
+ return token_set
697
+ if token_set_audience == audience and token_set_scopes.issuperset(requested_scopes):
698
+ # consider stored tokens with more scopes than requested by number of scopes
699
+ matches.append((len(token_set_scopes), token_set))
700
+
701
+ # Return the token set with the smallest superset of scopes that matches the requested audience and scopes
702
+ return min(matches, key=lambda t: t[0])[1] if matches else None
703
+
634
704
  async def get_access_token_for_connection(
635
705
  self,
636
706
  options: dict[str, Any],
@@ -1143,9 +1213,18 @@ class ServerClient(Generic[TStoreOptions]):
1143
1213
  "client_id": self._client_id,
1144
1214
  }
1145
1215
 
1146
- # Add scope if present in the original authorization params
1147
- if "scope" in self._default_authorization_params:
1148
- token_params["scope"] = self._default_authorization_params["scope"]
1216
+ audience = options.get("audience")
1217
+ if audience:
1218
+ token_params["audience"] = audience
1219
+
1220
+ # Merge scope if present in options with any in the original authorization params
1221
+ merged_scope = self._merge_scope_with_defaults(
1222
+ request_scope=options.get("scope"),
1223
+ audience=audience
1224
+ )
1225
+
1226
+ if merged_scope:
1227
+ token_params["scope"] = merged_scope
1149
1228
 
1150
1229
  # Exchange the refresh token for an access token
1151
1230
  async with httpx.AsyncClient() as client:
@@ -1260,3 +1339,135 @@ class ServerClient(Generic[TStoreOptions]):
1260
1339
  "There was an error while trying to retrieve an access token for a connection.",
1261
1340
  e
1262
1341
  )
1342
+
1343
+ async def start_connect_account(
1344
+ self,
1345
+ options: ConnectAccountOptions,
1346
+ store_options: dict = None
1347
+ ) -> str:
1348
+ """
1349
+ Initiates the connect account flow for linking a third-party account to the user's profile.
1350
+
1351
+ This method generates PKCE parameters, creates a transaction and calls the My Account API
1352
+ to create a connect account request, returning /connect url containing a ticket.
1353
+
1354
+ Args:
1355
+ options: Options for retrieving an access token for a connection.
1356
+ store_options: Optional options used to pass to the Transaction and State Store.
1357
+
1358
+ Returns:
1359
+ The a connect URL containing a ticket to redirect the user to.
1360
+ """
1361
+ # Use the default redirect_uri if none is specified
1362
+ redirect_uri = options.redirect_uri or self._redirect_uri
1363
+ # Ensure we have a redirect_uri
1364
+ if not redirect_uri:
1365
+ raise MissingRequiredArgumentError("redirect_uri")
1366
+
1367
+ # Generate PKCE code verifier and challenge
1368
+ code_verifier = PKCE.generate_code_verifier()
1369
+ code_challenge = PKCE.generate_code_challenge(code_verifier)
1370
+
1371
+ state= PKCE.generate_random_string(32)
1372
+
1373
+ connect_request = ConnectAccountRequest(
1374
+ connection=options.connection,
1375
+ scopes=options.scopes,
1376
+ redirect_uri = redirect_uri,
1377
+ code_challenge=code_challenge,
1378
+ code_challenge_method="S256",
1379
+ state=state,
1380
+ authorization_params=options.authorization_params
1381
+ )
1382
+
1383
+ access_token = await self.get_access_token(
1384
+ audience=self._my_account_client.audience,
1385
+ scope="create:me:connected_accounts",
1386
+ store_options=store_options
1387
+ )
1388
+ connect_response = await self._my_account_client.connect_account(
1389
+ access_token=access_token,
1390
+ request=connect_request
1391
+ )
1392
+
1393
+ # Build the transaction data to store
1394
+ transaction_data = TransactionData(
1395
+ code_verifier=code_verifier,
1396
+ app_state=options.app_state,
1397
+ auth_session=connect_response.auth_session,
1398
+ redirect_uri=redirect_uri
1399
+ )
1400
+
1401
+ # Store the transaction data
1402
+ await self._transaction_store.set(
1403
+ f"{self._transaction_identifier}:{state}",
1404
+ transaction_data,
1405
+ options=store_options
1406
+ )
1407
+
1408
+ parsedUrl = urlparse(connect_response.connect_uri)
1409
+ query = urlencode({"ticket": connect_response.connect_params.ticket})
1410
+ return urlunparse((parsedUrl.scheme, parsedUrl.netloc, parsedUrl.path, parsedUrl.params, query, parsedUrl.fragment))
1411
+
1412
+ async def complete_connect_account(
1413
+ self,
1414
+ url: str,
1415
+ store_options: dict = None
1416
+ ) -> CompleteConnectAccountResponse:
1417
+ """
1418
+ Handles the redirect callback to complete the connect account flow for linking a third-party
1419
+ account to the user's profile.
1420
+
1421
+ This works similar to the redirect from the login flow except it verifies the `connect_code`
1422
+ with the My Account API rather than the `code` with the Authorization Server.
1423
+
1424
+ Args:
1425
+ url: The full callback URL including query parameters
1426
+ store_options: Optional options used to pass to the Transaction and State Store.
1427
+
1428
+ Returns:
1429
+ A response from the connect account flow.
1430
+ """
1431
+ # Parse the URL to get query parameters
1432
+ parsed_url = urlparse(url)
1433
+ query_params = parse_qs(parsed_url.query)
1434
+
1435
+ # Get state parameter from the URL
1436
+ state = query_params.get("state", [""])[0]
1437
+ if not state:
1438
+ raise MissingRequiredArgumentError("state")
1439
+
1440
+ # Get the authorization code from the URL
1441
+ connect_code = query_params.get("connect_code", [""])[0]
1442
+ if not connect_code:
1443
+ raise MissingRequiredArgumentError("connect_code")
1444
+
1445
+ # Retrieve the transaction data using the state
1446
+ transaction_identifier = f"{self._transaction_identifier}:{state}"
1447
+ transaction_data = await self._transaction_store.get(transaction_identifier, options=store_options)
1448
+
1449
+ if not transaction_data:
1450
+ raise MissingTransactionError()
1451
+
1452
+ access_token = await self.get_access_token(
1453
+ audience=self._my_account_client.audience,
1454
+ scope="create:me:connected_accounts",
1455
+ store_options=store_options
1456
+ )
1457
+
1458
+ request = CompleteConnectAccountRequest(
1459
+ auth_session=transaction_data.auth_session,
1460
+ connect_code=connect_code,
1461
+ redirect_uri=transaction_data.redirect_uri,
1462
+ code_verifier=transaction_data.code_verifier
1463
+ )
1464
+ try:
1465
+ response = await self._my_account_client.complete_connect_account(
1466
+ access_token=access_token, request=request)
1467
+ if transaction_data.app_state is not None:
1468
+ response.app_state = transaction_data.app_state
1469
+ finally:
1470
+ # Clean up transaction data
1471
+ await self._transaction_store.delete(transaction_identifier, options=store_options)
1472
+
1473
+ return response
@@ -87,6 +87,8 @@ class TransactionData(BaseModel):
87
87
  audience: Optional[str] = None
88
88
  code_verifier: str
89
89
  app_state: Optional[Any] = None
90
+ auth_session: Optional[str] = None
91
+ redirect_uri: Optional[str] = None
90
92
 
91
93
  class Config:
92
94
  extra = "allow" # Allow additional fields not defined in the model
@@ -210,3 +212,43 @@ class StartLinkUserOptions(BaseModel):
210
212
  connection_scope: Optional[str] = None
211
213
  authorization_params: Optional[dict[str, Any]] = None
212
214
  app_state: Optional[Any] = None
215
+
216
+ class ConnectParams(BaseModel):
217
+ ticket: str
218
+
219
+ class ConnectAccountOptions(BaseModel):
220
+ connection: str
221
+ redirect_uri: Optional[str] = None
222
+ scopes: Optional[list[str]] = None
223
+ app_state: Optional[Any] = None
224
+ authorization_params: Optional[dict[str, Any]] = None
225
+
226
+ class ConnectAccountRequest(BaseModel):
227
+ connection: str
228
+ scopes: Optional[list[str]] = None
229
+ redirect_uri: Optional[str] = None
230
+ state: Optional[str] = None
231
+ code_challenge: Optional[str] = None
232
+ code_challenge_method: Optional[str] = 'S256'
233
+ authorization_params: Optional[dict[str, Any]] = None
234
+
235
+ class ConnectAccountResponse(BaseModel):
236
+ auth_session: str
237
+ connect_uri: str
238
+ connect_params: ConnectParams
239
+ expires_in: int
240
+
241
+ class CompleteConnectAccountRequest(BaseModel):
242
+ auth_session: str
243
+ connect_code: str
244
+ redirect_uri: str
245
+ code_verifier: Optional[str] = None
246
+
247
+ class CompleteConnectAccountResponse(BaseModel):
248
+ id: str
249
+ connection: str
250
+ access_type: str
251
+ scopes: list[str]
252
+ created_at: str
253
+ expires_at: Optional[str] = None
254
+ app_state: Optional[Any] = None
@@ -56,6 +56,26 @@ class PollingApiError(ApiError):
56
56
  super().__init__(code, message, cause)
57
57
  self.interval = interval
58
58
 
59
+ class MyAccountApiError(Auth0Error):
60
+ """
61
+ Error raised when an API request to My Account API fails.
62
+ Contains details about the original error from Auth0.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ title: Optional[str],
68
+ type: Optional[str],
69
+ detail: Optional[str],
70
+ status: Optional[int],
71
+ validation_errors: Optional[list[dict[str, str]]] = None
72
+ ):
73
+ super().__init__(detail)
74
+ self.title = title
75
+ self.type = type
76
+ self.detail = detail
77
+ self.status = status
78
+ self.validation_errors = validation_errors
59
79
 
60
80
  class AccessTokenError(Auth0Error):
61
81
  """Error raised when there's an issue with access tokens."""
@@ -124,6 +144,7 @@ class AccessTokenErrorCode:
124
144
  FAILED_TO_REQUEST_TOKEN = "failed_to_request_token"
125
145
  REFRESH_TOKEN_ERROR = "refresh_token_error"
126
146
  AUTH_REQ_ID_ERROR = "auth_req_id_error"
147
+ INCORRECT_AUDIENCE = "incorrect_audience"
127
148
 
128
149
 
129
150
  class AccessTokenForConnectionErrorCode: