Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ __pycache__/
#Environments
.env
.venv
.venv-*/
env/

#Session Cache
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ async def callback(request: Request):
return RedirectResponse(url="/")
```

#### Organizations

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).

### 4. Login with Custom Token Exchange

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):
Expand Down
107 changes: 101 additions & 6 deletions examples/InteractiveLogin.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Interactive Login

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.
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.

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.
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.

## 1. Starting Interactive Login

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

### Global Configuration

Expand Down Expand Up @@ -76,7 +76,7 @@ result = await server_client.complete_interactive_login(callback_url)
print(result.get("app_state").get("returnTo")) # Should output: http://localhost:3000/dashboard
```
> [!NOTE]
>- `authorize_url` is the URL for Auth0s /authorize endpoint (or a URL built from PAR, if enabled).
>- `authorize_url` is the URL for Auth0's /authorize endpoint (or a URL built from PAR, if enabled).
>- `callback_url` is the URL Auth0 redirects back to after authentication.

## 4. Using Pushed Authorization Requests (PAR)
Expand All @@ -89,7 +89,7 @@ authorization_url = await server_client.start_interactive_login({
})
```
>[!IMPORTANT]
> Using PAR requires that your Auth0 tenant is configured to support it. Refer to Auth0s documentation for details.
> Using PAR requires that your Auth0 tenant is configured to support it. Refer to Auth0's documentation for details.

## 5. Using Pushed Authorization Requests and Rich Authorization Requests (RAR)

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

>[!NOTE]
>The `callback_url` must include the necessary parameters (`state` and `code`) that Auth0 sends upon successful authentication.
>The `callback_url` must include the necessary parameters (`state` and `code`) that Auth0 sends upon successful authentication.

## 8. Organizations

[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.

### Logging in to an organization

Set `organization` on `ServerClient` to enforce it for every login (dedicated-org), or pass it per login via `StartInteractiveLoginOptions` (multi-org):

```python
from auth0_server_python.auth_server.server_client import ServerClient
from auth0_server_python.auth_types import StartInteractiveLoginOptions

# Dedicated-org: every login enforces this organization
auth0 = ServerClient(
domain="YOUR_AUTH0_DOMAIN",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
secret="YOUR_SECRET",
organization="org_abc123",
authorization_params={"redirect_uri": "http://localhost:3000/auth/callback"}
)

# Multi-org: pass organization per login
authorization_url = await auth0.start_interactive_login(
StartInteractiveLoginOptions(organization="org_xyz789"),
store_options={"request": request, "response": response}
)
```

`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.

> [!IMPORTANT]
> In the multi-org pattern, validate that the `organization` value comes from a trusted source — never pass it unvalidated directly from user input.

### Accepting an invitation

When a user follows an invitation link, extract `organization` and `invitation` from the URL and pass them as typed fields:

```python
@app.get("/auth/login")
async def login(request: Request, response: Response):
authorization_url = await auth0.start_interactive_login(
StartInteractiveLoginOptions(
organization=request.query_params.get("organization"),
invitation=request.query_params.get("invitation"),
),
store_options={"request": request, "response": response}
)
return RedirectResponse(url=authorization_url)
```

### Handling organization errors

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`:

```python
from auth0_server_python.error import ApiError, OrganizationTokenValidationError

