Skip to content

Commit 89cca31

Browse files
Merge branch 'main' into feat/cte-delegation
2 parents 2feb760 + 0935394 commit 89cca31

8 files changed

Lines changed: 1157 additions & 9 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ __pycache__/
77
#Environments
88
.env
99
.venv
10+
.venv-*/
1011
env/
1112

1213
#Session Cache

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ async def callback(request: Request):
104104
return RedirectResponse(url="/")
105105
```
106106

107+
#### Organizations
108+
109+
The SDK supports [Auth0 Organizations](https://auth0.com/docs/organizations) with first-class `organization` and `invitation` parameters on `ServerClient` and `StartInteractiveLoginOptions`. Token claim validation is enforced automatically at callback. For setup, invitation flows, error handling, and reading org data from the session, see [examples/InteractiveLogin.md](examples/InteractiveLogin.md#8-organizations).
110+
107111
### 4. Login with Custom Token Exchange
108112

109113
If you're migrating from a legacy authentication system or integrating with a custom identity provider, you can exchange external tokens for Auth0 tokens using the OAuth 2.0 Token Exchange specification (RFC 8693):

examples/InteractiveLogin.md

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Interactive Login
22

3-
Interactive login in `auth0‑server‑python` is a two‑step process. First, you start the login flow by obtaining an authorization URL; then, after the user authenticates at Auth0 and is redirected back, you complete the login flow to exchange the authorization code for tokens.
3+
Interactive login in `auth0‑server‑python` is a two‑step process. First, you start the login flow by obtaining an authorization URL; then, after the user authenticates at Auth0 and is redirected back, you complete the login flow to exchange the authorization code for tokens.
44

5-
This guide covers how to customize the authorization parameters, pass custom app state, enable **Pushed Authorization Requests (PAR)** and **Rich Authorization Requests (RAR)**, and supply store options.
5+
This guide covers how to customize the authorization parameters, pass custom app state, enable **Pushed Authorization Requests (PAR)** and **Rich Authorization Requests (RAR)**, supply store options, and log in to an organization.
66

77
## 1. Starting Interactive Login
88

@@ -28,7 +28,7 @@ Now call `start_interactive_login()` to obtain the authorization URL and redirec
2828
authorization_url = await server_client.start_interactive_login()
2929
```
3030
## 2. Passing Authorization Params
31-
You can customize the parameters sent to Auth0s `/authorize` endpoint in two ways:
31+
You can customize the parameters sent to Auth0's `/authorize` endpoint in two ways:
3232

3333
### Global Configuration
3434

@@ -76,7 +76,7 @@ result = await server_client.complete_interactive_login(callback_url)
7676
print(result.get("app_state").get("returnTo")) # Should output: http://localhost:3000/dashboard
7777
```
7878
> [!NOTE]
79-
>- `authorize_url` is the URL for Auth0s /authorize endpoint (or a URL built from PAR, if enabled).
79+
>- `authorize_url` is the URL for Auth0's /authorize endpoint (or a URL built from PAR, if enabled).
8080
>- `callback_url` is the URL Auth0 redirects back to after authentication.
8181
8282
## 4. Using Pushed Authorization Requests (PAR)
@@ -89,7 +89,7 @@ authorization_url = await server_client.start_interactive_login({
8989
})
9090
```
9191
>[!IMPORTANT]
92-
> Using PAR requires that your Auth0 tenant is configured to support it. Refer to Auth0s documentation for details.
92+
> Using PAR requires that your Auth0 tenant is configured to support it. Refer to Auth0's documentation for details.
9393
9494
## 5. Using Pushed Authorization Requests and Rich Authorization Requests (RAR)
9595

