Skip to content
Draft
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rf

Sign users in with a one-time code sent by email or SMS, or with a magic link sent by email, via [Auth0 embedded passwordless login](https://auth0.com/docs/authenticate/passwordless/implement-login/embedded-login/relevant-api-endpoints). OTP verification and the magic-link callback each establish a server-side session like every other login path. For prerequisites, both flows, custom scopes/audiences, step-up MFA, and error handling, see [examples/Passwordless.md](examples/Passwordless.md).

### 11. Enterprise Connect (Embedded Login)

Sign users in through their company's identity provider while **your application owns the session**. Opt in with `enterprise_connect=True`; Auth0 acts as a pure SSO relay and issues no refresh token. `start_enterprise_login()` discovers whether an email domain is managed and returns an authorization URL or `None`, and `complete_interactive_login()` returns the verified claims and access token for your app to build its own session from. Early Access. For discovery, the callback contract, multi-tenant `org_id` checks, and federated logout, see [examples/EnterpriseConnect.md](examples/EnterpriseConnect.md).

## Feedback

### Contributing
Expand Down
232 changes: 232 additions & 0 deletions examples/EnterpriseConnect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# Enterprise Connect (Embedded Login)

Enterprise Connect lets your application sign users in through their company's identity provider while **your app owns the session**. Auth0 acts as a pure SSO relay: it authenticates the user and returns verified claims, but issues no refresh token and holds no session on your behalf. This guide covers the `enterprise_connect` mode on `ServerClient`, email-domain discovery, the callback contract, and federated logout.

> [!IMPORTANT]
> Enterprise Connect is an Early Access feature. Tenant setup (entitlements, connection type, and the exact claims a token carries) depends on your Auth0 configuration and may change. Treat the tenant-side steps below as a starting point and confirm them against your tenant. The SDK behavior described here is stable.

> [!IMPORTANT]
> These flows are for confidential server-side applications. The verified claims and access token are handed to your server, which creates and owns the user session. The browser should only ever receive your application's own session cookie or opaque session reference, never an Auth0 token.

## Table of Contents

- [How the flow works](#how-the-flow-works)
- [Prerequisites](#prerequisites)
- [1. Configure the client](#1-configure-the-client)
- [2. Discover and start the login](#2-discover-and-start-the-login)
- [3. Complete the callback](#3-complete-the-callback)
- [4. Protect your routes](#4-protect-your-routes)
- [5. Organizations and multi-tenant apps](#5-organizations-and-multi-tenant-apps)
- [6. Logout](#6-logout)
- [What is not available in Enterprise Connect](#what-is-not-available-in-enterprise-connect)
- [Error Handling](#error-handling)

## How the flow works

1. The user enters their email. Your app calls `start_enterprise_login()`, which runs [WebFinger](https://datatracker.ietf.org/doc/html/rfc7033) discovery on the email domain.
2. If the domain is managed by Auth0 for enterprise SSO, the SDK returns an authorization URL with the email as `login_hint` so Auth0 can resolve the connection and organization. If it is not managed, the method returns `None` and your app falls back to its own login.
3. The user authenticates at their identity provider and is redirected back to your callback.
4. Your app calls `complete_interactive_login()`. The SDK exchanges the code, verifies the ID token's signature and issuer, and returns the claims from it. It persists **nothing** and issues no refresh token.
5. Your app creates its own first-party session from the returned claims.

The contract is inverted from a normal login: the SDK does not store a session, so the session-reading methods (`get_session`, `get_access_token`) are not available in this mode.

## Prerequisites

Enterprise Connect requires a **Regular Web Application** with a client secret. The tenant and connection must be provisioned for Enterprise Connect (Early Access), and WebFinger discovery must be enabled on the tenant. Work with your Auth0 contact to confirm entitlements for your tenant.

Do not request `offline_access` and do not set a static `organization` on the client. Enterprise Connect issues no refresh token, and the organization is resolved from the login email at Auth0. The SDK warns at construction if either is set.

## 1. Configure the client

Opt in with `enterprise_connect=True`. Supply a `transaction_store` (used to protect the callback with `state` and PKCE); a `state_store` is not needed, because the SDK persists no session.

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

server_client = ServerClient(
domain="YOUR_AUTH0_DOMAIN",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
secret="YOUR_SECRET",
redirect_uri="https://app.example.com/auth/callback",
authorization_params={"scope": "openid profile email"},
enterprise_connect=True,
)
```

For apps using request/response-backed stores or multiple custom domains, pass `store_options={"request": request, "response": response}` to each method that reads or writes transaction state.

## 2. Discover and start the login

Your app must serve a login page that collects the user's work email. Pass it to `start_enterprise_login()`, which runs WebFinger discovery and returns the authorization URL when the domain is managed, or `None` when it is not.

```python
from auth0_server_python.auth_types import StartEnterpriseLoginOptions
from auth0_server_python.error import MissingRequiredArgumentError, InvalidArgumentError

try:
auth_url = await server_client.start_enterprise_login(
StartEnterpriseLoginOptions(
email=user_email,
app_state={"return_to": "/dashboard"},
),
store_options={"request": request, "response": response},
)
except (MissingRequiredArgumentError, InvalidArgumentError):
auth_url = None

if auth_url:
return redirect(auth_url)
return redirect("/login/password")
```

> [!IMPORTANT]
> Discovery is a routing hint, not an authorization decision. It fails closed to "not managed" on any error, so a discovery failure routes the user to your fallback login rather than granting access. It never, on its own, signs anyone in - the callback must still complete.

If you only need the discovery signal (for example, to decide which login button to show), call the standalone helper. It takes no client instance and is stateless.

```python
from auth0_server_python.auth_server import is_federated_domain

managed = await is_federated_domain("YOUR_AUTH0_DOMAIN", "acme.example")
```

## 3. Complete the callback

When Auth0 redirects back, call `complete_interactive_login()`. In Enterprise Connect mode it returns the verified claims and an access token instead of a session record.

```python
result = await server_client.complete_interactive_login(
str(request.url),
store_options={"request": request, "response": response},
)

user = result["user"]
access_token = result["token_set"]["access_token"]
id_token = result["id_token"]
domain = result["domain"]

create_app_session(user_id=user.sub)

app_state = result.get("app_state") or {}
return redirect(app_state.get("return_to", "/"))
```

The returned dict contains:

- `user` - the verified `UserClaims`, parsed from the ID token that Enterprise Connect returns
- `token_set` - `audience`, `access_token`, `scope`, and `expires_at`
- `id_token` - the raw ID token, for your own use
- `domain` - the Auth0 domain the login came from
- `app_state` - present only when you passed `app_state` at `start_enterprise_login()`

`result` is a plain dict, so index it with `result["user"]`. `user` is a `UserClaims` model with no dict access, so read claims by attribute like `user.org_id`.

The SDK verifies the ID token's signature and issuer, and derives the returned claims from it, before returning. It does not write a session store record and retains no refresh token.

## 4. Protect your routes

Your app owns the session - the SDK holds nothing. Check your session store at the start of any route that requires authentication and redirect to your login page when the session is absent.

```python
user = your_session.get("user")
if not user:
return redirect("/login")

your_template.render(user=user)
```

> [!IMPORTANT]
> Do not store an Auth0 access or ID token in your session. Store only the claims you need (for example `sub`, `email`, `org_id`). The browser must never see an Auth0 token.

## 5. Organizations and multi-tenant apps

Auth0 stamps the resolved organization into the token as `org_id`, available on `result["user"]`. The SDK surfaces it but does not enforce it. It cannot know which organization *your* app expected for this user.

> [!WARNING]
> Validate `org_id` after every callback, regardless of routing. WebFinger discovery and `login_hint` are routing mechanisms, not security controls. On their own they do not prove the user belongs to a customer you serve. Read `org_id` from the returned claims and check it against your own record of known organizations before creating the session. Without this check, a user who authenticates through any managed connection could obtain a session in a context you did not intend. This is an authorization decision your app owns.

```python
user = result["user"]
if user.org_id not in allowed_orgs_for(current_customer):
raise Forbidden("user does not belong to this organization")
```

If you serve exactly one organization, this is a single check against your one known org, not a reason to skip it. An app that skips it today can silently let users in from other tenants the day it onboards a second customer.

## 6. Logout

Clear your own application session first, then send the user to the Auth0 logout URL.

```python
from auth0_server_python.auth_types import LogoutOptions

destroy_app_session()

logout_url = await server_client.logout(
LogoutOptions(return_to="https://app.example.com/login"),
store_options={"request": request, "response": response},
)
return redirect(logout_url)
```

By default this ends the Auth0 session but leaves the upstream identity provider session intact, so the user is not re-prompted at their IdP on the next login. To also terminate the IdP session, pass `federated=True`.

Federated logout ends the corporate IdP session itself, which can also sign the user out of other applications that share that same enterprise SSO, not just yours. Weigh that against the shared-device benefit before enabling it by default.

```python
logout_url = await server_client.logout(
LogoutOptions(return_to="https://app.example.com/login", federated=True),
)
```

> [!NOTE]
> `return_to` must be an absolute URL on your tenant's Allowed Logout URLs list. Auth0 rejects a URL that is not allow-listed.

## What is not available in Enterprise Connect

These members work in Enterprise Connect mode:

| Member | Notes |
|---|---|
| `start_enterprise_login()` | EC login entry point |
| `start_interactive_login()` | Writes the transaction store only |
| `complete_interactive_login()` | Returns verified claims without persisting a session |
| `logout()` | Clears transaction state and returns the Auth0 logout URL |
| `custom_token_exchange()` | Works once, while the callback access token is valid. No refresh after it expires |
| `handle_backchannel_logout()` | No-op. The SDK holds no session to revoke |

Everything else raises `EnterpriseConnectError`. Branch on `code`:
- `enterprise_connect_session_unavailable` - `get_session()` was called
- `enterprise_connect_access_token_unavailable` - `get_access_token()` was called. Read the token from `complete_interactive_login()` instead
- `enterprise_connect_method_unavailable` - any other session or refresh-dependent member was called

Own the session and any token refresh in your app.

## Error Handling

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

try:
result = await server_client.complete_interactive_login(
str(request.url),
store_options={"request": request, "response": response},
)
except ApiError as e:
return {"error": e.code}

try:
await server_client.get_access_token()
except EnterpriseConnectError as e:
return {"error": e.code}
```

Errors you may see:

- `EnterpriseConnectError` - a session or token method is unavailable in this mode. Branch on `code`:
- `enterprise_connect_session_unavailable` - `get_session()` was called
- `enterprise_connect_access_token_unavailable` - `get_access_token()` was called
- `enterprise_connect_method_unavailable` - any other session or refresh dependent member was called
- `ApiError` - the token exchange failed, or the login returned no verifiable claims (`invalid_response`)
1 change: 1 addition & 0 deletions references/docs-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@ it from `README.md`'s section for the feature. Name the file after the flow, mat
| MCD domain resolver | `examples/MultipleCustomDomains.md` |
| Account linking / unlinking | `examples/UserLinking.md` |
| Passwordless email/SMS OTP + magic link | `examples/Passwordless.md` |
| Enterprise Connect embedded login (`enterprise_connect`, `start_enterprise_login`, `is_federated_domain`) | `examples/EnterpriseConnect.md` |
1 change: 1 addition & 0 deletions references/flow-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Before working on a flow, read its entry points and supporting modules. Every fl
| Passkeys | `passkey_signup_challenge`, `passkey_login_challenge`, `signin_with_passkey` | `auth_schemes/dpop_auth.py` — passkey sign-in is the DPoP-bound path | `examples/Passkeys.md` |
| My Account | `MyAccountClient` (factors, authentication methods, enroll/verify) | `auth_schemes/dpop_auth.py`; stateless — every call takes a user token | `examples/MyAccountAuthenticationMethods.md` |
| MCD | any flow — `domain` may be an async resolver | `_resolve_current_domain`, pitfall 5 in `references/pitfalls.md` | `examples/MultipleCustomDomains.md` |
| Enterprise Connect | `start_enterprise_login`, `complete_interactive_login` (EC branch), `is_federated_domain` (standalone), `logout` (`federated`) | `auth_types/` (`StartEnterpriseLoginOptions`, `LogoutOptions.federated`), `error/` (`EnterpriseConnectError`); the SDK owns no session in this mode | `examples/EnterpriseConnect.md` |

Two rules cut across every flow above, so check them on any change here: resolve the domain through
`await self._resolve_current_domain(store_options)` rather than reading `self._domain`, and accept
Expand Down
10 changes: 8 additions & 2 deletions src/auth0_server_python/auth_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from .mfa_client import MfaClient
from .my_account_client import MyAccountClient
from .passwordless_client import PasswordlessClient
from .server_client import ServerClient
from .server_client import ServerClient, is_federated_domain

__all__ = ["ServerClient", "MyAccountClient", "MfaClient", "PasswordlessClient"]
__all__ = [
"ServerClient",
"MyAccountClient",
"MfaClient",
"PasswordlessClient",
"is_federated_domain",
]
Loading
Loading