@app.get("/auth/callback")
async def callback(request: Request, response: Response):
try:
result = await auth0.complete_interactive_login(
str(request.url),
store_options={"request": request, "response": response}
)
return RedirectResponse(url="/dashboard")
except OrganizationTokenValidationError:
return RedirectResponse(url="/error?reason=org_mismatch")
except ApiError as e:
return RedirectResponse(url=f"/error?reason={e.code}")
```

| Exception | When raised |
|-----------|-------------|
| `OrganizationTokenValidationError` | `org_id` / `org_name` in the returned token does not match what was requested |
| `ApiError` | Auth0 rejected the authorization request — inspect `error.code` and `error.message` for the raw OAuth error and description |

Common `ApiError.code` values for org flows:

| `error.code` | Typical cause |
|---|---|
| `access_denied` | User not a member, connection not enabled for org, member quota exceeded |
| `invalid_request` | Invalid org format, feature disabled, client not configured for orgs, expired or invalid invitation ticket |

### Reading organization data from the session

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:

```python
user = await auth0.get_user(store_options={"request": request, "response": response})
Comment thread
kishore7snehil marked this conversation as resolved.
if user:
print(user.get("org_id")) # always present; use as stable identifier
print(user.get("org_name")) # present when org name is enabled
```
49 changes: 47 additions & 2 deletions src/auth0_server_python/auth_server/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,15 @@
MfaRequiredError,
MissingRequiredArgumentError,
MissingTransactionError,
OrganizationTokenValidationError,
PollingApiError,
StartLinkUserError,
)
from auth0_server_python.telemetry import Telemetry
from auth0_server_python.utils import PKCE, URL, State
from auth0_server_python.utils.helpers import (
build_domain_resolver_context,
validate_org_claims,
validate_resolved_domain_value,
)

Expand Down Expand Up @@ -96,6 +98,7 @@ def __init__(
state_identifier: str = "_a0_session",
authorization_params: Optional[dict[str, Any]] = None,
pushed_authorization_requests: bool = False,
organization: Optional[str] = None,
):
"""
Initialize the Auth0 server client.
Expand All @@ -112,6 +115,9 @@ def __init__(
state_identifier: Identifier for state data
authorization_params: Default parameters for authorization requests
pushed_authorization_requests: Whether to use Pushed Authorization Requests
organization: Default organization for all login flows from this client.
Can be an org ID (e.g. 'org_abc123') or an org name (e.g. 'acme-corp').
Per-login values passed in StartInteractiveLoginOptions always override this.
"""
if not secret:
raise MissingRequiredArgumentError("secret")
Expand Down Expand Up @@ -146,6 +152,7 @@ def __init__(
self._secret = secret
self._default_authorization_params = authorization_params or {}
self._pushed_authorization_requests = pushed_authorization_requests # store the flag
self._organization = organization

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

return value.rstrip('/')


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

# Typed org/invitation fields win over anything already in auth_params from authorization_params.
resolved_org = options.organization or self._organization
if resolved_org and not resolved_org.strip():
raise InvalidArgumentError("organization", "organization must not be blank")
if resolved_org:
auth_params["organization"] = resolved_org

if options.invitation:
auth_params["invitation"] = options.invitation

# Build the transaction data to store with domain
transaction_data = TransactionData(
code_verifier=code_verifier,
app_state=options.app_state,
audience=audience,
domain=origin_domain,
redirect_uri=auth_params.get("redirect_uri"),
organization=resolved_org,
)

# Store the transaction data
Expand Down Expand Up @@ -638,8 +657,26 @@ async def complete_interactive_login(
user_info = token_response.get("userinfo")
user_claims = None
id_token = token_response.get("id_token")
expected_org = transaction_data.organization

if not user_info and not id_token and expected_org:
raise OrganizationTokenValidationError(
"Organization was requested but the token response included neither an ID token nor userinfo; "
"cannot verify organization membership"
)

if user_info:
if not isinstance(user_info, dict):
if expected_org:
raise OrganizationTokenValidationError(
"Userinfo response is not a valid claims dictionary; cannot verify organization membership"
)
raise ApiError(
"invalid_response",
"Userinfo response is not a valid claims dictionary"
)
if expected_org:
validate_org_claims(user_info, expected_org)
user_claims = UserClaims.parse_obj(user_info)
elif id_token:
# Fetch JWKS for signature verification
Expand All @@ -656,6 +693,10 @@ async def complete_interactive_login(
if self._normalize_url(token_issuer) != self._normalize_url(origin_issuer):
raise IssuerValidationError("ID token issuer mismatch. Ensure your Auth0 domain is configured correctly.")

# Organization claim validation — mandatory when org was requested.
if expected_org:
validate_org_claims(claims, expected_org)

user_claims = UserClaims.parse_obj(claims)
except ValueError as e:
raise ApiError("jwks_key_not_found", str(e))
Expand Down Expand Up @@ -1283,7 +1324,10 @@ async def backchannel_authentication(
while time.time() < end_time:
# Make token request
try:
token_response = await self.backchannel_authentication_grant(auth_req_id, store_options=store_options)
token_response = await self.backchannel_authentication_grant(
auth_req_id,
store_options=store_options,
)
return token_response

except Exception as e:
Expand Down Expand Up @@ -2386,6 +2430,7 @@ async def login_with_custom_token_exchange(
# Extract user claims from ID token if present
user_claims = None
sid = PKCE.generate_random_string(32) # Default sid

if token_response.id_token:
# Fetch JWKS and verify ID token signature
jwks = await self._get_jwks_cached(domain, metadata)
Expand Down Expand Up @@ -2467,7 +2512,7 @@ async def login_with_custom_token_exchange(
return result

except Exception as e:
if isinstance(e, (CustomTokenExchangeError, ApiError)):
if isinstance(e, (CustomTokenExchangeError, ApiError, IssuerValidationError)):
raise
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.TOKEN_EXCHANGE_FAILED,
Expand Down
5 changes: 5 additions & 0 deletions src/auth0_server_python/auth_types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class UserClaims(BaseModel):
email: Optional[str] = None
email_verified: Optional[bool] = None
org_id: Optional[str] = None
org_name: Optional[str] = None

class Config:
extra = "allow" # Allow additional fields not defined in the model
Expand Down Expand Up @@ -91,6 +92,7 @@ class TransactionData(BaseModel):
auth_session: Optional[str] = None
redirect_uri: Optional[str] = None
domain: Optional[str] = None
organization: Optional[str] = None

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


class ServerClientOptionsWithSecret(ServerClientOptionsBase):
Expand All @@ -147,6 +150,8 @@ class StartInteractiveLoginOptions(BaseModel):
pushed_authorization_requests: Optional[bool] = False
app_state: Optional[Any] = None
authorization_params: Optional[dict[str, Any]] = None
organization: Optional[str] = None
invitation: Optional[str] = None


class LogoutOptions(BaseModel):
Expand Down
12 changes: 12 additions & 0 deletions src/auth0_server_python/error/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,18 @@ class AccessTokenErrorCode:
DOMAIN_MISMATCH = "domain_mismatch"


class OrganizationTokenValidationError(Auth0Error):
"""
Raised when org_id or org_name claim in the ID token fails validation
against the organization value that was requested at login.
"""
code = "organization_token_validation_error"

def __init__(self, message: str):
super().__init__(message)
self.name = "OrganizationTokenValidationError"


class AccessTokenForConnectionErrorCode:
"""Error codes for connection-specific token operations."""
MISSING_REFRESH_TOKEN = "missing_refresh_token"
Expand Down
Loading
Loading