@@ -137,4 +137,99 @@ print(result.get("authorization_details")) # Rich Authorization Re
137137
```
138138

139139
>[!NOTE]
140-
>The `callback_url` must include the necessary parameters (`state` and `code`) that Auth0 sends upon successful authentication.
140+
>The `callback_url` must include the necessary parameters (`state` and `code`) that Auth0 sends upon successful authentication.
141+
142+
## 8. Organizations
143+
144+
[Auth0 Organizations](https://auth0.com/docs/organizations) lets you manage teams, business customers, and partner companies as distinct entities with their own login flows and membership.
145+
146+
### Logging in to an organization
147+
148+
Set `organization` on `ServerClient` to enforce it for every login (dedicated-org), or pass it per login via `StartInteractiveLoginOptions` (multi-org):
149+
150+
```python
151+
from auth0_server_python.auth_server.server_client import ServerClient
152+
from auth0_server_python.auth_types import StartInteractiveLoginOptions
153+
154+
# Dedicated-org: every login enforces this organization
155+
auth0 = ServerClient(
156+
domain="YOUR_AUTH0_DOMAIN",
157+
client_id="YOUR_CLIENT_ID",
158+
client_secret="YOUR_CLIENT_SECRET",
159+
secret="YOUR_SECRET",
160+
organization="org_abc123",
161+
authorization_params={"redirect_uri": "http://localhost:3000/auth/callback"}
162+
)
163+
164+
# Multi-org: pass organization per login
165+
authorization_url = await auth0.start_interactive_login(
166+
StartInteractiveLoginOptions(organization="org_xyz789"),
167+
store_options={"request": request, "response": response}
168+
)
169+
```
170+
171+
`organization` accepts either an org ID (`org_` prefix) or an org name. The SDK validates the corresponding `org_id` or `org_name` claim in the returned token automatically at callback.
172+
173+
> [!IMPORTANT]
174+
> In the multi-org pattern, validate that the `organization` value comes from a trusted source — never pass it unvalidated directly from user input.
175+
176+
### Accepting an invitation
177+
178+
When a user follows an invitation link, extract `organization` and `invitation` from the URL and pass them as typed fields:
179+
180+
```python
181+
@app.get("/auth/login")
182+
async def login(request: Request, response: Response):
183+
authorization_url = await auth0.start_interactive_login(
184+
StartInteractiveLoginOptions(
185+
organization=request.query_params.get("organization"),
186+
invitation=request.query_params.get("invitation"),
187+
),
188+
store_options={"request": request, "response": response}
189+
)
190+
return RedirectResponse(url=authorization_url)
191+
```
192+
193+
### Handling organization errors
194+
195+
Auth0 returns organization errors as standard OAuth error responses (`error` + `error_description`). The SDK surfaces these as `ApiError`, preserving the raw values so you can branch on `error.code`:
196+
197+
```python
198+
from auth0_server_python.error import ApiError, OrganizationTokenValidationError
199+
200+
@app.get("/auth/callback")
201+
async def callback(request: Request, response: Response):
202+
try:
203+
result = await auth0.complete_interactive_login(
204+
str(request.url),
205+
store_options={"request": request, "response": response}
206+
)
207+
return RedirectResponse(url="/dashboard")
208+
except OrganizationTokenValidationError:
209+
return RedirectResponse(url="/error?reason=org_mismatch")
210+
except ApiError as e:
211+
return RedirectResponse(url=f"/error?reason={e.code}")
212+
```
213+
214+
| Exception | When raised |
215+
|-----------|-------------|
216+
| `OrganizationTokenValidationError` | `org_id` / `org_name` in the returned token does not match what was requested |
217+
| `ApiError` | Auth0 rejected the authorization request — inspect `error.code` and `error.message` for the raw OAuth error and description |
218+
219+
Common `ApiError.code` values for org flows:
220+
221+
| `error.code` | Typical cause |
222+
|---|---|
223+
| `access_denied` | User not a member, connection not enabled for org, member quota exceeded |
224+
| `invalid_request` | Invalid org format, feature disabled, client not configured for orgs, expired or invalid invitation ticket |
225+
226+
### Reading organization data from the session
227+
228+
After a successful org login, `org_id` is always present in the token. `org_name` is also present when the organization has the org name feature enabled:
229+
230+
```python
231+
user = await auth0.get_user(store_options={"request": request, "response": response})
232+
if user:
233+
print(user.get("org_id")) # always present; use as stable identifier
234+
print(user.get("org_name")) # present when org name is enabled
235+
```

src/auth0_server_python/auth_server/server_client.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,15 @@
5454
MfaRequiredError,
5555
MissingRequiredArgumentError,
5656
MissingTransactionError,
57+
OrganizationTokenValidationError,
5758
PollingApiError,
5859
StartLinkUserError,
5960
)
6061
from auth0_server_python.telemetry import Telemetry
6162
from auth0_server_python.utils import PKCE, URL, State
6263
from auth0_server_python.utils.helpers import (
6364
build_domain_resolver_context,
65+
validate_org_claims,
6466
validate_resolved_domain_value,
6567
)
6668

@@ -96,6 +98,7 @@ def __init__(
9698
state_identifier: str = "_a0_session",
9799
authorization_params: Optional[dict[str, Any]] = None,
98100
pushed_authorization_requests: bool = False,
101+
organization: Optional[str] = None,
99102
):
100103
"""
101104
Initialize the Auth0 server client.
@@ -112,6 +115,9 @@ def __init__(
112115
state_identifier: Identifier for state data
113116
authorization_params: Default parameters for authorization requests
114117
pushed_authorization_requests: Whether to use Pushed Authorization Requests
118+
organization: Default organization for all login flows from this client.
119+
Can be an org ID (e.g. 'org_abc123') or an org name (e.g. 'acme-corp').
120+
Per-login values passed in StartInteractiveLoginOptions always override this.
115121
"""
116122
if not secret:
117123
raise MissingRequiredArgumentError("secret")
@@ -146,6 +152,7 @@ def __init__(
146152
self._secret = secret
147153
self._default_authorization_params = authorization_params or {}
148154
self._pushed_authorization_requests = pushed_authorization_requests # store the flag
155+
self._organization = organization
149156

150157
# Initialize stores
151158
self._transaction_store = transaction_store
@@ -207,6 +214,7 @@ def _normalize_url(self, value: str) -> str:
207214

208215
return value.rstrip('/')
209216

217+
210218
async def _resolve_current_domain(self, store_options=None) -> str:
211219
"""Resolve domain from resolver function or return static domain."""
212220
if self._domain_resolver:
@@ -502,13 +510,24 @@ async def start_interactive_login(
502510
merged_scope = self._merge_scope_with_defaults(requested_scope, audience)
503511
auth_params["scope"] = merged_scope
504512

513+
# Typed org/invitation fields win over anything already in auth_params from authorization_params.
514+
resolved_org = options.organization or self._organization
515+
if resolved_org and not resolved_org.strip():
516+
raise InvalidArgumentError("organization", "organization must not be blank")
517+
if resolved_org:
518+
auth_params["organization"] = resolved_org
519+
520+
if options.invitation:
521+
auth_params["invitation"] = options.invitation
522+
505523
# Build the transaction data to store with domain
506524
transaction_data = TransactionData(
507525
code_verifier=code_verifier,
508526
app_state=options.app_state,
509527
audience=audience,
510528
domain=origin_domain,
511529
redirect_uri=auth_params.get("redirect_uri"),
530+
organization=resolved_org,
512531
)
513532

514533
# Store the transaction data
@@ -638,8 +657,26 @@ async def complete_interactive_login(
638657
user_info = token_response.get("userinfo")
639658
user_claims = None
640659
id_token = token_response.get("id_token")
660+
expected_org = transaction_data.organization
661+
662+
if not user_info and not id_token and expected_org:
663+
raise OrganizationTokenValidationError(
664+
"Organization was requested but the token response included neither an ID token nor userinfo; "
665+
"cannot verify organization membership"
666+
)
641667

642668
if user_info:
669+
if not isinstance(user_info, dict):
670+
if expected_org:
671+
raise OrganizationTokenValidationError(
672+
"Userinfo response is not a valid claims dictionary; cannot verify organization membership"
673+
)
674+
raise ApiError(
675+
"invalid_response",
676+
"Userinfo response is not a valid claims dictionary"
677+
)
678+
if expected_org:
679+
validate_org_claims(user_info, expected_org)
643680
user_claims = UserClaims.parse_obj(user_info)
644681
elif id_token:
645682
# Fetch JWKS for signature verification
@@ -656,6 +693,10 @@ async def complete_interactive_login(
656693
if self._normalize_url(token_issuer) != self._normalize_url(origin_issuer):
657694
raise IssuerValidationError("ID token issuer mismatch. Ensure your Auth0 domain is configured correctly.")
658695

696+
# Organization claim validation — mandatory when org was requested.
697+
if expected_org:
698+
validate_org_claims(claims, expected_org)
699+
659700
user_claims = UserClaims.parse_obj(claims)
660701
except ValueError as e:
661702
raise ApiError("jwks_key_not_found", str(e))
@@ -1283,7 +1324,10 @@ async def backchannel_authentication(
12831324
while time.time() < end_time:
12841325
# Make token request
12851326
try:
1286-
token_response = await self.backchannel_authentication_grant(auth_req_id, store_options=store_options)
1327+
token_response = await self.backchannel_authentication_grant(
1328+
auth_req_id,
1329+
store_options=store_options,
1330+
)
12871331
return token_response
12881332

12891333
except Exception as e:
@@ -2438,6 +2482,7 @@ async def login_with_custom_token_exchange(
24382482
# Extract user claims from ID token if present
24392483
user_claims = None
24402484
sid = PKCE.generate_random_string(32) # Default sid
2485+
24412486
if token_response.id_token:
24422487
# Fetch JWKS and verify ID token signature
24432488
jwks = await self._get_jwks_cached(domain, metadata)
@@ -2521,7 +2566,7 @@ async def login_with_custom_token_exchange(
25212566
return result
25222567

25232568
except Exception as e:
2524-
if isinstance(e, (CustomTokenExchangeError, ApiError)):
2569+
if isinstance(e, (CustomTokenExchangeError, ApiError, IssuerValidationError)):
25252570
raise
25262571
raise CustomTokenExchangeError(
25272572
CustomTokenExchangeErrorCode.TOKEN_EXCHANGE_FAILED,

src/auth0_server_python/auth_types/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class UserClaims(BaseModel):
2222
email: Optional[str] = None
2323
email_verified: Optional[bool] = None
2424
org_id: Optional[str] = None
25+
org_name: Optional[str] = None
2526

2627
class Config:
2728
extra = "allow" # Allow additional fields not defined in the model
@@ -91,6 +92,7 @@ class TransactionData(BaseModel):
9192
auth_session: Optional[str] = None
9293
redirect_uri: Optional[str] = None
9394
domain: Optional[str] = None
95+
organization: Optional[str] = None
9496

9597
class Config:
9698
extra = "allow" # Allow additional fields not defined in the model
@@ -128,6 +130,7 @@ class ServerClientOptionsBase(BaseModel):
128130
transaction_identifier: Optional[str] = "_a0_tx"
129131
state_identifier: Optional[str] = "_a0_session"
130132
custom_fetch: Optional[Any] = None # Function type hint would be more complex
133+
organization: Optional[str] = None
131134

132135

133136
class ServerClientOptionsWithSecret(ServerClientOptionsBase):
@@ -147,6 +150,8 @@ class StartInteractiveLoginOptions(BaseModel):
147150
pushed_authorization_requests: Optional[bool] = False
148151
app_state: Optional[Any] = None
149152
authorization_params: Optional[dict[str, Any]] = None
153+
organization: Optional[str] = None
154+
invitation: Optional[str] = None
150155

151156

152157
class LogoutOptions(BaseModel):

src/auth0_server_python/error/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,18 @@ class AccessTokenErrorCode:
200200
DOMAIN_MISMATCH = "domain_mismatch"
201201

202202

203+
class OrganizationTokenValidationError(Auth0Error):
204+
"""
205+
Raised when org_id or org_name claim in the ID token fails validation
206+
against the organization value that was requested at login.
207+
"""
208+
code = "organization_token_validation_error"
209+
210+
def __init__(self, message: str):
211+
super().__init__(message)
212+
self.name = "OrganizationTokenValidationError"
213+
214+
203215
class AccessTokenForConnectionErrorCode:
204216
"""Error codes for connection-specific token operations."""
205217
MISSING_REFRESH_TOKEN = "missing_refresh_token"

0 commit comments

Comments
 (0)