auth0-server-python 1.0.0b5__tar.gz → 1.0.0b6__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.
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/PKG-INFO +6 -2
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/README.md +5 -2
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/pyproject.toml +1 -1
- auth0_server_python-1.0.0b6/src/auth0_server_python/auth_schemes/__init__.py +3 -0
- auth0_server_python-1.0.0b6/src/auth0_server_python/auth_schemes/bearer_auth.py +10 -0
- auth0_server_python-1.0.0b6/src/auth0_server_python/auth_server/__init__.py +4 -0
- auth0_server_python-1.0.0b6/src/auth0_server_python/auth_server/my_account_client.py +94 -0
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/src/auth0_server_python/auth_server/server_client.py +230 -19
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/src/auth0_server_python/auth_types/__init__.py +42 -0
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/src/auth0_server_python/error/__init__.py +21 -0
- auth0_server_python-1.0.0b6/src/auth0_server_python/tests/test_my_account_client.py +160 -0
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/src/auth0_server_python/tests/test_server_client.py +685 -5
- auth0_server_python-1.0.0b5/src/auth0_server_python/auth_server/__init__.py +0 -3
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/LICENSE +0 -0
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/src/auth0_server_python/encryption/__init__.py +0 -0
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/src/auth0_server_python/encryption/encrypt.py +0 -0
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/src/auth0_server_python/store/__init__.py +0 -0
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/src/auth0_server_python/store/abstract.py +0 -0
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/src/auth0_server_python/utils/__init__.py +0 -0
- {auth0_server_python-1.0.0b5 → auth0_server_python-1.0.0b6}/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.
|
|
3
|
+
Version: 1.0.0b6
|
|
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
|
-

|
|
28
|
+

|
|
29
|
+

|
|
30
|
+
[](https://opensource.org/licenses/MIT)
|
|
31
|
+
[](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
|
-

|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](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>
|
|
@@ -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=
|
|
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",
|
|
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(
|
|
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
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
-
|
|
1147
|
-
if
|
|
1148
|
-
token_params["
|
|
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:
|