From c49d8508be6bd66800052557315da04047efb5de Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 25 May 2026 00:48:12 +0530 Subject: [PATCH 01/17] Adding organisation feature parity changes. --- .gitignore | 77 +- .../auth_server/server_client.py | 160 ++- .../auth_types/__init__.py | 14 +- src/auth0_server_python/error/__init__.py | 14 +- .../tests/test_server_client.py | 1076 ++++++++++++++++- 5 files changed, 1259 insertions(+), 82 deletions(-) diff --git a/.gitignore b/.gitignore index caa3e08..418eed6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,66 @@ -### Python ### -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] *$py.class +*.so +*.egg +*.egg-info/ +dist/ +build/ +eggs/ +parts/ +var/ +sdist/ +wheels/ +*.egg-link +MANIFEST -#Environments -.env -.venv +# Virtual environments +.venv/ +.venv-*/ +venv/ env/ +ENV/ -#Session Cache -.sessions_cache -.DS_Store - -#Build files -dist -docs - -#testfile -server.py -setup.py -test.py -test-script.py +# Testing & coverage +.pytest_cache/ .coverage +.coverage.* coverage.xml +htmlcov/ +.tox/ +nosetests.xml +pytest-cache/ + +# Type checking +.mypy_cache/ +.dmypy.json +.pytype/ + +# Distribution / packaging +*.spec +pip-wheel-metadata/ +share/python-wheels/ + +# Jupyter +.ipynb_checkpoints + +# Environment files +.env +.env.* +!.env.example + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# macOS +.DS_Store +.AppleDouble +.LSOverride -# AI tools -.claude \ No newline at end of file +# Logs +*.log diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 91de45d..9518c9a 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -54,6 +54,7 @@ MfaRequiredError, MissingRequiredArgumentError, MissingTransactionError, + OrganizationTokenValidationError, PollingApiError, StartLinkUserError, ) @@ -79,9 +80,7 @@ class ServerClient(Generic[TStoreOptions]): """ DEFAULT_AUDIENCE_STATE_KEY = "default" - # ============================================================================ # INITIALIZATION - # ============================================================================ def __init__( self, @@ -96,6 +95,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. @@ -112,6 +112,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") @@ -146,6 +149,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 @@ -207,6 +211,40 @@ def _normalize_url(self, value: str) -> str: return value.rstrip('/') + def _validate_org_claims(self, claims: dict, expected_org: str) -> None: + """ + Validate org_id or org_name in token claims against the requested organization. + + Uses expected_org prefix to determine which claim to check: + - 'org_' prefix → validate claims['org_id'] exact match + - no prefix → validate claims['org_name'] case-insensitive match + + Raises: + OrganizationTokenValidationError: if the claim is missing or mismatched. + """ + if expected_org.startswith("org_"): + actual = claims.get("org_id") + if not isinstance(actual, str): + raise OrganizationTokenValidationError( + "Organization Id (org_id) claim must be a string present in the ID token" + ) + if actual != expected_org: + raise OrganizationTokenValidationError( + f"Organization Id (org_id) claim value mismatch in the ID token; " + f"expected {expected_org}, found {actual}" + ) + else: + actual = claims.get("org_name") + if not isinstance(actual, str): + raise OrganizationTokenValidationError( + "Organization Name (org_name) claim must be a string present in the ID token" + ) + if actual.lower() != expected_org.lower(): + raise OrganizationTokenValidationError( + f"Organization Name (org_name) claim value mismatch in the ID token; " + f"expected {expected_org}, found {actual}" + ) + async def _resolve_current_domain(self, store_options=None) -> str: """Resolve domain from resolver function or return static domain.""" if self._domain_resolver: @@ -435,11 +473,7 @@ async def _get_jwks_cached(self, domain: str, metadata: dict = None) -> dict: return jwks - # ============================================================================ # INTERACTIVE LOGIN FLOW - # Handles browser-based authentication using the Authorization Code flow - # with PKCE for secure token exchange. - # ============================================================================ async def start_interactive_login( self, @@ -502,6 +536,15 @@ async def start_interactive_login( merged_scope = self._merge_scope_with_defaults(requested_scope, audience) auth_params["scope"] = merged_scope + # Resolve organization: per-login value takes precedence over client-level default. + resolved_org = options.organization or self._organization + if resolved_org: + auth_params["organization"] = resolved_org + + # Invitation is forwarded to /authorize but not stored for callback validation. + if options.invitation: + auth_params["invitation"] = options.invitation + # Build the transaction data to store with domain transaction_data = TransactionData( code_verifier=code_verifier, @@ -509,6 +552,7 @@ async def start_interactive_login( audience=audience, domain=origin_domain, redirect_uri=auth_params.get("redirect_uri"), + organization=resolved_org, ) # Store the transaction data @@ -638,8 +682,12 @@ 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 user_info: + # Org validation on the userinfo path — claims come from userinfo dict. + if expected_org: + self._validate_org_claims(user_info, expected_org) user_claims = UserClaims.parse_obj(user_info) elif id_token: # Fetch JWKS for signature verification @@ -656,6 +704,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: + self._validate_org_claims(claims, expected_org) + user_claims = UserClaims.parse_obj(claims) except ValueError as e: raise ApiError("jwks_key_not_found", str(e)) @@ -729,10 +781,7 @@ async def complete_interactive_login( return result - # ============================================================================ # USER SESSION MANAGEMENT - # Methods for retrieving user information, session data, and logout operations. - # ============================================================================ async def get_user(self, store_options: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]: """ @@ -916,10 +965,7 @@ async def handle_backchannel_logout( raise BackchannelLogoutError( f"Error processing logout token: {str(e)}") - # ============================================================================ # ACCESS TOKEN MANAGEMENT - # Retrieves, validates, and refreshes access tokens for API calls. - # ============================================================================ async def get_access_token( self, @@ -1002,6 +1048,16 @@ async def get_access_token( if merged_scope: get_refresh_token_options["scope"] = merged_scope + # Carry org context so refreshed tokens include org_id/org_name claims. + # Use org_id (stable) rather than org_name (mutable). + user_dict = state_data_dict.get("user") or {} + if isinstance(user_dict, dict): + session_org_id = user_dict.get("org_id") + else: + session_org_id = getattr(user_dict, "org_id", None) + if session_org_id is not None: + get_refresh_token_options["organization"] = session_org_id + token_endpoint_response = await self.get_token_by_refresh_token(get_refresh_token_options) # Update state data with new token @@ -1090,6 +1146,10 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, if merged_scope: token_params["scope"] = merged_scope + organization = options.get("organization") + if organization is not None: + token_params["organization"] = organization + # Exchange the refresh token for an access token async with self._get_http_client() as client: response = await client.post( @@ -1128,10 +1188,23 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, token_response["expires_at"] = int( time.time()) + token_response["expires_in"] + # Validate org claims in the refreshed ID token when org context was sent. + # This ensures a refresh cannot silently downgrade or change org membership. + refresh_id_token = token_response.get("id_token") + if organization is not None and refresh_id_token: + refresh_jwks = await self._get_jwks_cached(domain, metadata) + try: + refresh_claims = await self._verify_and_decode_jwt( + refresh_id_token, refresh_jwks, audience=self._client_id + ) + except Exception as e: + raise ApiError("invalid_token", f"Refresh ID token verification failed: {str(e)}", e) + self._validate_org_claims(refresh_claims, organization) + return token_response except Exception as e: - if isinstance(e, ApiError): + if isinstance(e, (ApiError, OrganizationTokenValidationError)): raise raise AccessTokenError( AccessTokenErrorCode.REFRESH_TOKEN_ERROR, @@ -1185,11 +1258,7 @@ def _find_matching_token_set( # Return the token set with the smallest superset of scopes that matches the requested audience and scopes return min(matches, key=lambda t: t[0])[1] if matches else None - # ============================================================================ # BACKCHANNEL AUTHENTICATION (CIBA) - # Client-Initiated Backchannel Authentication for decoupled authentication - # flows where users authenticate on a separate device. - # ============================================================================ async def login_backchannel( self, @@ -1213,10 +1282,12 @@ async def login_backchannel( Returns: A dictionary containing the authorizationDetails (when RAR was used). """ + resolved_org = options.get("organization") or self._organization token_endpoint_response = await self.backchannel_authentication({ "binding_message": options.get("binding_message"), "login_hint": options.get("login_hint"), "authorization_params": options.get("authorization_params"), + "organization": resolved_org, }, store_options=store_options) existing_state_data = await self._state_store.get(self._state_identifier, store_options) @@ -1275,6 +1346,7 @@ async def backchannel_authentication( "expires_in", 120) # Default to 2 minutes interval = backchannel_data.get( "interval", 5) # Default to 5 seconds + organization = options.get("organization") # Calculate when to stop polling end_time = time.time() + expires_in @@ -1283,7 +1355,11 @@ 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, + organization=organization, + store_options=store_options, + ) return token_response except Exception as e: @@ -1296,6 +1372,8 @@ async def backchannel_authentication( # Wait for the specified interval before polling again await asyncio.sleep(e.interval or interval) continue + if isinstance(e, OrganizationTokenValidationError): + raise raise ApiError( "backchannel_error", f"Backchannel authentication failed: {str(e) or 'Unknown error'}", @@ -1404,6 +1482,12 @@ async def initiate_backchannel_authentication( if authorization_params: params.update(authorization_params) + # Organization: per-request value already resolved upstream in login_backchannel. + # Accept it here so it reaches the bc-authorize request body. + backchannel_org = options.get("organization") + if backchannel_org: + params["organization"] = backchannel_org + # Make the backchannel authentication request async with self._get_http_client() as client: backchannel_response = await client.post( @@ -1443,6 +1527,7 @@ async def initiate_backchannel_authentication( async def backchannel_authentication_grant( self, auth_req_id: str, + organization: Optional[str] = None, store_options: Optional[dict[str, Any]] = None ) -> dict[str, Any]: """ @@ -1450,6 +1535,7 @@ async def backchannel_authentication_grant( Args: auth_req_id (str): The authentication request ID obtained from bc-authorize + organization: Organization value used at CIBA initiation, for ID token validation. store_options: Optional options used to pass to the Transaction and State Store. Raises: @@ -1511,10 +1597,29 @@ async def backchannel_authentication_grant( token_response["expires_at"] = int( time.time()) + token_response["expires_in"] + # Validate org claims in the ID token when an org was requested. + # If org was requested but no id_token was returned, fail closed — + # we cannot verify org membership without an id_token. + id_token = token_response.get("id_token") + if organization: + if not id_token: + raise OrganizationTokenValidationError( + "Organization was requested but the token response did not include an ID token; " + "cannot verify organization membership" + ) + jwks = await self._get_jwks_cached(domain) + try: + ciba_claims = await self._verify_and_decode_jwt( + id_token, jwks, audience=self._client_id + ) + except Exception as e: + raise ApiError("invalid_token", f"CIBA ID token verification failed: {str(e)}", e) + self._validate_org_claims(ciba_claims, organization) + return token_response except Exception as e: - if isinstance(e, (ApiError, PollingApiError)): + if isinstance(e, (ApiError, PollingApiError, OrganizationTokenValidationError)): raise raise AccessTokenError( AccessTokenErrorCode.AUTH_REQ_ID_ERROR, @@ -1522,11 +1627,7 @@ async def backchannel_authentication_grant( e ) - # ============================================================================ # USER LINKING / UNLINKING - # Methods for linking and unlinking external identity provider accounts - # to a user's Auth0 profile. - # ============================================================================ async def start_link_user( self, @@ -1797,11 +1898,7 @@ async def _build_unlink_user_url( return URL.build_url(auth_endpoint, params) - # ============================================================================ # FEDERATED CONNECTION TOKENS - # Retrieves access tokens for federated identity provider connections - # (e.g., Google, GitHub) using token exchange. - # ============================================================================ async def get_access_token_for_connection( self, @@ -1968,11 +2065,7 @@ async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, A e ) - # ============================================================================ # CONNECTED ACCOUNTS - # Methods for managing third-party account connections via the My Account API. - # Includes initiating connections, completing flows, and CRUD operations. - # ============================================================================ async def start_connect_account( self, @@ -2199,10 +2292,7 @@ async def list_connected_account_connections( return await self._my_account_client.list_connected_account_connections( access_token=access_token, from_param=from_param, take=take) - # ============================================================================ # CUSTOM TOKEN EXCHANGE (RFC 8693) - # Exchanges external custom tokens for Auth0 tokens. - # ============================================================================ async def custom_token_exchange( self, @@ -2475,9 +2565,7 @@ async def login_with_custom_token_exchange( e ) - # ============================================================================ # MFA (Multi-Factor Authentication) - # ============================================================================ @property def mfa(self) -> MfaClient: diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 055103a..57b4b91 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -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 @@ -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 @@ -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): @@ -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): @@ -191,6 +196,7 @@ class LoginBackchannelOptions(BaseModel): binding_message: str login_hint: dict[str, str] # Should contain a 'sub' field authorization_params: Optional[dict[str, Any]] = None + organization: Optional[str] = None class Config: extra = "allow" # Allow additional fields not defined in the model @@ -216,9 +222,7 @@ class StartLinkUserOptions(BaseModel): authorization_params: Optional[dict[str, Any]] = None app_state: Optional[Any] = None -# ============================================================================= # Multiple Custom Domain -# ============================================================================= class DomainResolverContext(BaseModel): """ @@ -239,9 +243,7 @@ async def domain_resolver(context: DomainResolverContext) -> str: request_url: Optional[str] = None request_headers: Optional[dict[str, str]] = None -# ============================================================================= # Custom Token Exchange Types -# ============================================================================= class CustomTokenExchangeOptions(BaseModel): """ @@ -350,9 +352,7 @@ class LoginWithCustomTokenExchangeResult(BaseModel): state_data: dict[str, Any] authorization_details: Optional[list[AuthorizationDetails]] = None -# ============================================================================= # Connected Accounts Types -# ============================================================================= # BASE & SHARED class ConnectedAccountBase(BaseModel): @@ -425,9 +425,7 @@ class ListConnectedAccountConnectionsResponse(BaseModel): next: Optional[str] = None -# ============================================================================= # MFA Types -# ============================================================================= # Type aliases using Literal types AuthenticatorType = Literal["otp", "oob", "recovery-code"] diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index db4f28e..f3e7660 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -185,6 +185,18 @@ def __init__(self, message: str): self.name = "StartLinkUserError" +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" + + # Error code enumerations - these can be used to identify specific error scenarios class AccessTokenErrorCode: @@ -229,9 +241,7 @@ class CustomTokenExchangeErrorCode: INVALID_RESPONSE = "invalid_response" -# ============================================================================= # MFA Error Classes -# ============================================================================= class MfaApiError(Auth0Error): """Base class for MFA API errors.""" diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 47ba774..5160c5b 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -24,6 +24,7 @@ LoginWithCustomTokenExchangeOptions, LogoutOptions, MfaRequirements, + StartInteractiveLoginOptions, StateData, TransactionData, ) @@ -43,6 +44,7 @@ MfaRequiredError, MissingRequiredArgumentError, MissingTransactionError, + OrganizationTokenValidationError, PollingApiError, StartLinkUserError, ) @@ -2320,9 +2322,7 @@ async def test_get_token_by_refresh_token_exchange_failed(mocker): args, kwargs = mock_post.call_args assert kwargs["data"]["refresh_token"] == "" -# ============================================================================= # Connected Accounts Tests (My Account Client) -# ============================================================================= @pytest.mark.asyncio @@ -2856,9 +2856,7 @@ async def test_list_connected_account_connections_with_invalid_take_param(mocker assert "The 'take' parameter must be a positive integer." in str(exc.value) mock_my_account_client.list_connected_account_connections.assert_not_awaited() -# ============================================================================= # Custom Token Exchange Tests -# ============================================================================= @pytest.mark.asyncio async def test_custom_token_exchange_success(mocker): @@ -3351,9 +3349,7 @@ async def test_custom_token_exchange_forbidden_params_filtered(mocker): assert call_args[1]["data"]["allowed_param"] == "value" -# ============================================================================= # Login with Custom Token Exchange Tests -# ============================================================================= @pytest.mark.asyncio async def test_login_with_custom_token_exchange_success(mocker): @@ -3541,9 +3537,7 @@ async def test_login_with_custom_token_exchange_failure_propagates(mocker): assert exc.value.code == "unauthorized" -# ============================================================================= # OIDC Metadata and JWKS Fetching Tests -# ============================================================================= @pytest.mark.asyncio @@ -3844,9 +3838,7 @@ async def mock_fetch(domain): assert "domain3.auth0.com" in client._discovery_cache -# ============================================================================= # Issuer Validation Tests -# ============================================================================= @pytest.mark.asyncio @@ -4050,9 +4042,7 @@ async def test_normalize_url_handles_edge_cases(): assert client._normalize_url(None) is None -# ============================================================================= # MCD Tests : Multiple Issuer Configuration Methods Tests -# ============================================================================= @pytest.mark.asyncio async def test_domain_as_static_string(): @@ -4121,9 +4111,7 @@ async def test_empty_domain_string(): ) -# ============================================================================= # MCD Tests : Domain Resolver Context Tests -# ============================================================================= @pytest.mark.asyncio async def test_domain_resolver_receives_context(mocker): @@ -4336,9 +4324,7 @@ async def resolver_with_scheme(context): assert user["sub"] == "user123" -# ============================================================================= # MCD Tests : Domain-specific Session Management Tests -# ============================================================================= @pytest.mark.asyncio @@ -4816,3 +4802,1061 @@ async def _fake_fetch(self, domain): assert exc.value.mfa_requirements is not None finally: ServerClient._fetch_oidc_metadata = original_fetch + + +# ORGANIZATIONS SUPPORT TESTS + +def _make_org_client(mocker, transaction_data: TransactionData, **extra): + """Helper: build a ServerClient with mocked stores and standard JWT mocks.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = transaction_data + mock_state_store = AsyncMock() + + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + **extra + ) + + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + "authorization_endpoint": "https://tenant.auth0.com/authorize", + } + ) + mocker.patch.object( + client, + "_get_jwks_cached", + return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} + ) + async_fetch_token = AsyncMock(return_value={ + "access_token": "at123", + "id_token": "id_token_jwt", + "scope": "openid profile", + }) + mocker.patch.object(client._oauth, "fetch_token", async_fetch_token) + mocker.patch("jwt.get_unverified_header", return_value={"kid": "test-key"}) + mock_signing_key = mocker.MagicMock() + mock_signing_key.key = "mock_pem_key" + mocker.patch("jwt.PyJWK.from_dict", return_value=mock_signing_key) + return client + + +# -------------------------------------------------------------------------- +# ORG-001: org by ID — matching org_id succeeds +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_by_id_matching_claim_succeeds(mocker): + """ORG-001: Token with matching org_id passes validation.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", + "aud": "test_client", "org_id": "org_abc123", + }) + result = await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert result["state_data"]["user"]["org_id"] == "org_abc123" + + +# -------------------------------------------------------------------------- +# ORG-002: org by ID — missing org_id claim raises error +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_by_id_missing_claim_raises(mocker): + """ORG-002: Token missing org_id raises OrganizationTokenValidationError.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + # no org_id + }) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "org_id" in str(exc.value) + assert "must be a string present" in str(exc.value) + + +# -------------------------------------------------------------------------- +# ORG-003: org by ID — wrong org_id raises error with detail +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_by_id_wrong_claim_raises(mocker): + """ORG-003: Token with wrong org_id raises OrganizationTokenValidationError with mismatch detail.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_id": "org_attacker", + }) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "mismatch" in str(exc.value) + assert "org_abc123" in str(exc.value) + assert "org_attacker" in str(exc.value) + + +# -------------------------------------------------------------------------- +# ORG-004: org by ID — null org_id claim raises error +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_by_id_null_claim_raises(mocker): + """ORG-004: Token with null org_id raises OrganizationTokenValidationError.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_id": None, + }) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "org_id" in str(exc.value) + + +# -------------------------------------------------------------------------- +# ORG-005: org by name — exact case match succeeds +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_by_name_exact_match_succeeds(mocker): + """ORG-005: Token with matching org_name (exact case) passes validation.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="acme-corp"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_name": "acme-corp", + }) + result = await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert result is not None + + +# -------------------------------------------------------------------------- +# ORG-006: org by name — case-insensitive match succeeds +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_by_name_case_insensitive_match_succeeds(mocker): + """ORG-006: Token with org_name differing only in case passes validation.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="ACME-CORP"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_name": "acme-corp", + }) + result = await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert result is not None + + +# -------------------------------------------------------------------------- +# ORG-007: org by name — missing org_name claim raises error +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_by_name_missing_claim_raises(mocker): + """ORG-007: Token missing org_name raises OrganizationTokenValidationError.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="acme-corp"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + # no org_name + }) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "org_name" in str(exc.value) + assert "must be a string present" in str(exc.value) + + +# -------------------------------------------------------------------------- +# ORG-008: org by name — wrong org_name raises error with detail +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_by_name_wrong_claim_raises(mocker): + """ORG-008: Token with wrong org_name raises OrganizationTokenValidationError with detail.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="acme-corp"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_name": "evil-corp", + }) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "mismatch" in str(exc.value) + assert "acme-corp" in str(exc.value) + assert "evil-corp" in str(exc.value) + + +# -------------------------------------------------------------------------- +# ORG-009: no org requested — token with org_id is not rejected +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_no_org_requested_token_with_org_id_passes(mocker): + """ORG-009: When no org was requested, tokens carrying org_id must not be rejected.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com"), # no organization + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_id": "org_abc123", "org_name": "acme", + }) + result = await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert result["state_data"]["user"]["org_id"] == "org_abc123" + assert result["state_data"]["user"]["org_name"] == "acme" + + +# -------------------------------------------------------------------------- +# ORG-010: no org requested — plain token passes +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_no_org_requested_plain_token_passes(mocker): + """ORG-010: When no org was requested, a token without org claims passes normally.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + }) + result = await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert result is not None + + +# -------------------------------------------------------------------------- +# ORG-011: invitation and org forwarded to /authorize +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_invitation_and_org_forwarded_to_authorize(mocker): + """ORG-011: organization and invitation appear in the authorization URL.""" + mock_tx_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "authorization_endpoint": "https://tenant.auth0.com/authorize", + }) + + url = await client.start_interactive_login( + StartInteractiveLoginOptions( + organization="org_abc123", + invitation="inv_token_xyz", + ) + ) + + assert "organization=org_abc123" in url + assert "invitation=inv_token_xyz" in url + + # Confirm transaction stores the organization + stored = mock_tx_store.set.call_args[0][1] + assert stored.organization == "org_abc123" + + +# -------------------------------------------------------------------------- +# ORG-012: invitation without org forwarded to /authorize +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_invitation_without_org_forwarded_to_authorize(mocker): + """ORG-012: invitation alone appears in the URL; no organization param.""" + mock_tx_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "authorization_endpoint": "https://tenant.auth0.com/authorize", + }) + + url = await client.start_interactive_login( + StartInteractiveLoginOptions(invitation="inv_token_xyz") + ) + + assert "invitation=inv_token_xyz" in url + assert "organization=" not in url + + +# -------------------------------------------------------------------------- +# ORG-013: per-login org overrides client-level org +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_per_login_org_overrides_client_org(mocker): + """ORG-013: Per-login organization overrides the client-level default.""" + mock_tx_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + organization="org_default", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "authorization_endpoint": "https://tenant.auth0.com/authorize", + }) + + url = await client.start_interactive_login( + StartInteractiveLoginOptions(organization="org_override") + ) + + assert "organization=org_override" in url + assert "org_default" not in url + + stored = mock_tx_store.set.call_args[0][1] + assert stored.organization == "org_override" + + +# -------------------------------------------------------------------------- +# ORG-014: client-level org used when login options has no org +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_client_level_org_used_when_no_per_login_org(mocker): + """ORG-014: Client-level organization is used when no per-login org is set.""" + mock_tx_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + organization="org_default", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "authorization_endpoint": "https://tenant.auth0.com/authorize", + }) + + url = await client.start_interactive_login(StartInteractiveLoginOptions()) + + assert "organization=org_default" in url + stored = mock_tx_store.set.call_args[0][1] + assert stored.organization == "org_default" + + +# -------------------------------------------------------------------------- +# ORG-015: org_name present in UserClaims after successful org login +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_name_present_in_user_claims_after_org_login(mocker): + """ORG-015: org_id and org_name both surface in session user claims.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_id": "org_abc123", "org_name": "acme-corp", + }) + result = await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + user = result["state_data"]["user"] + assert user["org_id"] == "org_abc123" + assert user["org_name"] == "acme-corp" + + +# -------------------------------------------------------------------------- +# ORG-016: refresh token carries org context +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_refresh_token_includes_org_from_session(mocker): + """ORG-016: get_access_token passes organization to the refresh token request.""" + mock_state_store = AsyncMock() + mock_state_store.get.return_value = { + "user": {"sub": "u1", "org_id": "org_abc123"}, + "refresh_token": "rt_xyz", + "token_sets": [], + "domain": "tenant.auth0.com", + } + + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + + mock_refresh = AsyncMock(return_value={ + "access_token": "new_at", + "expires_in": 3600, + "expires_at": int(time.time()) + 3600, + }) + mocker.patch.object(client, "get_token_by_refresh_token", mock_refresh) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + }) + + await client.get_access_token() + + call_options = mock_refresh.call_args[0][0] + assert call_options.get("organization") == "org_abc123" + + +# -------------------------------------------------------------------------- +# ORG-017: refresh without org context — no org in request +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_refresh_token_no_org_when_no_session_org(mocker): + """ORG-017: When session has no org_id, no organization param in refresh request.""" + mock_state_store = AsyncMock() + mock_state_store.get.return_value = { + "user": {"sub": "u1"}, + "refresh_token": "rt_xyz", + "token_sets": [], + "domain": "tenant.auth0.com", + } + + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + + mock_refresh = AsyncMock(return_value={ + "access_token": "new_at", + "expires_in": 3600, + "expires_at": int(time.time()) + 3600, + }) + mocker.patch.object(client, "get_token_by_refresh_token", mock_refresh) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + }) + + await client.get_access_token() + + call_options = mock_refresh.call_args[0][0] + assert "organization" not in call_options + + +# -------------------------------------------------------------------------- +# ORG-018: refresh uses org_id not org_name +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_refresh_uses_org_id_not_org_name(mocker): + """ORG-018: Refresh token request uses org_id (stable), not org_name (mutable).""" + mock_state_store = AsyncMock() + mock_state_store.get.return_value = { + "user": {"sub": "u1", "org_id": "org_abc123", "org_name": "acme-corp"}, + "refresh_token": "rt_xyz", + "token_sets": [], + "domain": "tenant.auth0.com", + } + + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + + mock_refresh = AsyncMock(return_value={ + "access_token": "new_at", + "expires_in": 3600, + "expires_at": int(time.time()) + 3600, + }) + mocker.patch.object(client, "get_token_by_refresh_token", mock_refresh) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + }) + + await client.get_access_token() + + call_options = mock_refresh.call_args[0][0] + assert call_options.get("organization") == "org_abc123" + + +# -------------------------------------------------------------------------- +# Adversarial tests +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_adv_org_id_is_integer_not_string_raises(mocker): + """ADV-001: org_id claim as integer (not string) raises OrganizationTokenValidationError.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_id": 12345, + }) + with pytest.raises(OrganizationTokenValidationError): + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + + +@pytest.mark.asyncio +async def test_adv_org_name_is_array_raises(mocker): + """ADV-002: org_name claim as array raises OrganizationTokenValidationError.""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="acme"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_name": ["acme", "other"], + }) + with pytest.raises(OrganizationTokenValidationError): + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + + +@pytest.mark.asyncio +async def test_adv_empty_string_org_id_raises(mocker): + """ADV-004: Empty string org_id claim raises OrganizationTokenValidationError (mismatch).""" + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_id": "", + }) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "mismatch" in str(exc.value) + + +@pytest.mark.asyncio +async def test_adv_org_in_untyped_dict_does_not_leak_into_transaction(mocker): + """ADV-003: org in authorization_params dict is overridden by typed organization field.""" + mock_tx_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "authorization_endpoint": "https://tenant.auth0.com/authorize", + }) + + # Typed organization wins; untyped dict has attacker value + url = await client.start_interactive_login( + StartInteractiveLoginOptions( + organization="org_legitimate", + authorization_params={"organization": "org_attacker"}, + ) + ) + + # TransactionData stores the typed value + stored = mock_tx_store.set.call_args[0][1] + assert stored.organization == "org_legitimate" + + # URL must not contain the attacker value + from urllib.parse import parse_qs, urlparse + parsed = parse_qs(urlparse(url).query) + org_values = parsed.get("organization", []) + assert "org_attacker" not in org_values + assert "org_legitimate" in org_values + + +# -------------------------------------------------------------------------- +# Error class properties +# -------------------------------------------------------------------------- + +def test_organization_token_validation_error_code(): + """OrganizationTokenValidationError has the correct code and message.""" + err = OrganizationTokenValidationError("test message") + assert err.code == "organization_token_validation_error" + assert err.name == "OrganizationTokenValidationError" + assert str(err) == "test message" + + +# -------------------------------------------------------------------------- +# ORG-019: userinfo path — org_id validated when org requested +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_userinfo_path_matching_org_id_succeeds(mocker): + """ORG-019: userinfo response with matching org_id passes validation.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123" + ) + mock_state_store = AsyncMock() + mock_state_store.get.return_value = None + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + secret="test_secret_key_32_chars_long!!", + transaction_store=mock_tx_store, + state_store=mock_state_store, + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } + ) + # Token response returns userinfo (no id_token) + mocker.patch.object(client._oauth, "fetch_token", AsyncMock(return_value={ + "access_token": "at123", + "userinfo": {"sub": "u1", "org_id": "org_abc123"}, + })) + result = await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert result["state_data"]["user"]["org_id"] == "org_abc123" + + +@pytest.mark.asyncio +async def test_org_userinfo_path_wrong_org_id_raises(mocker): + """ORG-020: userinfo response with wrong org_id raises OrganizationTokenValidationError.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123" + ) + mock_state_store = AsyncMock() + mock_state_store.get.return_value = None + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + secret="test_secret_key_32_chars_long!!", + transaction_store=mock_tx_store, + state_store=mock_state_store, + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } + ) + mocker.patch.object(client._oauth, "fetch_token", AsyncMock(return_value={ + "access_token": "at123", + "userinfo": {"sub": "u1", "org_id": "org_different"}, + })) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "mismatch" in str(exc.value) + assert "org_abc123" in str(exc.value) + + +@pytest.mark.asyncio +async def test_org_userinfo_path_missing_org_id_raises(mocker): + """ORG-021: userinfo response missing org_id raises OrganizationTokenValidationError.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123" + ) + mock_state_store = AsyncMock() + mock_state_store.get.return_value = None + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + secret="test_secret_key_32_chars_long!!", + transaction_store=mock_tx_store, + state_store=mock_state_store, + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } + ) + mocker.patch.object(client._oauth, "fetch_token", AsyncMock(return_value={ + "access_token": "at123", + "userinfo": {"sub": "u1"}, # no org_id + })) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "must be a string present" in str(exc.value) + + +# -------------------------------------------------------------------------- +# ORG-022: CIBA — org_id validated in grant response +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ciba_org_id_matching_succeeds(mocker): + """ORG-022: backchannel_authentication_grant with matching org_id passes.""" + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + secret="some-secret" + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={"token_endpoint": "https://auth0.local/token"} + ) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={ + "access_token": "at_ciba", + "id_token": "id_token_jwt", + "expires_in": 3600, + }) + mock_response.headers = {} + mock_post.return_value = mock_response + + mocker.patch.object( + client, "_get_jwks_cached", + return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} + ) + mocker.patch.object( + client, "_verify_and_decode_jwt", + return_value={"sub": "u1", "org_id": "org_abc123"} + ) + + result = await client.backchannel_authentication_grant( + "auth_req_123", organization="org_abc123" + ) + assert result["access_token"] == "at_ciba" + + +# -------------------------------------------------------------------------- +# ORG-023: CIBA — org_id mismatch raises OrganizationTokenValidationError +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ciba_org_id_mismatch_raises(mocker): + """ORG-023: backchannel_authentication_grant with wrong org_id raises OrganizationTokenValidationError.""" + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + secret="some-secret" + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={"token_endpoint": "https://auth0.local/token"} + ) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={ + "access_token": "at_ciba", + "id_token": "id_token_jwt", + "expires_in": 3600, + }) + mock_response.headers = {} + mock_post.return_value = mock_response + + mocker.patch.object( + client, "_get_jwks_cached", + return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} + ) + mocker.patch.object( + client, "_verify_and_decode_jwt", + return_value={"sub": "u1", "org_id": "org_different"} + ) + + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.backchannel_authentication_grant( + "auth_req_123", organization="org_abc123" + ) + assert "mismatch" in str(exc.value) + assert "org_abc123" in str(exc.value) + + +# -------------------------------------------------------------------------- +# ORG-024: CIBA — client-level org propagates through login_backchannel +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ciba_client_level_org_propagates(mocker): + """ORG-024: Client-level organization propagates through login_backchannel.""" + mock_state_store = AsyncMock() + mock_state_store.get.return_value = {"token_sets": []} + + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + secret="some-secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + organization="org_client_level", + ) + + mock_backchannel = AsyncMock(return_value={ + "access_token": "at_ciba", + "expires_in": 3600, + }) + mocker.patch.object(client, "backchannel_authentication", mock_backchannel) + + await client.login_backchannel({ + "binding_message": "Approve login", + "login_hint": {"sub": "user1"}, + }) + + called_options = mock_backchannel.call_args[0][0] + assert called_options.get("organization") == "org_client_level" + + +# -------------------------------------------------------------------------- +# ORG-025: CIBA — missing id_token with org set fails closed +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ciba_org_requested_no_id_token_fails_closed(mocker): + """ORG-025: org requested but id_token absent in CIBA grant response raises OrganizationTokenValidationError.""" + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + secret="some-secret" + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={"token_endpoint": "https://auth0.local/token"} + ) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={ + "access_token": "at_ciba", + # no id_token + "expires_in": 3600, + }) + mock_response.headers = {} + mock_post.return_value = mock_response + + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.backchannel_authentication_grant( + "auth_req_123", organization="org_abc123" + ) + assert "did not include an ID token" in str(exc.value) + + +# -------------------------------------------------------------------------- +# ORG-026: CIBA — no org + no id_token succeeds +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ciba_no_org_no_id_token_succeeds(mocker): + """ORG-026: No org requested and no id_token — grant succeeds without validation.""" + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + secret="some-secret" + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={"token_endpoint": "https://auth0.local/token"} + ) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={ + "access_token": "at_ciba", + # no id_token, no org + "expires_in": 3600, + }) + mock_response.headers = {} + mock_post.return_value = mock_response + + result = await client.backchannel_authentication_grant("auth_req_123") + assert result["access_token"] == "at_ciba" + + +# -------------------------------------------------------------------------- +# ORG-027: refresh response — matching org_id accepted +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_refresh_response_matching_org_id_accepted(mocker): + """ORG-027: get_token_by_refresh_token validates org claims in returned id_token.""" + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=AsyncMock(), + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } + ) + mocker.patch.object( + client, "_get_jwks_cached", + return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} + ) + mocker.patch.object( + client, "_verify_and_decode_jwt", + return_value={"sub": "u1", "org_id": "org_abc123"} + ) + + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={ + "access_token": "new_at", + "id_token": "id_token_jwt", + "expires_in": 3600, + }) + mock_post.return_value = mock_response + + result = await client.get_token_by_refresh_token({ + "refresh_token": "rt_xyz", + "organization": "org_abc123", + }) + assert result["access_token"] == "new_at" + + +# -------------------------------------------------------------------------- +# ORG-028: refresh response — wrong org_id raises OrganizationTokenValidationError +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_refresh_response_wrong_org_id_raises(mocker): + """ORG-028: get_token_by_refresh_token raises OrganizationTokenValidationError when refreshed id_token has wrong org.""" + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=AsyncMock(), + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } + ) + mocker.patch.object( + client, "_get_jwks_cached", + return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} + ) + mocker.patch.object( + client, "_verify_and_decode_jwt", + return_value={"sub": "u1", "org_id": "org_attacker"} + ) + + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={ + "access_token": "new_at", + "id_token": "id_token_jwt", + "expires_in": 3600, + }) + mock_post.return_value = mock_response + + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.get_token_by_refresh_token({ + "refresh_token": "rt_xyz", + "organization": "org_abc123", + }) + assert "mismatch" in str(exc.value) + assert "org_abc123" in str(exc.value) + + +# -------------------------------------------------------------------------- +# ORG-029: refresh response — no id_token when org set succeeds (no validation possible) +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_refresh_response_no_id_token_with_org_succeeds(mocker): + """ORG-029: When refresh response has no id_token, org validation is skipped (AS choice).""" + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=AsyncMock(), + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } + ) + + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={ + "access_token": "new_at", + # no id_token — AS did not include one + "expires_in": 3600, + }) + mock_post.return_value = mock_response + + result = await client.get_token_by_refresh_token({ + "refresh_token": "rt_xyz", + "organization": "org_abc123", + }) + assert result["access_token"] == "new_at" From 057e49e2f49c78c53724e938d5b1ccb5814ec7dd Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 26 May 2026 11:43:40 +0530 Subject: [PATCH 02/17] SDK-8833 Changes for organisation support --- .../auth_server/server_client.py | 42 +- .../tests/test_server_client.py | 514 ++++++++++++++---- 2 files changed, 431 insertions(+), 125 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 9518c9a..e9024a9 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -6,6 +6,7 @@ import asyncio import json import time +import unicodedata from collections import OrderedDict from typing import Any, Callable, Generic, Optional, TypeVar, Union from urllib.parse import parse_qs, urlencode, urlparse, urlunparse @@ -239,7 +240,10 @@ def _validate_org_claims(self, claims: dict, expected_org: str) -> None: raise OrganizationTokenValidationError( "Organization Name (org_name) claim must be a string present in the ID token" ) - if actual.lower() != expected_org.lower(): + # NFC-normalize before comparison: the same visual character (e.g. é) can have + # multiple byte representations in Unicode. Normalizing both sides prevents + # false rejections without risk of false matches. + if unicodedata.normalize("NFC", actual).lower() != unicodedata.normalize("NFC", expected_org).lower(): raise OrganizationTokenValidationError( f"Organization Name (org_name) claim value mismatch in the ID token; " f"expected {expected_org}, found {actual}" @@ -684,8 +688,22 @@ async def complete_interactive_login( 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: - # Org validation on the userinfo path — claims come from userinfo dict. + 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: self._validate_org_claims(user_info, expected_org) user_claims = UserClaims.parse_obj(user_info) @@ -1088,7 +1106,7 @@ async def get_access_token( mfa_requirements=mfa_requirements ) - if isinstance(e, AccessTokenError): + if isinstance(e, (AccessTokenError, OrganizationTokenValidationError)): raise raise AccessTokenError( AccessTokenErrorCode.REFRESH_TOKEN_ERROR, @@ -2364,9 +2382,6 @@ async def custom_token_exchange( params["actor_token"] = options.actor_token params["actor_token_type"] = options.actor_token_type - if options.organization: - params["organization"] = options.organization - # Merge additional authorization params if options.authorization_params: # Prevent override of critical parameters @@ -2375,6 +2390,9 @@ async def custom_token_exchange( if key not in forbidden_params: params[key] = value + if options.organization: + params["organization"] = options.organization + # Make the token exchange request async with self._get_http_client() as client: response = await client.post( @@ -2476,6 +2494,13 @@ 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 options.organization and not token_response.id_token: + raise OrganizationTokenValidationError( + "Organization was requested but the token response did not include an ID token; " + "cannot verify organization membership" + ) + if token_response.id_token: # Fetch JWKS and verify ID token signature jwks = await self._get_jwks_cached(domain, metadata) @@ -2492,6 +2517,9 @@ async def login_with_custom_token_exchange( "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." ) + if options.organization: + self._validate_org_claims(claims, options.organization) + user_claims = UserClaims.parse_obj(claims) # Extract sid from token if available sid = claims.get("sid", sid) @@ -2557,7 +2585,7 @@ async def login_with_custom_token_exchange( return result except Exception as e: - if isinstance(e, (CustomTokenExchangeError, ApiError)): + if isinstance(e, (CustomTokenExchangeError, ApiError, OrganizationTokenValidationError, IssuerValidationError)): raise raise CustomTokenExchangeError( CustomTokenExchangeErrorCode.TOKEN_EXCHANGE_FAILED, diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 5160c5b..a7ad67a 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -26,6 +26,7 @@ MfaRequirements, StartInteractiveLoginOptions, StateData, + TokenExchangeResponse, TransactionData, ) from auth0_server_python.error import ( @@ -3036,6 +3037,55 @@ async def test_custom_token_exchange_with_organization(mocker): assert call_args[1]["data"]["organization"] == "org_abc1234" +@pytest.mark.asyncio +async def test_custom_token_exchange_typed_org_overrides_authorization_params(mocker): + """Typed organization param must override authorization_params['organization'].""" + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret" + ) + + mocker.patch.object( + client, + "_fetch_oidc_metadata", + return_value={"token_endpoint": "https://auth0.local/oauth/token"} + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "org_scoped_token", + "token_type": "Bearer", + "expires_in": 3600 + } + mock_response.headers.get.return_value = "application/json" + + mock_httpx_client = AsyncMock() + mock_httpx_client.__aenter__.return_value = mock_httpx_client + mock_httpx_client.__aexit__.return_value = None + mock_httpx_client.post.return_value = mock_response + + mocker.patch("httpx.AsyncClient", return_value=mock_httpx_client) + + options = CustomTokenExchangeOptions( + subject_token="custom-token", + subject_token_type="urn:acme:mcp-token", + organization="org_typed", + authorization_params={"organization": "org_from_dict"} + ) + await client.custom_token_exchange(options) + + call_args = mock_httpx_client.post.call_args + assert call_args[1]["data"]["organization"] == "org_typed" + + @pytest.mark.asyncio async def test_custom_token_exchange_empty_token(): """Test that empty/whitespace tokens are rejected.""" @@ -4849,13 +4899,10 @@ def _make_org_client(mocker, transaction_data: TransactionData, **extra): return client -# -------------------------------------------------------------------------- -# ORG-001: org by ID — matching org_id succeeds -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_org_by_id_matching_claim_succeeds(mocker): - """ORG-001: Token with matching org_id passes validation.""" + """Token with matching org_id passes validation.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), @@ -4868,13 +4915,10 @@ async def test_org_by_id_matching_claim_succeeds(mocker): assert result["state_data"]["user"]["org_id"] == "org_abc123" -# -------------------------------------------------------------------------- -# ORG-002: org by ID — missing org_id claim raises error -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_org_by_id_missing_claim_raises(mocker): - """ORG-002: Token missing org_id raises OrganizationTokenValidationError.""" + """Token missing org_id raises OrganizationTokenValidationError.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), @@ -4889,13 +4933,10 @@ async def test_org_by_id_missing_claim_raises(mocker): assert "must be a string present" in str(exc.value) -# -------------------------------------------------------------------------- -# ORG-003: org by ID — wrong org_id raises error with detail -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_org_by_id_wrong_claim_raises(mocker): - """ORG-003: Token with wrong org_id raises OrganizationTokenValidationError with mismatch detail.""" + """Token with wrong org_id raises OrganizationTokenValidationError with mismatch detail.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), @@ -4911,13 +4952,10 @@ async def test_org_by_id_wrong_claim_raises(mocker): assert "org_attacker" in str(exc.value) -# -------------------------------------------------------------------------- -# ORG-004: org by ID — null org_id claim raises error -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_org_by_id_null_claim_raises(mocker): - """ORG-004: Token with null org_id raises OrganizationTokenValidationError.""" + """Token with null org_id raises OrganizationTokenValidationError.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), @@ -4931,13 +4969,10 @@ async def test_org_by_id_null_claim_raises(mocker): assert "org_id" in str(exc.value) -# -------------------------------------------------------------------------- -# ORG-005: org by name — exact case match succeeds -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_org_by_name_exact_match_succeeds(mocker): - """ORG-005: Token with matching org_name (exact case) passes validation.""" + """Token with matching org_name (exact case) passes validation.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="acme-corp"), @@ -4950,13 +4985,10 @@ async def test_org_by_name_exact_match_succeeds(mocker): assert result is not None -# -------------------------------------------------------------------------- -# ORG-006: org by name — case-insensitive match succeeds -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_org_by_name_case_insensitive_match_succeeds(mocker): - """ORG-006: Token with org_name differing only in case passes validation.""" + """Token with org_name differing only in case passes validation.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="ACME-CORP"), @@ -4969,13 +5001,10 @@ async def test_org_by_name_case_insensitive_match_succeeds(mocker): assert result is not None -# -------------------------------------------------------------------------- -# ORG-007: org by name — missing org_name claim raises error -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_org_by_name_missing_claim_raises(mocker): - """ORG-007: Token missing org_name raises OrganizationTokenValidationError.""" + """Token missing org_name raises OrganizationTokenValidationError.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="acme-corp"), @@ -4990,13 +5019,10 @@ async def test_org_by_name_missing_claim_raises(mocker): assert "must be a string present" in str(exc.value) -# -------------------------------------------------------------------------- -# ORG-008: org by name — wrong org_name raises error with detail -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_org_by_name_wrong_claim_raises(mocker): - """ORG-008: Token with wrong org_name raises OrganizationTokenValidationError with detail.""" + """Token with wrong org_name raises OrganizationTokenValidationError with detail.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="acme-corp"), @@ -5012,13 +5038,10 @@ async def test_org_by_name_wrong_claim_raises(mocker): assert "evil-corp" in str(exc.value) -# -------------------------------------------------------------------------- -# ORG-009: no org requested — token with org_id is not rejected -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_no_org_requested_token_with_org_id_passes(mocker): - """ORG-009: When no org was requested, tokens carrying org_id must not be rejected.""" + """When no org was requested, tokens carrying org_id must not be rejected.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com"), # no organization @@ -5032,13 +5055,10 @@ async def test_no_org_requested_token_with_org_id_passes(mocker): assert result["state_data"]["user"]["org_name"] == "acme" -# -------------------------------------------------------------------------- -# ORG-010: no org requested — plain token passes -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_no_org_requested_plain_token_passes(mocker): - """ORG-010: When no org was requested, a token without org claims passes normally.""" + """When no org was requested, a token without org claims passes normally.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com"), @@ -5050,13 +5070,10 @@ async def test_no_org_requested_plain_token_passes(mocker): assert result is not None -# -------------------------------------------------------------------------- -# ORG-011: invitation and org forwarded to /authorize -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_invitation_and_org_forwarded_to_authorize(mocker): - """ORG-011: organization and invitation appear in the authorization URL.""" + """organization and invitation appear in the authorization URL.""" mock_tx_store = AsyncMock() mock_state_store = AsyncMock() client = ServerClient( @@ -5088,13 +5105,10 @@ async def test_invitation_and_org_forwarded_to_authorize(mocker): assert stored.organization == "org_abc123" -# -------------------------------------------------------------------------- -# ORG-012: invitation without org forwarded to /authorize -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_invitation_without_org_forwarded_to_authorize(mocker): - """ORG-012: invitation alone appears in the URL; no organization param.""" + """invitation alone appears in the URL; no organization param.""" mock_tx_store = AsyncMock() mock_state_store = AsyncMock() client = ServerClient( @@ -5119,13 +5133,10 @@ async def test_invitation_without_org_forwarded_to_authorize(mocker): assert "organization=" not in url -# -------------------------------------------------------------------------- -# ORG-013: per-login org overrides client-level org -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_per_login_org_overrides_client_org(mocker): - """ORG-013: Per-login organization overrides the client-level default.""" + """Per-login organization overrides the client-level default.""" mock_tx_store = AsyncMock() mock_state_store = AsyncMock() client = ServerClient( @@ -5154,13 +5165,10 @@ async def test_per_login_org_overrides_client_org(mocker): assert stored.organization == "org_override" -# -------------------------------------------------------------------------- -# ORG-014: client-level org used when login options has no org -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_client_level_org_used_when_no_per_login_org(mocker): - """ORG-014: Client-level organization is used when no per-login org is set.""" + """Client-level organization is used when no per-login org is set.""" mock_tx_store = AsyncMock() mock_state_store = AsyncMock() client = ServerClient( @@ -5185,13 +5193,10 @@ async def test_client_level_org_used_when_no_per_login_org(mocker): assert stored.organization == "org_default" -# -------------------------------------------------------------------------- -# ORG-015: org_name present in UserClaims after successful org login -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_org_name_present_in_user_claims_after_org_login(mocker): - """ORG-015: org_id and org_name both surface in session user claims.""" + """org_id and org_name both surface in session user claims.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), @@ -5206,13 +5211,10 @@ async def test_org_name_present_in_user_claims_after_org_login(mocker): assert user["org_name"] == "acme-corp" -# -------------------------------------------------------------------------- -# ORG-016: refresh token carries org context -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_refresh_token_includes_org_from_session(mocker): - """ORG-016: get_access_token passes organization to the refresh token request.""" + """get_access_token passes organization to the refresh token request.""" mock_state_store = AsyncMock() mock_state_store.get.return_value = { "user": {"sub": "u1", "org_id": "org_abc123"}, @@ -5247,13 +5249,10 @@ async def test_refresh_token_includes_org_from_session(mocker): assert call_options.get("organization") == "org_abc123" -# -------------------------------------------------------------------------- -# ORG-017: refresh without org context — no org in request -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_refresh_token_no_org_when_no_session_org(mocker): - """ORG-017: When session has no org_id, no organization param in refresh request.""" + """When session has no org_id, no organization param in refresh request.""" mock_state_store = AsyncMock() mock_state_store.get.return_value = { "user": {"sub": "u1"}, @@ -5288,13 +5287,10 @@ async def test_refresh_token_no_org_when_no_session_org(mocker): assert "organization" not in call_options -# -------------------------------------------------------------------------- -# ORG-018: refresh uses org_id not org_name -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_refresh_uses_org_id_not_org_name(mocker): - """ORG-018: Refresh token request uses org_id (stable), not org_name (mutable).""" + """Refresh token request uses org_id (stable), not org_name (mutable).""" mock_state_store = AsyncMock() mock_state_store.get.return_value = { "user": {"sub": "u1", "org_id": "org_abc123", "org_name": "acme-corp"}, @@ -5329,13 +5325,45 @@ async def test_refresh_uses_org_id_not_org_name(mocker): assert call_options.get("organization") == "org_abc123" -# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_access_token_propagates_org_validation_error(mocker): + """OrganizationTokenValidationError from refresh is not wrapped as AccessTokenError by get_access_token.""" + mock_state_store = AsyncMock() + mock_state_store.get.return_value = { + "user": {"sub": "u1", "org_id": "org_abc123"}, + "refresh_token": "rt_xyz", + "token_sets": [], + "domain": "tenant.auth0.com", + } + + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + + mocker.patch.object( + client, "get_token_by_refresh_token", + AsyncMock(side_effect=OrganizationTokenValidationError("org_id mismatch on refresh")) + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + }) + + with pytest.raises(OrganizationTokenValidationError): + await client.get_access_token() + + # Adversarial tests -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_adv_org_id_is_integer_not_string_raises(mocker): - """ADV-001: org_id claim as integer (not string) raises OrganizationTokenValidationError.""" + """org_id claim as integer (not string) raises OrganizationTokenValidationError.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), @@ -5350,7 +5378,7 @@ async def test_adv_org_id_is_integer_not_string_raises(mocker): @pytest.mark.asyncio async def test_adv_org_name_is_array_raises(mocker): - """ADV-002: org_name claim as array raises OrganizationTokenValidationError.""" + """org_name claim as array raises OrganizationTokenValidationError.""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="acme"), @@ -5365,7 +5393,7 @@ async def test_adv_org_name_is_array_raises(mocker): @pytest.mark.asyncio async def test_adv_empty_string_org_id_raises(mocker): - """ADV-004: Empty string org_id claim raises OrganizationTokenValidationError (mismatch).""" + """Empty string org_id claim raises OrganizationTokenValidationError (mismatch).""" client = _make_org_client( mocker, TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123"), @@ -5381,7 +5409,7 @@ async def test_adv_empty_string_org_id_raises(mocker): @pytest.mark.asyncio async def test_adv_org_in_untyped_dict_does_not_leak_into_transaction(mocker): - """ADV-003: org in authorization_params dict is overridden by typed organization field.""" + """org in authorization_params dict is overridden by typed organization field.""" mock_tx_store = AsyncMock() mock_state_store = AsyncMock() client = ServerClient( @@ -5418,9 +5446,7 @@ async def test_adv_org_in_untyped_dict_does_not_leak_into_transaction(mocker): assert "org_legitimate" in org_values -# -------------------------------------------------------------------------- # Error class properties -# -------------------------------------------------------------------------- def test_organization_token_validation_error_code(): """OrganizationTokenValidationError has the correct code and message.""" @@ -5430,13 +5456,10 @@ def test_organization_token_validation_error_code(): assert str(err) == "test message" -# -------------------------------------------------------------------------- -# ORG-019: userinfo path — org_id validated when org requested -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_org_userinfo_path_matching_org_id_succeeds(mocker): - """ORG-019: userinfo response with matching org_id passes validation.""" + """userinfo response with matching org_id passes validation.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123" @@ -5469,7 +5492,7 @@ async def test_org_userinfo_path_matching_org_id_succeeds(mocker): @pytest.mark.asyncio async def test_org_userinfo_path_wrong_org_id_raises(mocker): - """ORG-020: userinfo response with wrong org_id raises OrganizationTokenValidationError.""" + """userinfo response with wrong org_id raises OrganizationTokenValidationError.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123" @@ -5503,7 +5526,7 @@ async def test_org_userinfo_path_wrong_org_id_raises(mocker): @pytest.mark.asyncio async def test_org_userinfo_path_missing_org_id_raises(mocker): - """ORG-021: userinfo response missing org_id raises OrganizationTokenValidationError.""" + """userinfo response missing org_id raises OrganizationTokenValidationError.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123" @@ -5534,13 +5557,44 @@ async def test_org_userinfo_path_missing_org_id_raises(mocker): assert "must be a string present" in str(exc.value) -# -------------------------------------------------------------------------- -# ORG-022: CIBA — org_id validated in grant response -# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_org_requested_no_userinfo_no_id_token_fails_closed(mocker): + """org was requested but token response has neither user_info nor id_token — fails closed.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123" + ) + mock_state_store = AsyncMock() + mock_state_store.get.return_value = None + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + secret="test_secret_key_32_chars_long!!", + transaction_store=mock_tx_store, + state_store=mock_state_store, + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } + ) + mocker.patch.object(client._oauth, "fetch_token", AsyncMock(return_value={ + "access_token": "at123", + # neither user_info nor id_token + })) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "neither" in str(exc.value) + + @pytest.mark.asyncio async def test_ciba_org_id_matching_succeeds(mocker): - """ORG-022: backchannel_authentication_grant with matching org_id passes.""" + """backchannel_authentication_grant with matching org_id passes.""" client = ServerClient( domain="auth0.local", client_id="client_id", @@ -5577,13 +5631,10 @@ async def test_ciba_org_id_matching_succeeds(mocker): assert result["access_token"] == "at_ciba" -# -------------------------------------------------------------------------- -# ORG-023: CIBA — org_id mismatch raises OrganizationTokenValidationError -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_ciba_org_id_mismatch_raises(mocker): - """ORG-023: backchannel_authentication_grant with wrong org_id raises OrganizationTokenValidationError.""" + """backchannel_authentication_grant with wrong org_id raises OrganizationTokenValidationError.""" client = ServerClient( domain="auth0.local", client_id="client_id", @@ -5622,13 +5673,10 @@ async def test_ciba_org_id_mismatch_raises(mocker): assert "org_abc123" in str(exc.value) -# -------------------------------------------------------------------------- -# ORG-024: CIBA — client-level org propagates through login_backchannel -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_ciba_client_level_org_propagates(mocker): - """ORG-024: Client-level organization propagates through login_backchannel.""" + """Client-level organization propagates through login_backchannel.""" mock_state_store = AsyncMock() mock_state_store.get.return_value = {"token_sets": []} @@ -5657,13 +5705,10 @@ async def test_ciba_client_level_org_propagates(mocker): assert called_options.get("organization") == "org_client_level" -# -------------------------------------------------------------------------- -# ORG-025: CIBA — missing id_token with org set fails closed -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_ciba_org_requested_no_id_token_fails_closed(mocker): - """ORG-025: org requested but id_token absent in CIBA grant response raises OrganizationTokenValidationError.""" + """org requested but id_token absent in CIBA grant response raises OrganizationTokenValidationError.""" client = ServerClient( domain="auth0.local", client_id="client_id", @@ -5692,13 +5737,10 @@ async def test_ciba_org_requested_no_id_token_fails_closed(mocker): assert "did not include an ID token" in str(exc.value) -# -------------------------------------------------------------------------- -# ORG-026: CIBA — no org + no id_token succeeds -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_ciba_no_org_no_id_token_succeeds(mocker): - """ORG-026: No org requested and no id_token — grant succeeds without validation.""" + """No org requested and no id_token — grant succeeds without validation.""" client = ServerClient( domain="auth0.local", client_id="client_id", @@ -5724,13 +5766,10 @@ async def test_ciba_no_org_no_id_token_succeeds(mocker): assert result["access_token"] == "at_ciba" -# -------------------------------------------------------------------------- -# ORG-027: refresh response — matching org_id accepted -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_refresh_response_matching_org_id_accepted(mocker): - """ORG-027: get_token_by_refresh_token validates org claims in returned id_token.""" + """get_token_by_refresh_token validates org claims in returned id_token.""" client = ServerClient( domain="tenant.auth0.com", client_id="test_client", @@ -5772,13 +5811,10 @@ async def test_refresh_response_matching_org_id_accepted(mocker): assert result["access_token"] == "new_at" -# -------------------------------------------------------------------------- -# ORG-028: refresh response — wrong org_id raises OrganizationTokenValidationError -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_refresh_response_wrong_org_id_raises(mocker): - """ORG-028: get_token_by_refresh_token raises OrganizationTokenValidationError when refreshed id_token has wrong org.""" + """get_token_by_refresh_token raises OrganizationTokenValidationError when refreshed id_token has wrong org.""" client = ServerClient( domain="tenant.auth0.com", client_id="test_client", @@ -5822,13 +5858,10 @@ async def test_refresh_response_wrong_org_id_raises(mocker): assert "org_abc123" in str(exc.value) -# -------------------------------------------------------------------------- -# ORG-029: refresh response — no id_token when org set succeeds (no validation possible) -# -------------------------------------------------------------------------- @pytest.mark.asyncio async def test_refresh_response_no_id_token_with_org_succeeds(mocker): - """ORG-029: When refresh response has no id_token, org validation is skipped (AS choice).""" + """When refresh response has no id_token, org validation is skipped (AS choice).""" client = ServerClient( domain="tenant.auth0.com", client_id="test_client", @@ -5860,3 +5893,248 @@ async def test_refresh_response_no_id_token_with_org_succeeds(mocker): "organization": "org_abc123", }) assert result["access_token"] == "new_at" + + + +@pytest.mark.asyncio +async def test_org_userinfo_non_dict_raises_organization_error(mocker): + """Non-dict truthy userinfo raises OrganizationTokenValidationError when org is requested.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123" + ) + mock_state_store = AsyncMock() + mock_state_store.get.return_value = None + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + secret="test_secret_key_32_chars_long!!", + transaction_store=mock_tx_store, + state_store=mock_state_store, + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } + ) + # Return a string (truthy, but not a dict) as userinfo + mocker.patch.object(client._oauth, "fetch_token", AsyncMock(return_value={ + "access_token": "at123", + "userinfo": "not-a-dict", + })) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "valid claims dictionary" in str(exc.value) + + +@pytest.mark.asyncio +async def test_userinfo_non_dict_no_org_raises_api_error(mocker): + """Non-dict truthy userinfo without org requested raises ApiError (not OrganizationTokenValidationError).""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", + ) + mock_state_store = AsyncMock() + mock_state_store.get.return_value = None + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + secret="test_secret_key_32_chars_long!!", + transaction_store=mock_tx_store, + state_store=mock_state_store, + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } + ) + mocker.patch.object(client._oauth, "fetch_token", AsyncMock(return_value={ + "access_token": "at123", + "userinfo": "not-a-dict", + })) + with pytest.raises(ApiError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert exc.value.code == "invalid_response" + assert "valid claims dictionary" in str(exc.value) + + + +@pytest.mark.asyncio +async def test_ciba_polling_loop_reraises_org_validation_error(mocker): + """OrganizationTokenValidationError from grant is not swallowed by the polling loop.""" + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + secret="some-secret" + ) + + mocker.patch.object( + client, "initiate_backchannel_authentication", + AsyncMock(return_value={"auth_req_id": "req_123", "expires_in": 30, "interval": 1}) + ) + mocker.patch.object( + client, "backchannel_authentication_grant", + AsyncMock(side_effect=OrganizationTokenValidationError("org_id mismatch in CIBA grant")) + ) + + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.backchannel_authentication({"organization": "org_abc123", "login_hint": {"sub": "u1"}, "binding_message": "test"}) + assert "mismatch" in str(exc.value) + + + +@pytest.mark.asyncio +async def test_custom_token_exchange_org_mismatch_raises(mocker): + """login_with_custom_token_exchange validates org claims in returned id_token.""" + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=AsyncMock(), + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + }) + mocker.patch.object(client, "_get_jwks_cached", return_value={"keys": []}) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "org_id": "org_attacker", + }) + + mocker.patch.object( + client, "custom_token_exchange", + AsyncMock(return_value=TokenExchangeResponse( + access_token="at", token_type="Bearer", expires_in=3600, + id_token="header.payload.sig" + )) + ) + + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.login_with_custom_token_exchange( + LoginWithCustomTokenExchangeOptions( + subject_token="tok", + subject_token_type="urn:example:type", + organization="org_abc123", + ) + ) + assert "mismatch" in str(exc.value) + + +@pytest.mark.asyncio +async def test_custom_token_exchange_org_error_not_swallowed(mocker): + """OrganizationTokenValidationError propagates, not wrapped as CustomTokenExchangeError.""" + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=AsyncMock(), + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + }) + mocker.patch.object(client, "_get_jwks_cached", return_value={"keys": []}) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "org_name": "evil-corp", + }) + + mocker.patch.object( + client, "custom_token_exchange", + AsyncMock(return_value=TokenExchangeResponse( + access_token="at", token_type="Bearer", expires_in=3600, + id_token="header.payload.sig" + )) + ) + + with pytest.raises(OrganizationTokenValidationError): + await client.login_with_custom_token_exchange( + LoginWithCustomTokenExchangeOptions( + subject_token="tok", + subject_token_type="urn:example:type", + organization="acme-corp", + ) + ) + + +@pytest.mark.asyncio +async def test_custom_token_exchange_org_no_id_token_fails_closed(mocker): + """login_with_custom_token_exchange must fail closed when org requested but no id_token returned.""" + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=AsyncMock(), + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + }) + + mocker.patch.object( + client, "custom_token_exchange", + AsyncMock(return_value=TokenExchangeResponse( + access_token="at", token_type="Bearer", expires_in=3600, + id_token=None + )) + ) + + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.login_with_custom_token_exchange( + LoginWithCustomTokenExchangeOptions( + subject_token="tok", + subject_token_type="urn:example:type", + organization="org_abc123", + ) + ) + assert "did not include an ID token" in str(exc.value) + + +@pytest.mark.asyncio +async def test_custom_token_exchange_issuer_error_not_swallowed(mocker): + """IssuerValidationError from id_token propagates, not wrapped as CustomTokenExchangeError.""" + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=AsyncMock(), + state_store=AsyncMock(), + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + }) + mocker.patch.object(client, "_get_jwks_cached", return_value={"keys": []}) + # Token claims an issuer from a different domain + mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ + "sub": "u1", "iss": "https://attacker.example.com/", + }) + + mocker.patch.object( + client, "custom_token_exchange", + AsyncMock(return_value=TokenExchangeResponse( + access_token="at", token_type="Bearer", expires_in=3600, + id_token="header.payload.sig" + )) + ) + + with pytest.raises(IssuerValidationError): + await client.login_with_custom_token_exchange( + LoginWithCustomTokenExchangeOptions( + subject_token="tok", + subject_token_type="urn:example:type", + ) + ) From 400dfc17198538c6e8a2eeb9f4cdc2e6587096c7 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 26 May 2026 11:51:20 +0530 Subject: [PATCH 03/17] Restore incorrectly removed patterns from .gitignore --- .gitignore | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.gitignore b/.gitignore index 418eed6..348ffc1 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,18 @@ share/python-wheels/ # Logs *.log + +# Session cache +.sessions_cache + +# Docs build output +docs + +# Dev scripts +server.py +setup.py +test.py +test-script.py + +# AI tools +.claude From 6c321881da3f085a996b5eb2a17c850822baf460 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 26 May 2026 11:58:09 +0530 Subject: [PATCH 04/17] Restore .gitignore to earlier state with single pattern addition --- .gitignore | 77 +++++++++--------------------------------------------- 1 file changed, 13 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index 348ffc1..24939dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,81 +1,30 @@ -# Python +### Python ### +# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class -*.so -*.egg -*.egg-info/ -dist/ -build/ -eggs/ -parts/ -var/ -sdist/ -wheels/ -*.egg-link -MANIFEST -# Virtual environments -.venv/ +#Environments +.env +.venv .venv-*/ -venv/ env/ -ENV/ - -# Testing & coverage -.pytest_cache/ -.coverage -.coverage.* -coverage.xml -htmlcov/ -.tox/ -nosetests.xml -pytest-cache/ - -# Type checking -.mypy_cache/ -.dmypy.json -.pytype/ - -# Distribution / packaging -*.spec -pip-wheel-metadata/ -share/python-wheels/ - -# Jupyter -.ipynb_checkpoints - -# Environment files -.env -.env.* -!.env.example - -# IDEs -.idea/ -.vscode/ -*.swp -*.swo -*~ - -# macOS -.DS_Store -.AppleDouble -.LSOverride -# Logs -*.log - -# Session cache +#Session Cache .sessions_cache +.DS_Store -# Docs build output +#Build files +dist docs -# Dev scripts +#testfile server.py setup.py test.py test-script.py +.coverage +coverage.xml # AI tools -.claude +.claude \ No newline at end of file From b5a3e49521339ea9344c5e14300661ad9c62ae11 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 26 May 2026 12:09:57 +0530 Subject: [PATCH 05/17] Linting fix - removed duplicate import --- src/auth0_server_python/tests/test_server_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index a7ad67a..adce8a7 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -5439,7 +5439,6 @@ async def test_adv_org_in_untyped_dict_does_not_leak_into_transaction(mocker): assert stored.organization == "org_legitimate" # URL must not contain the attacker value - from urllib.parse import parse_qs, urlparse parsed = parse_qs(urlparse(url).query) org_values = parsed.get("organization", []) assert "org_attacker" not in org_values From 7d6a00f0eba0bca232e75cacd4ab9e85072e1fbe Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 8 Jun 2026 00:25:41 +0530 Subject: [PATCH 06/17] error messages contain only org name --- .../auth_server/server_client.py | 6 ++-- .../tests/test_server_client.py | 29 ++++++++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index e9024a9..ab78e62 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -231,8 +231,7 @@ def _validate_org_claims(self, claims: dict, expected_org: str) -> None: ) if actual != expected_org: raise OrganizationTokenValidationError( - f"Organization Id (org_id) claim value mismatch in the ID token; " - f"expected {expected_org}, found {actual}" + "Organization Id (org_id) claim value mismatch in the ID token" ) else: actual = claims.get("org_name") @@ -245,8 +244,7 @@ def _validate_org_claims(self, claims: dict, expected_org: str) -> None: # false rejections without risk of false matches. if unicodedata.normalize("NFC", actual).lower() != unicodedata.normalize("NFC", expected_org).lower(): raise OrganizationTokenValidationError( - f"Organization Name (org_name) claim value mismatch in the ID token; " - f"expected {expected_org}, found {actual}" + "Organization Name (org_name) claim value mismatch in the ID token" ) async def _resolve_current_domain(self, store_options=None) -> str: diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index adce8a7..b71fda8 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -1,5 +1,6 @@ import json import time +import unicodedata from unittest.mock import ANY, AsyncMock, MagicMock, patch from urllib.parse import parse_qs, urlparse @@ -4948,8 +4949,6 @@ async def test_org_by_id_wrong_claim_raises(mocker): with pytest.raises(OrganizationTokenValidationError) as exc: await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") assert "mismatch" in str(exc.value) - assert "org_abc123" in str(exc.value) - assert "org_attacker" in str(exc.value) @@ -5034,8 +5033,6 @@ async def test_org_by_name_wrong_claim_raises(mocker): with pytest.raises(OrganizationTokenValidationError) as exc: await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") assert "mismatch" in str(exc.value) - assert "acme-corp" in str(exc.value) - assert "evil-corp" in str(exc.value) @@ -5445,6 +5442,27 @@ async def test_adv_org_in_untyped_dict_does_not_leak_into_transaction(mocker): assert "org_legitimate" in org_values +@pytest.mark.asyncio +async def test_adv_unicode_nfc_nfd_org_name_matches(mocker): + """ADV-005: NFC and NFD representations of the same org name are treated as equal.""" + # "café" NFC: é is U+00E9 (single precomposed codepoint) + # "café" NFD: é is U+0065 U+0301 (base letter + combining accent) + nfc_name = unicodedata.normalize("NFC", "café") + nfd_name = unicodedata.normalize("NFD", "café") + assert nfc_name != nfd_name, "precondition: NFC and NFD byte sequences differ" + + client = _make_org_client( + mocker, + TransactionData(code_verifier="cv", domain="tenant.auth0.com", organization=nfd_name), + ) + mocker.patch("jwt.decode", return_value={ + "sub": "u1", "iss": "https://tenant.auth0.com/", "aud": "test_client", + "org_name": nfc_name, + }) + result = await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert result is not None + + # Error class properties def test_organization_token_validation_error_code(): @@ -5520,7 +5538,6 @@ async def test_org_userinfo_path_wrong_org_id_raises(mocker): with pytest.raises(OrganizationTokenValidationError) as exc: await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") assert "mismatch" in str(exc.value) - assert "org_abc123" in str(exc.value) @pytest.mark.asyncio @@ -5669,7 +5686,6 @@ async def test_ciba_org_id_mismatch_raises(mocker): "auth_req_123", organization="org_abc123" ) assert "mismatch" in str(exc.value) - assert "org_abc123" in str(exc.value) @@ -5854,7 +5870,6 @@ async def test_refresh_response_wrong_org_id_raises(mocker): "organization": "org_abc123", }) assert "mismatch" in str(exc.value) - assert "org_abc123" in str(exc.value) From cff2355981b9337cb73d9821c0c4d2b4a31f9127 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Wed, 17 Jun 2026 11:45:10 +0530 Subject: [PATCH 07/17] Reverting non org login related changes --- .../auth_server/server_client.py | 125 +- .../auth_types/__init__.py | 3 - src/auth0_server_python/error/__init__.py | 41 + .../tests/test_server_client.py | 1028 ++++++----------- 4 files changed, 430 insertions(+), 767 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index ab78e62..7a67e94 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -51,10 +51,13 @@ CustomTokenExchangeErrorCode, DomainResolverError, InvalidArgumentError, + InvitationError, IssuerValidationError, MfaRequiredError, MissingRequiredArgumentError, MissingTransactionError, + OrganizationAccessDeniedError, + OrganizationRequiredError, OrganizationTokenValidationError, PollingApiError, StartLinkUserError, @@ -71,7 +74,42 @@ # redirect_uri is intentionally excluded — in MCD mode it is built # dynamically from the resolved domain at login time. INTERNAL_AUTHORIZE_PARAMS = ["client_id", "response_type", - "code_challenge", "code_challenge_method", "state", "nonce", "scope"] + "code_challenge", "code_challenge_method", "state", "nonce", "scope", + "organization", "invitation"] + +_ORG_ACCESS_DENIED_FRAGMENTS = ( + "is not part of the", + "connection is not enabled for this organization", + "quota exceeded", + "organization member limit", + "user_id that longer than 1024", +) + +_ORG_INVALID_REQUEST_FRAGMENTS = ( + "organization must be an organization id", + "organizations feature is not enabled", + "organizations feature is not supported", + "parameter organization is required", + "parameter organization is not allowed", + "client is missing", +) + + +def _classify_org_error(error: str, error_description: str) -> "ApiError": + desc_lower = error_description.lower() + + if "invalid_user_invitation_ticket" in desc_lower or error == "invalid_user_invitation_ticket": + return InvitationError(error_description) + + if error == "access_denied": + if any(f in desc_lower for f in _ORG_ACCESS_DENIED_FRAGMENTS): + return OrganizationAccessDeniedError(error_description) + + if error == "invalid_request": + if any(f in desc_lower for f in _ORG_INVALID_REQUEST_FRAGMENTS): + return OrganizationRequiredError(error_description) + + return ApiError(error, error_description) class ServerClient(Generic[TStoreOptions]): @@ -540,6 +578,8 @@ async def start_interactive_login( # Resolve organization: per-login value takes precedence over client-level default. 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 @@ -649,7 +689,7 @@ async def complete_interactive_login( if "error" in query_params: error = query_params.get("error", [""])[0] error_description = query_params.get("error_description", [""])[0] - raise ApiError(error, error_description) + raise _classify_org_error(error, error_description) # Get the authorization code from the URL code = query_params.get("code", [""])[0] @@ -1064,16 +1104,6 @@ async def get_access_token( if merged_scope: get_refresh_token_options["scope"] = merged_scope - # Carry org context so refreshed tokens include org_id/org_name claims. - # Use org_id (stable) rather than org_name (mutable). - user_dict = state_data_dict.get("user") or {} - if isinstance(user_dict, dict): - session_org_id = user_dict.get("org_id") - else: - session_org_id = getattr(user_dict, "org_id", None) - if session_org_id is not None: - get_refresh_token_options["organization"] = session_org_id - token_endpoint_response = await self.get_token_by_refresh_token(get_refresh_token_options) # Update state data with new token @@ -1104,7 +1134,7 @@ async def get_access_token( mfa_requirements=mfa_requirements ) - if isinstance(e, (AccessTokenError, OrganizationTokenValidationError)): + if isinstance(e, AccessTokenError): raise raise AccessTokenError( AccessTokenErrorCode.REFRESH_TOKEN_ERROR, @@ -1162,10 +1192,6 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, if merged_scope: token_params["scope"] = merged_scope - organization = options.get("organization") - if organization is not None: - token_params["organization"] = organization - # Exchange the refresh token for an access token async with self._get_http_client() as client: response = await client.post( @@ -1204,23 +1230,10 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, token_response["expires_at"] = int( time.time()) + token_response["expires_in"] - # Validate org claims in the refreshed ID token when org context was sent. - # This ensures a refresh cannot silently downgrade or change org membership. - refresh_id_token = token_response.get("id_token") - if organization is not None and refresh_id_token: - refresh_jwks = await self._get_jwks_cached(domain, metadata) - try: - refresh_claims = await self._verify_and_decode_jwt( - refresh_id_token, refresh_jwks, audience=self._client_id - ) - except Exception as e: - raise ApiError("invalid_token", f"Refresh ID token verification failed: {str(e)}", e) - self._validate_org_claims(refresh_claims, organization) - return token_response except Exception as e: - if isinstance(e, (ApiError, OrganizationTokenValidationError)): + if isinstance(e, ApiError): raise raise AccessTokenError( AccessTokenErrorCode.REFRESH_TOKEN_ERROR, @@ -1298,12 +1311,10 @@ async def login_backchannel( Returns: A dictionary containing the authorizationDetails (when RAR was used). """ - resolved_org = options.get("organization") or self._organization token_endpoint_response = await self.backchannel_authentication({ "binding_message": options.get("binding_message"), "login_hint": options.get("login_hint"), "authorization_params": options.get("authorization_params"), - "organization": resolved_org, }, store_options=store_options) existing_state_data = await self._state_store.get(self._state_identifier, store_options) @@ -1362,7 +1373,6 @@ async def backchannel_authentication( "expires_in", 120) # Default to 2 minutes interval = backchannel_data.get( "interval", 5) # Default to 5 seconds - organization = options.get("organization") # Calculate when to stop polling end_time = time.time() + expires_in @@ -1373,7 +1383,6 @@ async def backchannel_authentication( try: token_response = await self.backchannel_authentication_grant( auth_req_id, - organization=organization, store_options=store_options, ) return token_response @@ -1388,8 +1397,6 @@ async def backchannel_authentication( # Wait for the specified interval before polling again await asyncio.sleep(e.interval or interval) continue - if isinstance(e, OrganizationTokenValidationError): - raise raise ApiError( "backchannel_error", f"Backchannel authentication failed: {str(e) or 'Unknown error'}", @@ -1498,12 +1505,6 @@ async def initiate_backchannel_authentication( if authorization_params: params.update(authorization_params) - # Organization: per-request value already resolved upstream in login_backchannel. - # Accept it here so it reaches the bc-authorize request body. - backchannel_org = options.get("organization") - if backchannel_org: - params["organization"] = backchannel_org - # Make the backchannel authentication request async with self._get_http_client() as client: backchannel_response = await client.post( @@ -1543,7 +1544,6 @@ async def initiate_backchannel_authentication( async def backchannel_authentication_grant( self, auth_req_id: str, - organization: Optional[str] = None, store_options: Optional[dict[str, Any]] = None ) -> dict[str, Any]: """ @@ -1551,7 +1551,6 @@ async def backchannel_authentication_grant( Args: auth_req_id (str): The authentication request ID obtained from bc-authorize - organization: Organization value used at CIBA initiation, for ID token validation. store_options: Optional options used to pass to the Transaction and State Store. Raises: @@ -1613,29 +1612,10 @@ async def backchannel_authentication_grant( token_response["expires_at"] = int( time.time()) + token_response["expires_in"] - # Validate org claims in the ID token when an org was requested. - # If org was requested but no id_token was returned, fail closed — - # we cannot verify org membership without an id_token. - id_token = token_response.get("id_token") - if organization: - if not id_token: - raise OrganizationTokenValidationError( - "Organization was requested but the token response did not include an ID token; " - "cannot verify organization membership" - ) - jwks = await self._get_jwks_cached(domain) - try: - ciba_claims = await self._verify_and_decode_jwt( - id_token, jwks, audience=self._client_id - ) - except Exception as e: - raise ApiError("invalid_token", f"CIBA ID token verification failed: {str(e)}", e) - self._validate_org_claims(ciba_claims, organization) - return token_response except Exception as e: - if isinstance(e, (ApiError, PollingApiError, OrganizationTokenValidationError)): + if isinstance(e, (ApiError, PollingApiError)): raise raise AccessTokenError( AccessTokenErrorCode.AUTH_REQ_ID_ERROR, @@ -2388,9 +2368,6 @@ async def custom_token_exchange( if key not in forbidden_params: params[key] = value - if options.organization: - params["organization"] = options.organization - # Make the token exchange request async with self._get_http_client() as client: response = await client.post( @@ -2479,7 +2456,6 @@ async def login_with_custom_token_exchange( scope=options.scope, actor_token=options.actor_token, actor_token_type=options.actor_token_type, - organization=options.organization, authorization_params=options.authorization_params ) @@ -2493,12 +2469,6 @@ async def login_with_custom_token_exchange( user_claims = None sid = PKCE.generate_random_string(32) # Default sid - if options.organization and not token_response.id_token: - raise OrganizationTokenValidationError( - "Organization was requested but the token response did not include an ID token; " - "cannot verify organization membership" - ) - if token_response.id_token: # Fetch JWKS and verify ID token signature jwks = await self._get_jwks_cached(domain, metadata) @@ -2515,9 +2485,6 @@ async def login_with_custom_token_exchange( "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." ) - if options.organization: - self._validate_org_claims(claims, options.organization) - user_claims = UserClaims.parse_obj(claims) # Extract sid from token if available sid = claims.get("sid", sid) @@ -2583,7 +2550,7 @@ async def login_with_custom_token_exchange( return result except Exception as e: - if isinstance(e, (CustomTokenExchangeError, ApiError, OrganizationTokenValidationError, IssuerValidationError)): + if isinstance(e, (CustomTokenExchangeError, ApiError, IssuerValidationError)): raise raise CustomTokenExchangeError( CustomTokenExchangeErrorCode.TOKEN_EXCHANGE_FAILED, diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 57b4b91..05eb1e7 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -196,7 +196,6 @@ class LoginBackchannelOptions(BaseModel): binding_message: str login_hint: dict[str, str] # Should contain a 'sub' field authorization_params: Optional[dict[str, Any]] = None - organization: Optional[str] = None class Config: extra = "allow" # Allow additional fields not defined in the model @@ -265,7 +264,6 @@ class CustomTokenExchangeOptions(BaseModel): scope: Optional[str] = None actor_token: Optional[str] = None actor_token_type: Optional[str] = None - organization: Optional[str] = None authorization_params: Optional[dict[str, Any]] = None @field_validator('subject_token', 'actor_token') @@ -321,7 +319,6 @@ class LoginWithCustomTokenExchangeOptions(BaseModel): scope: Optional[str] = None actor_token: Optional[str] = None actor_token_type: Optional[str] = None - organization: Optional[str] = None authorization_params: Optional[dict[str, Any]] = None @field_validator('subject_token', 'actor_token') diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index f3e7660..879053d 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -197,6 +197,47 @@ def __init__(self, message: str): self.name = "OrganizationTokenValidationError" +class OrganizationRequiredError(Auth0Error): + """ + Raised when Auth0 rejects the authorization request due to an organization + configuration problem — invalid format, feature disabled, client not + configured for organizations, or the organization does not exist. + """ + code = "organization_required_error" + + def __init__(self, message: str, cause: Optional[Exception] = None): + super().__init__(message) + self.name = "OrganizationRequiredError" + self.cause = cause + + +class OrganizationAccessDeniedError(Auth0Error): + """ + Raised when Auth0 denies access to the requested organization — the user is + not a member, the connection is not enabled for the org, or the org member + quota has been exceeded. + """ + code = "organization_access_denied_error" + + def __init__(self, message: str, cause: Optional[Exception] = None): + super().__init__(message) + self.name = "OrganizationAccessDeniedError" + self.cause = cause + + +class InvitationError(Auth0Error): + """ + Raised when an organization invitation is rejected by Auth0 — the ticket is + expired, already used, or otherwise invalid. + """ + code = "invitation_error" + + def __init__(self, message: str, cause: Optional[Exception] = None): + super().__init__(message) + self.name = "InvitationError" + self.cause = cause + + # Error code enumerations - these can be used to identify specific error scenarios class AccessTokenErrorCode: diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index b71fda8..a78d352 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9,7 +9,7 @@ from auth0_server_python.auth_server.mfa_client import MfaClient from auth0_server_python.auth_server.my_account_client import MyAccountClient -from auth0_server_python.auth_server.server_client import ServerClient +from auth0_server_python.auth_server.server_client import ServerClient, _classify_org_error from auth0_server_python.auth_types import ( CompleteConnectAccountRequest, ConnectAccountOptions, @@ -27,7 +27,6 @@ MfaRequirements, StartInteractiveLoginOptions, StateData, - TokenExchangeResponse, TransactionData, ) from auth0_server_python.error import ( @@ -42,10 +41,13 @@ CustomTokenExchangeErrorCode, DomainResolverError, InvalidArgumentError, + InvitationError, IssuerValidationError, MfaRequiredError, MissingRequiredArgumentError, MissingTransactionError, + OrganizationAccessDeniedError, + OrganizationRequiredError, OrganizationTokenValidationError, PollingApiError, StartLinkUserError, @@ -2984,109 +2986,6 @@ async def test_custom_token_exchange_with_actor_token(mocker): assert call_args[1]["data"]["actor_token_type"] == "urn:ietf:params:oauth:token-type:access_token" -@pytest.mark.asyncio -async def test_custom_token_exchange_with_organization(mocker): - """Test token exchange with organization parameter.""" - # Setup - mock_transaction_store = AsyncMock() - mock_state_store = AsyncMock() - - client = ServerClient( - domain="auth0.local", - client_id="", - client_secret="", - state_store=mock_state_store, - transaction_store=mock_transaction_store, - secret="some-secret" - ) - - mocker.patch.object( - client, - "_fetch_oidc_metadata", - return_value={"token_endpoint": "https://auth0.local/oauth/token"} - ) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "access_token": "org_scoped_token", - "token_type": "Bearer", - "expires_in": 3600 - } - mock_response.headers.get.return_value = "application/json" - - mock_httpx_client = AsyncMock() - mock_httpx_client.__aenter__.return_value = mock_httpx_client - mock_httpx_client.__aexit__.return_value = None - mock_httpx_client.post.return_value = mock_response - - mocker.patch("httpx.AsyncClient", return_value=mock_httpx_client) - - # Act - options = CustomTokenExchangeOptions( - subject_token="custom-token", - subject_token_type="urn:acme:mcp-token", - organization="org_abc1234" - ) - result = await client.custom_token_exchange(options) - - # Assert - assert result.access_token == "org_scoped_token" - - # Verify organization param was sent - call_args = mock_httpx_client.post.call_args - assert call_args[1]["data"]["organization"] == "org_abc1234" - - -@pytest.mark.asyncio -async def test_custom_token_exchange_typed_org_overrides_authorization_params(mocker): - """Typed organization param must override authorization_params['organization'].""" - mock_transaction_store = AsyncMock() - mock_state_store = AsyncMock() - - client = ServerClient( - domain="auth0.local", - client_id="", - client_secret="", - state_store=mock_state_store, - transaction_store=mock_transaction_store, - secret="some-secret" - ) - - mocker.patch.object( - client, - "_fetch_oidc_metadata", - return_value={"token_endpoint": "https://auth0.local/oauth/token"} - ) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "access_token": "org_scoped_token", - "token_type": "Bearer", - "expires_in": 3600 - } - mock_response.headers.get.return_value = "application/json" - - mock_httpx_client = AsyncMock() - mock_httpx_client.__aenter__.return_value = mock_httpx_client - mock_httpx_client.__aexit__.return_value = None - mock_httpx_client.post.return_value = mock_response - - mocker.patch("httpx.AsyncClient", return_value=mock_httpx_client) - - options = CustomTokenExchangeOptions( - subject_token="custom-token", - subject_token_type="urn:acme:mcp-token", - organization="org_typed", - authorization_params={"organization": "org_from_dict"} - ) - await client.custom_token_exchange(options) - - call_args = mock_httpx_client.post.call_args - assert call_args[1]["data"]["organization"] == "org_typed" - - @pytest.mark.asyncio async def test_custom_token_exchange_empty_token(): """Test that empty/whitespace tokens are rejected.""" @@ -5130,6 +5029,34 @@ async def test_invitation_without_org_forwarded_to_authorize(mocker): assert "organization=" not in url +@pytest.mark.asyncio +async def test_invitation_via_authorization_params_dict_is_ignored(mocker): + """invitation passed via authorization_params dict is stripped; typed field is the only path.""" + mock_tx_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "authorization_endpoint": "https://tenant.auth0.com/authorize", + }) + + url = await client.start_interactive_login( + StartInteractiveLoginOptions( + authorization_params={"invitation": "inv_via_dict"}, + ) + ) + + parsed = parse_qs(urlparse(url).query) + assert "invitation" not in parsed + @pytest.mark.asyncio async def test_per_login_org_overrides_client_org(mocker): @@ -5208,154 +5135,6 @@ async def test_org_name_present_in_user_claims_after_org_login(mocker): assert user["org_name"] == "acme-corp" - -@pytest.mark.asyncio -async def test_refresh_token_includes_org_from_session(mocker): - """get_access_token passes organization to the refresh token request.""" - mock_state_store = AsyncMock() - mock_state_store.get.return_value = { - "user": {"sub": "u1", "org_id": "org_abc123"}, - "refresh_token": "rt_xyz", - "token_sets": [], - "domain": "tenant.auth0.com", - } - - client = ServerClient( - domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - transaction_store=AsyncMock(), - state_store=mock_state_store, - secret="test_secret_key_32_chars_long!!", - ) - - mock_refresh = AsyncMock(return_value={ - "access_token": "new_at", - "expires_in": 3600, - "expires_at": int(time.time()) + 3600, - }) - mocker.patch.object(client, "get_token_by_refresh_token", mock_refresh) - mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ - "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - }) - - await client.get_access_token() - - call_options = mock_refresh.call_args[0][0] - assert call_options.get("organization") == "org_abc123" - - - -@pytest.mark.asyncio -async def test_refresh_token_no_org_when_no_session_org(mocker): - """When session has no org_id, no organization param in refresh request.""" - mock_state_store = AsyncMock() - mock_state_store.get.return_value = { - "user": {"sub": "u1"}, - "refresh_token": "rt_xyz", - "token_sets": [], - "domain": "tenant.auth0.com", - } - - client = ServerClient( - domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - transaction_store=AsyncMock(), - state_store=mock_state_store, - secret="test_secret_key_32_chars_long!!", - ) - - mock_refresh = AsyncMock(return_value={ - "access_token": "new_at", - "expires_in": 3600, - "expires_at": int(time.time()) + 3600, - }) - mocker.patch.object(client, "get_token_by_refresh_token", mock_refresh) - mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ - "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - }) - - await client.get_access_token() - - call_options = mock_refresh.call_args[0][0] - assert "organization" not in call_options - - - -@pytest.mark.asyncio -async def test_refresh_uses_org_id_not_org_name(mocker): - """Refresh token request uses org_id (stable), not org_name (mutable).""" - mock_state_store = AsyncMock() - mock_state_store.get.return_value = { - "user": {"sub": "u1", "org_id": "org_abc123", "org_name": "acme-corp"}, - "refresh_token": "rt_xyz", - "token_sets": [], - "domain": "tenant.auth0.com", - } - - client = ServerClient( - domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - transaction_store=AsyncMock(), - state_store=mock_state_store, - secret="test_secret_key_32_chars_long!!", - ) - - mock_refresh = AsyncMock(return_value={ - "access_token": "new_at", - "expires_in": 3600, - "expires_at": int(time.time()) + 3600, - }) - mocker.patch.object(client, "get_token_by_refresh_token", mock_refresh) - mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ - "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - }) - - await client.get_access_token() - - call_options = mock_refresh.call_args[0][0] - assert call_options.get("organization") == "org_abc123" - - - -@pytest.mark.asyncio -async def test_get_access_token_propagates_org_validation_error(mocker): - """OrganizationTokenValidationError from refresh is not wrapped as AccessTokenError by get_access_token.""" - mock_state_store = AsyncMock() - mock_state_store.get.return_value = { - "user": {"sub": "u1", "org_id": "org_abc123"}, - "refresh_token": "rt_xyz", - "token_sets": [], - "domain": "tenant.auth0.com", - } - - client = ServerClient( - domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - transaction_store=AsyncMock(), - state_store=mock_state_store, - secret="test_secret_key_32_chars_long!!", - ) - - mocker.patch.object( - client, "get_token_by_refresh_token", - AsyncMock(side_effect=OrganizationTokenValidationError("org_id mismatch on refresh")) - ) - mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ - "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - }) - - with pytest.raises(OrganizationTokenValidationError): - await client.get_access_token() - - # Adversarial tests @pytest.mark.asyncio @@ -5405,8 +5184,43 @@ async def test_adv_empty_string_org_id_raises(mocker): @pytest.mark.asyncio -async def test_adv_org_in_untyped_dict_does_not_leak_into_transaction(mocker): - """org in authorization_params dict is overridden by typed organization field.""" +async def test_adv_org_in_untyped_dict_is_ignored(mocker): + """organization passed via authorization_params dict is stripped; typed field is the only path.""" + mock_tx_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "authorization_endpoint": "https://tenant.auth0.com/authorize", + }) + + # org passed only via authorization_params — must be ignored entirely + url = await client.start_interactive_login( + StartInteractiveLoginOptions( + authorization_params={"organization": "org_via_dict"}, + ) + ) + + # TransactionData must not store any org (dict path is blocked) + stored = mock_tx_store.set.call_args[0][1] + assert stored.organization is None + + # URL must not contain the dict value + parsed = parse_qs(urlparse(url).query) + assert "organization" not in parsed + + +@pytest.mark.asyncio +async def test_adv_typed_org_wins_over_dict_injection(mocker): + """Typed organization field wins when both typed and dict values are present.""" mock_tx_store = AsyncMock() mock_state_store = AsyncMock() client = ServerClient( @@ -5423,7 +5237,6 @@ async def test_adv_org_in_untyped_dict_does_not_leak_into_transaction(mocker): "authorization_endpoint": "https://tenant.auth0.com/authorize", }) - # Typed organization wins; untyped dict has attacker value url = await client.start_interactive_login( StartInteractiveLoginOptions( organization="org_legitimate", @@ -5435,11 +5248,10 @@ async def test_adv_org_in_untyped_dict_does_not_leak_into_transaction(mocker): stored = mock_tx_store.set.call_args[0][1] assert stored.organization == "org_legitimate" - # URL must not contain the attacker value + # URL must contain only the typed value parsed = parse_qs(urlparse(url).query) org_values = parsed.get("organization", []) - assert "org_attacker" not in org_values - assert "org_legitimate" in org_values + assert org_values == ["org_legitimate"] @pytest.mark.asyncio @@ -5609,546 +5421,392 @@ async def test_org_requested_no_userinfo_no_id_token_fails_closed(mocker): @pytest.mark.asyncio -async def test_ciba_org_id_matching_succeeds(mocker): - """backchannel_authentication_grant with matching org_id passes.""" +async def test_org_userinfo_non_dict_raises_organization_error(mocker): + """Non-dict truthy userinfo raises OrganizationTokenValidationError when org is requested.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123" + ) + mock_state_store = AsyncMock() + mock_state_store.get.return_value = None client = ServerClient( - domain="auth0.local", - client_id="client_id", - client_secret="client_secret", - secret="some-secret" + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + secret="test_secret_key_32_chars_long!!", + transaction_store=mock_tx_store, + state_store=mock_state_store, ) mocker.patch.object( client, "_get_oidc_metadata_cached", - return_value={"token_endpoint": "https://auth0.local/token"} - ) - mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = MagicMock(return_value={ - "access_token": "at_ciba", - "id_token": "id_token_jwt", - "expires_in": 3600, - }) - mock_response.headers = {} - mock_post.return_value = mock_response - - mocker.patch.object( - client, "_get_jwks_cached", - return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} - ) - mocker.patch.object( - client, "_verify_and_decode_jwt", - return_value={"sub": "u1", "org_id": "org_abc123"} - ) - - result = await client.backchannel_authentication_grant( - "auth_req_123", organization="org_abc123" + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } ) - assert result["access_token"] == "at_ciba" - + # Return a string (truthy, but not a dict) as userinfo + mocker.patch.object(client._oauth, "fetch_token", AsyncMock(return_value={ + "access_token": "at123", + "userinfo": "not-a-dict", + })) + with pytest.raises(OrganizationTokenValidationError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert "valid claims dictionary" in str(exc.value) @pytest.mark.asyncio -async def test_ciba_org_id_mismatch_raises(mocker): - """backchannel_authentication_grant with wrong org_id raises OrganizationTokenValidationError.""" +async def test_userinfo_non_dict_no_org_raises_api_error(mocker): + """Non-dict truthy userinfo without org requested raises ApiError (not OrganizationTokenValidationError).""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", + ) + mock_state_store = AsyncMock() + mock_state_store.get.return_value = None client = ServerClient( - domain="auth0.local", - client_id="client_id", - client_secret="client_secret", - secret="some-secret" + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + secret="test_secret_key_32_chars_long!!", + transaction_store=mock_tx_store, + state_store=mock_state_store, ) mocker.patch.object( client, "_get_oidc_metadata_cached", - return_value={"token_endpoint": "https://auth0.local/token"} + return_value={ + "issuer": "https://tenant.auth0.com/", + "token_endpoint": "https://tenant.auth0.com/token", + } ) - mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = MagicMock(return_value={ - "access_token": "at_ciba", - "id_token": "id_token_jwt", - "expires_in": 3600, - }) - mock_response.headers = {} - mock_post.return_value = mock_response + mocker.patch.object(client._oauth, "fetch_token", AsyncMock(return_value={ + "access_token": "at123", + "userinfo": "not-a-dict", + })) + with pytest.raises(ApiError) as exc: + await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") + assert exc.value.code == "invalid_response" + assert "valid claims dictionary" in str(exc.value) - mocker.patch.object( - client, "_get_jwks_cached", - return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} - ) - mocker.patch.object( - client, "_verify_and_decode_jwt", - return_value={"sub": "u1", "org_id": "org_different"} - ) - with pytest.raises(OrganizationTokenValidationError) as exc: - await client.backchannel_authentication_grant( - "auth_req_123", organization="org_abc123" - ) - assert "mismatch" in str(exc.value) +# --------------------------------------------------------------------------- +# _classify_org_error unit tests +# --------------------------------------------------------------------------- +def test_classify_org_error_invitation_ticket_in_description(): + err = _classify_org_error("invalid_request", "invalid_user_invitation_ticket: ticket has expired") + assert isinstance(err, InvitationError) + assert err.code == "invitation_error" -@pytest.mark.asyncio -async def test_ciba_client_level_org_propagates(mocker): - """Client-level organization propagates through login_backchannel.""" - mock_state_store = AsyncMock() - mock_state_store.get.return_value = {"token_sets": []} +def test_classify_org_error_invitation_ticket_as_error_code(): + err = _classify_org_error("invalid_user_invitation_ticket", "The invitation ticket is invalid.") + assert isinstance(err, InvitationError) - client = ServerClient( - domain="auth0.local", - client_id="client_id", - client_secret="client_secret", - secret="some-secret", - transaction_store=AsyncMock(), - state_store=mock_state_store, - organization="org_client_level", - ) - mock_backchannel = AsyncMock(return_value={ - "access_token": "at_ciba", - "expires_in": 3600, - }) - mocker.patch.object(client, "backchannel_authentication", mock_backchannel) +def test_classify_org_error_user_not_member(): + desc = "user abc123 is not part of the org_xyz organization" + err = _classify_org_error("access_denied", desc) + assert isinstance(err, OrganizationAccessDeniedError) + assert err.code == "organization_access_denied_error" - await client.login_backchannel({ - "binding_message": "Approve login", - "login_hint": {"sub": "user1"}, - }) - called_options = mock_backchannel.call_args[0][0] - assert called_options.get("organization") == "org_client_level" +def test_classify_org_error_connection_not_enabled(): + err = _classify_org_error("access_denied", "connection is not enabled for this organization") + assert isinstance(err, OrganizationAccessDeniedError) +def test_classify_org_error_member_quota_exceeded(): + err = _classify_org_error("access_denied", "Quota exceeded: organization member limit has been reached") + assert isinstance(err, OrganizationAccessDeniedError) -@pytest.mark.asyncio -async def test_ciba_org_requested_no_id_token_fails_closed(mocker): - """org requested but id_token absent in CIBA grant response raises OrganizationTokenValidationError.""" - client = ServerClient( - domain="auth0.local", - client_id="client_id", - client_secret="client_secret", - secret="some-secret" - ) - mocker.patch.object( - client, "_get_oidc_metadata_cached", - return_value={"token_endpoint": "https://auth0.local/token"} + +def test_classify_org_error_user_id_too_long(): + err = _classify_org_error( + "access_denied", + "Organizations feature is not supported for users with user_id that longer than 1024 characters", ) - mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = MagicMock(return_value={ - "access_token": "at_ciba", - # no id_token - "expires_in": 3600, - }) - mock_response.headers = {} - mock_post.return_value = mock_response + assert isinstance(err, OrganizationAccessDeniedError) - with pytest.raises(OrganizationTokenValidationError) as exc: - await client.backchannel_authentication_grant( - "auth_req_123", organization="org_abc123" - ) - assert "did not include an ID token" in str(exc.value) +def test_classify_org_error_org_must_be_id(): + err = _classify_org_error( + "invalid_request", + "authorization request parameter organization must be an organization id", + ) + assert isinstance(err, OrganizationRequiredError) + assert err.code == "organization_required_error" -@pytest.mark.asyncio -async def test_ciba_no_org_no_id_token_succeeds(mocker): - """No org requested and no id_token — grant succeeds without validation.""" - client = ServerClient( - domain="auth0.local", - client_id="client_id", - client_secret="client_secret", - secret="some-secret" +def test_classify_org_error_org_must_be_id_or_name(): + err = _classify_org_error( + "invalid_request", + "authorization request parameter organization must be an organization id or name", ) - mocker.patch.object( - client, "_get_oidc_metadata_cached", - return_value={"token_endpoint": "https://auth0.local/token"} - ) - mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = MagicMock(return_value={ - "access_token": "at_ciba", - # no id_token, no org - "expires_in": 3600, - }) - mock_response.headers = {} - mock_post.return_value = mock_response + assert isinstance(err, OrganizationRequiredError) - result = await client.backchannel_authentication_grant("auth_req_123") - assert result["access_token"] == "at_ciba" +def test_classify_org_error_organizations_disabled(): + err = _classify_org_error("invalid_request", "organizations feature is not enabled") + assert isinstance(err, OrganizationRequiredError) -@pytest.mark.asyncio -async def test_refresh_response_matching_org_id_accepted(mocker): - """get_token_by_refresh_token validates org claims in returned id_token.""" - client = ServerClient( - domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - transaction_store=AsyncMock(), - state_store=AsyncMock(), - secret="test_secret_key_32_chars_long!!", - ) - mocker.patch.object( - client, "_get_oidc_metadata_cached", - return_value={ - "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - } - ) - mocker.patch.object( - client, "_get_jwks_cached", - return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} - ) - mocker.patch.object( - client, "_verify_and_decode_jwt", - return_value={"sub": "u1", "org_id": "org_abc123"} +def test_classify_org_error_organizations_not_supported_for_client(): + err = _classify_org_error( + "invalid_request", + "organizations feature is not supported for non-first party or non-strict third party clients", ) + assert isinstance(err, OrganizationRequiredError) - mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = MagicMock(return_value={ - "access_token": "new_at", - "id_token": "id_token_jwt", - "expires_in": 3600, - }) - mock_post.return_value = mock_response - result = await client.get_token_by_refresh_token({ - "refresh_token": "rt_xyz", - "organization": "org_abc123", - }) - assert result["access_token"] == "new_at" +def test_classify_org_error_org_required_for_client(): + err = _classify_org_error("invalid_request", "parameter organization is required for this client") + assert isinstance(err, OrganizationRequiredError) +def test_classify_org_error_org_not_allowed_for_client(): + err = _classify_org_error("invalid_request", "parameter organization is not allowed for this client") + assert isinstance(err, OrganizationRequiredError) -@pytest.mark.asyncio -async def test_refresh_response_wrong_org_id_raises(mocker): - """get_token_by_refresh_token raises OrganizationTokenValidationError when refreshed id_token has wrong org.""" - client = ServerClient( - domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - transaction_store=AsyncMock(), - state_store=AsyncMock(), - secret="test_secret_key_32_chars_long!!", - ) - mocker.patch.object( - client, "_get_oidc_metadata_cached", - return_value={ - "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - } - ) - mocker.patch.object( - client, "_get_jwks_cached", - return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} - ) - mocker.patch.object( - client, "_verify_and_decode_jwt", - return_value={"sub": "u1", "org_id": "org_attacker"} - ) - mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = MagicMock(return_value={ - "access_token": "new_at", - "id_token": "id_token_jwt", - "expires_in": 3600, - }) - mock_post.return_value = mock_response +def test_classify_org_error_unrelated_access_denied_passthrough(): + err = _classify_org_error("access_denied", "User cancelled the login.") + assert isinstance(err, ApiError) + assert err.code == "access_denied" - with pytest.raises(OrganizationTokenValidationError) as exc: - await client.get_token_by_refresh_token({ - "refresh_token": "rt_xyz", - "organization": "org_abc123", - }) - assert "mismatch" in str(exc.value) +def test_classify_org_error_unrelated_invalid_request_passthrough(): + err = _classify_org_error("invalid_request", "redirect_uri does not match") + assert isinstance(err, ApiError) + assert err.code == "invalid_request" -@pytest.mark.asyncio -async def test_refresh_response_no_id_token_with_org_succeeds(mocker): - """When refresh response has no id_token, org validation is skipped (AS choice).""" - client = ServerClient( - domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - transaction_store=AsyncMock(), - state_store=AsyncMock(), - secret="test_secret_key_32_chars_long!!", - ) - mocker.patch.object( - client, "_get_oidc_metadata_cached", - return_value={ - "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - } - ) +def test_classify_org_error_server_error_passthrough(): + err = _classify_org_error("server_error", "Internal server error") + assert isinstance(err, ApiError) + assert err.code == "server_error" - mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = MagicMock(return_value={ - "access_token": "new_at", - # no id_token — AS did not include one - "expires_in": 3600, - }) - mock_post.return_value = mock_response - result = await client.get_token_by_refresh_token({ - "refresh_token": "rt_xyz", - "organization": "org_abc123", - }) - assert result["access_token"] == "new_at" +def test_classify_org_error_preserves_description(): + desc = "user xyz is not part of the org_abc organization" + err = _classify_org_error("access_denied", desc) + assert err.message == desc +# --------------------------------------------------------------------------- +# complete_interactive_login — org error classification via callback URL +# --------------------------------------------------------------------------- -@pytest.mark.asyncio -async def test_org_userinfo_non_dict_raises_organization_error(mocker): - """Non-dict truthy userinfo raises OrganizationTokenValidationError when org is requested.""" - mock_tx_store = AsyncMock() - mock_tx_store.get.return_value = TransactionData( - code_verifier="cv", domain="tenant.auth0.com", organization="org_abc123" - ) - mock_state_store = AsyncMock() - mock_state_store.get.return_value = None - client = ServerClient( +def _make_org_callback_client(mock_tx_store, mock_state_store, org=None): + return ServerClient( domain="tenant.auth0.com", client_id="test_client", client_secret="test_secret", secret="test_secret_key_32_chars_long!!", + organization=org, transaction_store=mock_tx_store, state_store=mock_state_store, ) - mocker.patch.object( - client, "_get_oidc_metadata_cached", - return_value={ - "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - } + + +@pytest.mark.asyncio +async def test_callback_org_access_denied_raises_typed_error(): + """access_denied from org membership check → OrganizationAccessDeniedError.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" ) - # Return a string (truthy, but not a dict) as userinfo - mocker.patch.object(client._oauth, "fetch_token", AsyncMock(return_value={ - "access_token": "at123", - "userinfo": "not-a-dict", - })) - with pytest.raises(OrganizationTokenValidationError) as exc: - await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") - assert "valid claims dictionary" in str(exc.value) + client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") + desc = "user u1 is not part of the org_abc organization" + url = f"http://localhost/cb?state=xyz&error=access_denied&error_description={desc}" + with pytest.raises(OrganizationAccessDeniedError) as exc: + await client.complete_interactive_login(url) + assert desc in exc.value.message @pytest.mark.asyncio -async def test_userinfo_non_dict_no_org_raises_api_error(mocker): - """Non-dict truthy userinfo without org requested raises ApiError (not OrganizationTokenValidationError).""" +async def test_callback_org_invalid_format_raises_typed_error(): + """invalid_request with bad org format → OrganizationRequiredError.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( - code_verifier="cv", domain="tenant.auth0.com", + code_verifier="cv", domain="tenant.auth0.com", organization="my-org" ) - mock_state_store = AsyncMock() - mock_state_store.get.return_value = None - client = ServerClient( - domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - secret="test_secret_key_32_chars_long!!", - transaction_store=mock_tx_store, - state_store=mock_state_store, + client = _make_org_callback_client(mock_tx_store, AsyncMock()) + desc = "authorization request parameter organization must be an organization id" + url = f"http://localhost/cb?state=xyz&error=invalid_request&error_description={desc}" + with pytest.raises(OrganizationRequiredError) as exc: + await client.complete_interactive_login(url) + assert exc.value.code == "organization_required_error" + + +@pytest.mark.asyncio +async def test_callback_invitation_error_raises_typed_error(): + """Expired/invalid invitation ticket → InvitationError.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" ) - mocker.patch.object( - client, "_get_oidc_metadata_cached", - return_value={ - "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - } + client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") + desc = "invalid_user_invitation_ticket: ticket has already been used" + url = f"http://localhost/cb?state=xyz&error=invalid_request&error_description={desc}" + with pytest.raises(InvitationError) as exc: + await client.complete_interactive_login(url) + assert exc.value.code == "invitation_error" + + +@pytest.mark.asyncio +async def test_callback_unrelated_access_denied_raises_api_error(): + """access_denied not matching any org fragment → plain ApiError.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", ) - mocker.patch.object(client._oauth, "fetch_token", AsyncMock(return_value={ - "access_token": "at123", - "userinfo": "not-a-dict", - })) + client = _make_org_callback_client(mock_tx_store, AsyncMock()) + url = "http://localhost/cb?state=xyz&error=access_denied&error_description=User+cancelled" with pytest.raises(ApiError) as exc: - await client.complete_interactive_login("http://localhost/cb?code=abc&state=xyz") - assert exc.value.code == "invalid_response" - assert "valid claims dictionary" in str(exc.value) - + await client.complete_interactive_login(url) + assert type(exc.value) is ApiError + assert exc.value.code == "access_denied" @pytest.mark.asyncio -async def test_ciba_polling_loop_reraises_org_validation_error(mocker): - """OrganizationTokenValidationError from grant is not swallowed by the polling loop.""" - client = ServerClient( - domain="auth0.local", - client_id="client_id", - client_secret="client_secret", - secret="some-secret" +async def test_callback_connection_not_enabled_for_org_raises_typed_error(): + """access_denied for connection not enabled → OrganizationAccessDeniedError.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" ) + client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") + desc = "connection is not enabled for this organization" + url = f"http://localhost/cb?state=xyz&error=access_denied&error_description={desc}" + with pytest.raises(OrganizationAccessDeniedError): + await client.complete_interactive_login(url) - mocker.patch.object( - client, "initiate_backchannel_authentication", - AsyncMock(return_value={"auth_req_id": "req_123", "expires_in": 30, "interval": 1}) - ) - mocker.patch.object( - client, "backchannel_authentication_grant", - AsyncMock(side_effect=OrganizationTokenValidationError("org_id mismatch in CIBA grant")) - ) - with pytest.raises(OrganizationTokenValidationError) as exc: - await client.backchannel_authentication({"organization": "org_abc123", "login_hint": {"sub": "u1"}, "binding_message": "test"}) - assert "mismatch" in str(exc.value) +def test_org_error_classes_are_auth0_errors(): + """All three new error classes inherit from Auth0Error.""" + from auth0_server_python.error import Auth0Error # noqa: PLC0415 + assert issubclass(OrganizationRequiredError, Auth0Error) + assert issubclass(OrganizationAccessDeniedError, Auth0Error) + assert issubclass(InvitationError, Auth0Error) +def test_org_error_codes(): + assert OrganizationRequiredError("x").code == "organization_required_error" + assert OrganizationAccessDeniedError("x").code == "organization_access_denied_error" + assert InvitationError("x").code == "invitation_error" -@pytest.mark.asyncio -async def test_custom_token_exchange_org_mismatch_raises(mocker): - """login_with_custom_token_exchange validates org claims in returned id_token.""" - client = ServerClient( - domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - transaction_store=AsyncMock(), - state_store=AsyncMock(), - secret="test_secret_key_32_chars_long!!", - ) - mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ - "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - }) - mocker.patch.object(client, "_get_jwks_cached", return_value={"keys": []}) - mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ - "sub": "u1", "iss": "https://tenant.auth0.com/", "org_id": "org_attacker", - }) - mocker.patch.object( - client, "custom_token_exchange", - AsyncMock(return_value=TokenExchangeResponse( - access_token="at", token_type="Bearer", expires_in=3600, - id_token="header.payload.sig" - )) - ) +def test_org_error_names(): + assert OrganizationRequiredError("x").name == "OrganizationRequiredError" + assert OrganizationAccessDeniedError("x").name == "OrganizationAccessDeniedError" + assert InvitationError("x").name == "InvitationError" - with pytest.raises(OrganizationTokenValidationError) as exc: - await client.login_with_custom_token_exchange( - LoginWithCustomTokenExchangeOptions( - subject_token="tok", - subject_token_type="urn:example:type", - organization="org_abc123", - ) - ) - assert "mismatch" in str(exc.value) + +def test_org_error_stores_cause(): + cause = ValueError("upstream") + err = OrganizationAccessDeniedError("denied", cause=cause) + assert err.cause is cause +# --------------------------------------------------------------------------- +# Org resolution — per-login vs client-level precedence +# --------------------------------------------------------------------------- + @pytest.mark.asyncio -async def test_custom_token_exchange_org_error_not_swallowed(mocker): - """OrganizationTokenValidationError propagates, not wrapped as CustomTokenExchangeError.""" +async def test_per_login_org_overrides_client_default(mocker): + """ + A per-login org value overrides the client-level default. + Both paths end up in TransactionData — this is the multi-org scenario regression guard. + """ + mock_tx_store = AsyncMock() + stored_tx = None + + async def capture_set(key, value, options=None): + nonlocal stored_tx + stored_tx = value + + mock_tx_store.set.side_effect = capture_set + client = ServerClient( domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - transaction_store=AsyncMock(), - state_store=AsyncMock(), + client_id="cid", + client_secret="csec", secret="test_secret_key_32_chars_long!!", + organization="org_default", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=AsyncMock(), ) mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - }) - mocker.patch.object(client, "_get_jwks_cached", return_value={"keys": []}) - mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ - "sub": "u1", "iss": "https://tenant.auth0.com/", "org_name": "evil-corp", + "authorization_endpoint": "https://tenant.auth0.com/authorize", }) + mocker.patch.object(client._oauth, "create_authorization_url", + return_value=("https://tenant.auth0.com/authorize?state=x", "x")) - mocker.patch.object( - client, "custom_token_exchange", - AsyncMock(return_value=TokenExchangeResponse( - access_token="at", token_type="Bearer", expires_in=3600, - id_token="header.payload.sig" - )) + await client.start_interactive_login( + StartInteractiveLoginOptions(organization="org_override") ) - with pytest.raises(OrganizationTokenValidationError): - await client.login_with_custom_token_exchange( - LoginWithCustomTokenExchangeOptions( - subject_token="tok", - subject_token_type="urn:example:type", - organization="acme-corp", - ) - ) + assert stored_tx.organization == "org_override" @pytest.mark.asyncio -async def test_custom_token_exchange_org_no_id_token_fails_closed(mocker): - """login_with_custom_token_exchange must fail closed when org requested but no id_token returned.""" +async def test_blank_org_raises_invalid_argument_error(mocker): + """Whitespace-only organization value is rejected with InvalidArgumentError.""" client = ServerClient( domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", + client_id="cid", + client_secret="csec", + secret="test_secret_key_32_chars_long!!", + redirect_uri="https://app.example.com/callback", transaction_store=AsyncMock(), state_store=AsyncMock(), - secret="test_secret_key_32_chars_long!!", ) mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", + "authorization_endpoint": "https://tenant.auth0.com/authorize", }) - mocker.patch.object( - client, "custom_token_exchange", - AsyncMock(return_value=TokenExchangeResponse( - access_token="at", token_type="Bearer", expires_in=3600, - id_token=None - )) - ) - - with pytest.raises(OrganizationTokenValidationError) as exc: - await client.login_with_custom_token_exchange( - LoginWithCustomTokenExchangeOptions( - subject_token="tok", - subject_token_type="urn:example:type", - organization="org_abc123", - ) + with pytest.raises(InvalidArgumentError) as exc: + await client.start_interactive_login( + StartInteractiveLoginOptions(organization=" ") ) - assert "did not include an ID token" in str(exc.value) + assert "organization" in exc.value.argument @pytest.mark.asyncio -async def test_custom_token_exchange_issuer_error_not_swallowed(mocker): - """IssuerValidationError from id_token propagates, not wrapped as CustomTokenExchangeError.""" +async def test_client_level_org_used_when_options_org_is_none_not_set(mocker): + """ + When StartInteractiveLoginOptions does not set organization (defaults to None), + the client-level default is used — same as before the fix. + """ + mock_tx_store = AsyncMock() + stored_tx = None + + async def capture_set(key, value, options=None): + nonlocal stored_tx + stored_tx = value + + mock_tx_store.set.side_effect = capture_set + client = ServerClient( domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - transaction_store=AsyncMock(), - state_store=AsyncMock(), + client_id="cid", + client_secret="csec", secret="test_secret_key_32_chars_long!!", + organization="org_default", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=AsyncMock(), ) mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ "issuer": "https://tenant.auth0.com/", - "token_endpoint": "https://tenant.auth0.com/token", - }) - mocker.patch.object(client, "_get_jwks_cached", return_value={"keys": []}) - # Token claims an issuer from a different domain - mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ - "sub": "u1", "iss": "https://attacker.example.com/", + "authorization_endpoint": "https://tenant.auth0.com/authorize", }) + mocker.patch.object(client._oauth, "create_authorization_url", + return_value=("https://tenant.auth0.com/authorize?state=x", "x")) - mocker.patch.object( - client, "custom_token_exchange", - AsyncMock(return_value=TokenExchangeResponse( - access_token="at", token_type="Bearer", expires_in=3600, - id_token="header.payload.sig" - )) - ) + await client.start_interactive_login(StartInteractiveLoginOptions()) - with pytest.raises(IssuerValidationError): - await client.login_with_custom_token_exchange( - LoginWithCustomTokenExchangeOptions( - subject_token="tok", - subject_token_type="urn:example:type", - ) - ) + assert stored_tx.organization == "org_default" From 52504fe155f87d48222363c54df72c8cbfee5a34 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 18 Jun 2026 11:16:18 +0530 Subject: [PATCH 08/17] SDK-8833 Moved invitation param to inside authorization params --- .../auth_server/server_client.py | 6 +--- .../auth_types/__init__.py | 1 - .../tests/test_server_client.py | 36 +++---------------- 3 files changed, 5 insertions(+), 38 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 7a67e94..f923bae 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -75,7 +75,7 @@ # dynamically from the resolved domain at login time. INTERNAL_AUTHORIZE_PARAMS = ["client_id", "response_type", "code_challenge", "code_challenge_method", "state", "nonce", "scope", - "organization", "invitation"] + "organization"] _ORG_ACCESS_DENIED_FRAGMENTS = ( "is not part of the", @@ -583,10 +583,6 @@ async def start_interactive_login( if resolved_org: auth_params["organization"] = resolved_org - # Invitation is forwarded to /authorize but not stored for callback validation. - if options.invitation: - auth_params["invitation"] = options.invitation - # Build the transaction data to store with domain transaction_data = TransactionData( code_verifier=code_verifier, diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 05eb1e7..6e14161 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -151,7 +151,6 @@ class StartInteractiveLoginOptions(BaseModel): app_state: Optional[Any] = None authorization_params: Optional[dict[str, Any]] = None organization: Optional[str] = None - invitation: Optional[str] = None class LogoutOptions(BaseModel): diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index a78d352..2b68ce1 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -4989,7 +4989,7 @@ async def test_invitation_and_org_forwarded_to_authorize(mocker): url = await client.start_interactive_login( StartInteractiveLoginOptions( organization="org_abc123", - invitation="inv_token_xyz", + authorization_params={"invitation": "inv_token_xyz"}, ) ) @@ -5001,7 +5001,6 @@ async def test_invitation_and_org_forwarded_to_authorize(mocker): assert stored.organization == "org_abc123" - @pytest.mark.asyncio async def test_invitation_without_org_forwarded_to_authorize(mocker): """invitation alone appears in the URL; no organization param.""" @@ -5021,41 +5020,14 @@ async def test_invitation_without_org_forwarded_to_authorize(mocker): "authorization_endpoint": "https://tenant.auth0.com/authorize", }) - url = await client.start_interactive_login( - StartInteractiveLoginOptions(invitation="inv_token_xyz") - ) - - assert "invitation=inv_token_xyz" in url - assert "organization=" not in url - - -@pytest.mark.asyncio -async def test_invitation_via_authorization_params_dict_is_ignored(mocker): - """invitation passed via authorization_params dict is stripped; typed field is the only path.""" - mock_tx_store = AsyncMock() - mock_state_store = AsyncMock() - client = ServerClient( - domain="tenant.auth0.com", - client_id="test_client", - client_secret="test_secret", - redirect_uri="https://app.example.com/callback", - transaction_store=mock_tx_store, - state_store=mock_state_store, - secret="test_secret_key_32_chars_long!!", - ) - mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ - "issuer": "https://tenant.auth0.com/", - "authorization_endpoint": "https://tenant.auth0.com/authorize", - }) - url = await client.start_interactive_login( StartInteractiveLoginOptions( - authorization_params={"invitation": "inv_via_dict"}, + authorization_params={"invitation": "inv_token_xyz"} ) ) - parsed = parse_qs(urlparse(url).query) - assert "invitation" not in parsed + assert "invitation=inv_token_xyz" in url + assert "organization=" not in url @pytest.mark.asyncio From 65bae24f210c0435bcbe480c1db00e1c1fe214c2 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 18 Jun 2026 12:46:52 +0530 Subject: [PATCH 09/17] SDK-8833: Added Example docs, refined org errors and reverted some comments --- README.md | 27 +++ examples/Organizations.md | 194 ++++++++++++++++++ .../auth_server/server_client.py | 61 +++++- src/auth0_server_python/error/__init__.py | 6 +- .../tests/test_server_client.py | 109 ++++++++-- 5 files changed, 369 insertions(+), 28 deletions(-) create mode 100644 examples/Organizations.md diff --git a/README.md b/README.md index ff5e46c..4e4747a 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,33 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md). +### 6. Organizations + +To restrict login to a specific organization, set `organization` at client initialization (dedicated-org) or per login (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='', + client_id='', + client_secret='', + secret='', + organization='org_abc123', + authorization_params={"redirect_uri": ""} +) + +# Multi-org: pass organization per login +authorization_url = await auth0.start_interactive_login( + StartInteractiveLoginOptions(organization="org_xyz789"), + store_options={"request": request, "response": response} +) +``` + +The SDK validates the `org_id` (or `org_name`) claim in the returned token automatically — no extra code needed at callback. For invitation flows, per-org error handling, and reading org data from the session, see [examples/Organizations.md](examples/Organizations.md). + ## Feedback ### Contributing diff --git a/examples/Organizations.md b/examples/Organizations.md new file mode 100644 index 0000000..e219063 --- /dev/null +++ b/examples/Organizations.md @@ -0,0 +1,194 @@ +# Organizations + +[Organizations](https://auth0.com/docs/organizations) is a set of features that provide better support for developers building SaaS and B2B applications. This guide covers the two main deployment patterns and the invitation flow. + +- [1. Dedicated-org instance](#1-dedicated-org-instance) +- [2. Multi-org — per-login override](#2-multi-org--per-login-override) +- [3. Log in using an organization name](#3-log-in-using-an-organization-name) +- [4. Accept user invitations](#4-accept-user-invitations) +- [5. Handling organization errors](#5-handling-organization-errors) +- [6. Reading organization data from the session](#6-reading-organization-data-from-the-session) + +## 1. Dedicated-org instance + +When a single instance of your application serves one organization, set `organization` at client initialization. Every login from that instance will include the `organization` parameter in the `/authorize` request and validate the `org_id` claim in the returned token automatically. + +```python +from auth0_server_python.auth_server.server_client import ServerClient + +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", + } +) +``` + +```python +from fastapi import FastAPI, Request, Response +from starlette.responses import RedirectResponse + +app = FastAPI() + +@app.get("/auth/login") +async def login(request: Request, response: Response): + authorization_url = await auth0.start_interactive_login( + store_options={"request": request, "response": response} + ) + return RedirectResponse(url=authorization_url) + +@app.get("/auth/callback") +async def callback(request: Request, response: Response): + result = await auth0.complete_interactive_login( + str(request.url), + store_options={"request": request, "response": response} + ) + return RedirectResponse(url="/dashboard") +``` + +> [!NOTE] +> You do not need to pass `organization` to `complete_interactive_login`. The SDK stores it in the encrypted transaction at login time and reads it back at callback — the validation is automatic. + +## 2. Multi-org — per-login override + +When one application instance serves multiple organizations (for example, a B2B SaaS where different users belong to different orgs), pass `organization` at login time using `StartInteractiveLoginOptions`. This overrides any client-level default for that specific login. + +```python +from auth0_server_python.auth_types import StartInteractiveLoginOptions + +@app.get("/auth/login") +async def login(request: Request, response: Response, org_id: str): + authorization_url = await auth0.start_interactive_login( + StartInteractiveLoginOptions(organization=org_id), + store_options={"request": request, "response": response} + ) + return RedirectResponse(url=authorization_url) +``` + +> [!IMPORTANT] +> Validate that `org_id` comes from a trusted source (your own data, a verified session, or a registered tenant list) — never pass it unvalidated from a query parameter directly from an untrusted user. + +## 3. Log in using an organization name + +`organization` accepts either an org ID (starts with `org_`) or an org name (any other value). The SDK uses the prefix to determine which token claim to validate at callback: + +- **`org_` prefix** → validates `org_id` claim (exact, case-sensitive match) +- **No `org_` prefix** → validates `org_name` claim (case-insensitive match) + +```python +# By org ID — validates the org_id claim in the token +authorization_url = await auth0.start_interactive_login( + StartInteractiveLoginOptions(organization="org_abc123") +) + +# By org name — validates the org_name claim in the token (case-insensitive) +authorization_url = await auth0.start_interactive_login( + StartInteractiveLoginOptions(organization="acme-corp") +) +``` + +> [!NOTE] +> Auth0 enforces that organization names cannot start with `org_`, so the prefix dispatch is unambiguous. When using org name, the SDK applies NFC Unicode normalization before comparison to prevent false rejections from visually identical characters with different byte representations. + +## 4. Accept user invitations + +When a user follows an invitation link, extract the `invitation` and `organization` parameters from the URL and pass them at login time. Auth0 validates the invitation ticket server-side — your application does not need to verify it. + +The invitation URL Auth0 generates has this shape: +``` +https://your-tenant.auth0.com/login?invitation={INVITATION_TOKEN}&organization={ORG_ID}&organization_name={ORG_NAME} +``` + +```python +@app.get("/auth/login") +async def login(request: Request, response: Response): + invitation = request.query_params.get("invitation") + organization = request.query_params.get("organization") + + options = StartInteractiveLoginOptions(organization=organization) + if invitation: + options.authorization_params = {"invitation": invitation} + + authorization_url = await auth0.start_interactive_login( + options, + store_options={"request": request, "response": response} + ) + return RedirectResponse(url=authorization_url) +``` + +> [!NOTE] +> `organization` and `invitation` are forwarded to `/authorize`. Auth0 consumes the invitation ticket server-side — it is not stored in the encrypted transaction. If the ticket is expired or already used, `complete_interactive_login` raises `OrganizationInvitationError`. + +## 5. Handling organization errors + +The SDK raises typed exceptions for org-specific failure modes. Catch them in your callback handler to return meaningful responses to your users. + +```python +from auth0_server_python.error import ( + OrganizationInvitationError, + OrganizationAccessDeniedError, + OrganizationRequiredError, + 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 OrganizationAccessDeniedError: + # User is not a member of the org, the connection is not enabled + # for the org, or the org member quota has been exceeded. + return RedirectResponse(url="/error?reason=not_org_member") + except OrganizationRequiredError: + # Configuration problem — invalid org format, Organizations feature + # disabled, or the client is not configured for organizations. + return RedirectResponse(url="/error?reason=org_config") + except OrganizationInvitationError: + # The invitation ticket is expired, already used, or invalid. + return RedirectResponse(url="/error?reason=invitation_invalid") + except OrganizationTokenValidationError: + # The org_id or org_name in the returned token does not match + # the organization that was requested at login. + return RedirectResponse(url="/error?reason=org_mismatch") +``` + +| Exception | When raised | +|-----------|-------------| +| `OrganizationAccessDeniedError` | User not a member, connection not enabled for org, member quota exceeded | +| `OrganizationRequiredError` | Invalid org format, feature disabled, client not configured for orgs | +| `OrganizationInvitationError` | Invitation ticket expired, already used, or invalid | +| `OrganizationTokenValidationError` | `org_id` / `org_name` in the returned token does not match what was requested | + +## 6. Reading organization data from the session + +After a successful org login, `org_id` and `org_name` are available on the user object. Use `get_user()` to retrieve them on subsequent requests: + +```python +user = await auth0.get_user(store_options={"request": request, "response": response}) +if user: + print(user.get("org_id")) # e.g. "org_abc123" + print(user.get("org_name")) # e.g. "acme-corp" +``` + +You can also read them immediately from the `complete_interactive_login` result: + +```python +result = await auth0.complete_interactive_login( + str(request.url), + store_options={"request": request, "response": response} +) +user = result["state_data"].get("user", {}) +print(user.get("org_id")) +print(user.get("org_name")) +``` + +> [!NOTE] +> `org_name` is mutable — Auth0 allows renaming an organization after creation. Use `org_id` as the stable identifier for any persistent storage (e.g., mapping users to tenants in your database). Surface `org_name` only for display purposes. diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index f923bae..898b12a 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -51,7 +51,7 @@ CustomTokenExchangeErrorCode, DomainResolverError, InvalidArgumentError, - InvitationError, + OrganizationInvitationError, IssuerValidationError, MfaRequiredError, MissingRequiredArgumentError, @@ -77,36 +77,44 @@ "code_challenge", "code_challenge_method", "state", "nonce", "scope", "organization"] -_ORG_ACCESS_DENIED_FRAGMENTS = ( +_ORG_ACCESS_DENIED_DESCRIPTIONS = ( "is not part of the", - "connection is not enabled for this organization", "quota exceeded", "organization member limit", "user_id that longer than 1024", ) -_ORG_INVALID_REQUEST_FRAGMENTS = ( +_ORG_INVALID_REQUEST_DESCRIPTIONS = ( "organization must be an organization id", "organizations feature is not enabled", "organizations feature is not supported", "parameter organization is required", "parameter organization is not allowed", + "parameter organization is invalid", + "organization name must have between", + "organization must only contain lowercase", "client is missing", ) +_INVITATION_DESCRIPTIONS = ( + "invalid_user_invitation_ticket", + "invitation not found or already used", + "invalid invitation ticket", +) + def _classify_org_error(error: str, error_description: str) -> "ApiError": desc_lower = error_description.lower() - if "invalid_user_invitation_ticket" in desc_lower or error == "invalid_user_invitation_ticket": - return InvitationError(error_description) + if error == "invalid_user_invitation_ticket" or any(desc in desc_lower for desc in _INVITATION_DESCRIPTIONS): + return OrganizationInvitationError(error_description) if error == "access_denied": - if any(f in desc_lower for f in _ORG_ACCESS_DENIED_FRAGMENTS): + if any(desc in desc_lower for desc in _ORG_ACCESS_DENIED_DESCRIPTIONS): return OrganizationAccessDeniedError(error_description) if error == "invalid_request": - if any(f in desc_lower for f in _ORG_INVALID_REQUEST_FRAGMENTS): + if any(desc in desc_lower for desc in _ORG_INVALID_REQUEST_DESCRIPTIONS): return OrganizationRequiredError(error_description) return ApiError(error, error_description) @@ -119,7 +127,9 @@ class ServerClient(Generic[TStoreOptions]): """ DEFAULT_AUDIENCE_STATE_KEY = "default" + # ============================================================================ # INITIALIZATION + # ============================================================================ def __init__( self, @@ -513,7 +523,11 @@ async def _get_jwks_cached(self, domain: str, metadata: dict = None) -> dict: return jwks + # ============================================================================ # INTERACTIVE LOGIN FLOW + # Handles browser-based authentication using the Authorization Code flow + # with PKCE for secure token exchange. + # ============================================================================ async def start_interactive_login( self, @@ -685,7 +699,9 @@ async def complete_interactive_login( if "error" in query_params: error = query_params.get("error", [""])[0] error_description = query_params.get("error_description", [""])[0] - raise _classify_org_error(error, error_description) + if transaction_data.organization: + raise _classify_org_error(error, error_description) + raise ApiError(error, error_description) # Get the authorization code from the URL code = query_params.get("code", [""])[0] @@ -833,7 +849,10 @@ async def complete_interactive_login( return result + # ============================================================================ # USER SESSION MANAGEMENT + # Methods for retrieving user information, session data, and logout operations. + # ============================================================================ async def get_user(self, store_options: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]: """ @@ -1017,7 +1036,10 @@ async def handle_backchannel_logout( raise BackchannelLogoutError( f"Error processing logout token: {str(e)}") + # ============================================================================ # ACCESS TOKEN MANAGEMENT + # Retrieves, validates, and refreshes access tokens for API calls. + # ============================================================================ async def get_access_token( self, @@ -1283,7 +1305,11 @@ def _find_matching_token_set( # Return the token set with the smallest superset of scopes that matches the requested audience and scopes return min(matches, key=lambda t: t[0])[1] if matches else None + # ============================================================================ # BACKCHANNEL AUTHENTICATION (CIBA) + # Client-Initiated Backchannel Authentication for decoupled authentication + # flows where users authenticate on a separate device. + # ============================================================================ async def login_backchannel( self, @@ -1619,7 +1645,11 @@ async def backchannel_authentication_grant( e ) + # ============================================================================ # USER LINKING / UNLINKING + # Methods for linking and unlinking external identity provider accounts + # to a user's Auth0 profile. + # ============================================================================ async def start_link_user( self, @@ -1890,7 +1920,11 @@ async def _build_unlink_user_url( return URL.build_url(auth_endpoint, params) + # ============================================================================ # FEDERATED CONNECTION TOKENS + # Retrieves access tokens for federated identity provider connections + # (e.g., Google, GitHub) using token exchange. + # ============================================================================ async def get_access_token_for_connection( self, @@ -2057,7 +2091,11 @@ async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, A e ) + # ============================================================================ # CONNECTED ACCOUNTS + # Methods for managing third-party account connections via the My Account API. + # Includes initiating connections, completing flows, and CRUD operations. + # ============================================================================ async def start_connect_account( self, @@ -2284,7 +2322,10 @@ async def list_connected_account_connections( return await self._my_account_client.list_connected_account_connections( access_token=access_token, from_param=from_param, take=take) + # ============================================================================ # CUSTOM TOKEN EXCHANGE (RFC 8693) + # Exchanges external custom tokens for Auth0 tokens. + # ============================================================================ async def custom_token_exchange( self, @@ -2554,7 +2595,9 @@ async def login_with_custom_token_exchange( e ) + # ============================================================================ # MFA (Multi-Factor Authentication) + # ============================================================================ @property def mfa(self) -> MfaClient: diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 879053d..53ab65a 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -225,16 +225,16 @@ def __init__(self, message: str, cause: Optional[Exception] = None): self.cause = cause -class InvitationError(Auth0Error): +class OrganizationInvitationError(Auth0Error): """ Raised when an organization invitation is rejected by Auth0 — the ticket is expired, already used, or otherwise invalid. """ - code = "invitation_error" + code = "organization_invitation_error" def __init__(self, message: str, cause: Optional[Exception] = None): super().__init__(message) - self.name = "InvitationError" + self.name = "OrganizationInvitationError" self.cause = cause diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 2b68ce1..a68bd97 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -41,7 +41,7 @@ CustomTokenExchangeErrorCode, DomainResolverError, InvalidArgumentError, - InvitationError, + OrganizationInvitationError, IssuerValidationError, MfaRequiredError, MissingRequiredArgumentError, @@ -5466,13 +5466,34 @@ async def test_userinfo_non_dict_no_org_raises_api_error(mocker): def test_classify_org_error_invitation_ticket_in_description(): err = _classify_org_error("invalid_request", "invalid_user_invitation_ticket: ticket has expired") - assert isinstance(err, InvitationError) - assert err.code == "invitation_error" + assert isinstance(err, OrganizationInvitationError) + assert err.code == "organization_invitation_error" def test_classify_org_error_invitation_ticket_as_error_code(): err = _classify_org_error("invalid_user_invitation_ticket", "The invitation ticket is invalid.") - assert isinstance(err, InvitationError) + assert isinstance(err, OrganizationInvitationError) + + +def test_classify_org_error_invitation_not_found_or_already_used(): + err = _classify_org_error("invalid_request", "invitation not found or already used") + assert isinstance(err, OrganizationInvitationError) + assert err.code == "organization_invitation_error" + + +def test_classify_org_error_invalid_invitation_ticket_client_mismatch(): + err = _classify_org_error("invalid_request", "invalid invitation ticket (client_id mismatch)") + assert isinstance(err, OrganizationInvitationError) + + +def test_classify_org_error_invalid_invitation_ticket_org_mismatch(): + err = _classify_org_error("invalid_request", "invalid invitation ticket (organization mismatch)") + assert isinstance(err, OrganizationInvitationError) + + +def test_classify_org_error_invalid_invitation_ticket_unknown_roles(): + err = _classify_org_error("invalid_request", "invalid invitation ticket (unknown roles: role_abc123)") + assert isinstance(err, OrganizationInvitationError) def test_classify_org_error_user_not_member(): @@ -5482,8 +5503,9 @@ def test_classify_org_error_user_not_member(): assert err.code == "organization_access_denied_error" -def test_classify_org_error_connection_not_enabled(): - err = _classify_org_error("access_denied", "connection is not enabled for this organization") +def test_classify_org_error_connection_not_part_of_org(): + desc = "user abc123 belongs to connection google that is not part of the org_xyz organization" + err = _classify_org_error("access_denied", desc) assert isinstance(err, OrganizationAccessDeniedError) @@ -5540,6 +5562,34 @@ def test_classify_org_error_org_not_allowed_for_client(): assert isinstance(err, OrganizationRequiredError) +def test_classify_org_error_org_invalid_not_found(): + err = _classify_org_error("invalid_request", "parameter organization is invalid: org_doesnotexist") + assert isinstance(err, OrganizationRequiredError) + assert err.code == "organization_required_error" + + +def test_classify_org_error_org_name_length_invalid(): + err = _classify_org_error( + "invalid_request", + "organization name must have between 1 and 50 characters", + ) + assert isinstance(err, OrganizationRequiredError) + + +def test_classify_org_error_org_name_format_invalid(): + err = _classify_org_error( + "invalid_request", + "organization must only contain lowercase characters, '-' and '_', and start and end with a letter or number", + ) + assert isinstance(err, OrganizationRequiredError) + + +def test_classify_org_error_client_missing(): + err = _classify_org_error("invalid_request", "client is missing the organizations feature") + assert isinstance(err, OrganizationRequiredError) + assert err.code == "organization_required_error" + + def test_classify_org_error_unrelated_access_denied_passthrough(): err = _classify_org_error("access_denied", "User cancelled the login.") assert isinstance(err, ApiError) @@ -5612,7 +5662,7 @@ async def test_callback_org_invalid_format_raises_typed_error(): @pytest.mark.asyncio async def test_callback_invitation_error_raises_typed_error(): - """Expired/invalid invitation ticket → InvitationError.""" + """Expired/invalid invitation ticket → OrganizationInvitationError.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" @@ -5620,14 +5670,14 @@ async def test_callback_invitation_error_raises_typed_error(): client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") desc = "invalid_user_invitation_ticket: ticket has already been used" url = f"http://localhost/cb?state=xyz&error=invalid_request&error_description={desc}" - with pytest.raises(InvitationError) as exc: + with pytest.raises(OrganizationInvitationError) as exc: await client.complete_interactive_login(url) - assert exc.value.code == "invitation_error" + assert exc.value.code == "organization_invitation_error" @pytest.mark.asyncio async def test_callback_unrelated_access_denied_raises_api_error(): - """access_denied not matching any org fragment → plain ApiError.""" + """access_denied not matching any org error_description → plain ApiError.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( code_verifier="cv", domain="tenant.auth0.com", @@ -5641,37 +5691,64 @@ async def test_callback_unrelated_access_denied_raises_api_error(): @pytest.mark.asyncio -async def test_callback_connection_not_enabled_for_org_raises_typed_error(): - """access_denied for connection not enabled → OrganizationAccessDeniedError.""" +async def test_callback_connection_not_part_of_org_raises_typed_error(): + """access_denied for connection not part of org → OrganizationAccessDeniedError.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" ) client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") - desc = "connection is not enabled for this organization" + desc = "user u1 belongs to connection google that is not part of the org_abc organization" url = f"http://localhost/cb?state=xyz&error=access_denied&error_description={desc}" with pytest.raises(OrganizationAccessDeniedError): await client.complete_interactive_login(url) +@pytest.mark.asyncio +async def test_callback_invitation_not_found_raises_typed_error(): + """invitation not found or already used → OrganizationInvitationError.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" + ) + client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") + url = "http://localhost/cb?state=xyz&error=invalid_request&error_description=invitation+not+found+or+already+used" + with pytest.raises(OrganizationInvitationError) as exc: + await client.complete_interactive_login(url) + assert exc.value.code == "organization_invitation_error" + + +@pytest.mark.asyncio +async def test_callback_org_invalid_not_found_raises_typed_error(): + """parameter organization is invalid (org not found / third-party access denied) → OrganizationRequiredError.""" + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" + ) + client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") + url = "http://localhost/cb?state=xyz&error=invalid_request&error_description=parameter+organization+is+invalid%3A+org_doesnotexist" + with pytest.raises(OrganizationRequiredError): + await client.complete_interactive_login(url) + + def test_org_error_classes_are_auth0_errors(): """All three new error classes inherit from Auth0Error.""" from auth0_server_python.error import Auth0Error # noqa: PLC0415 assert issubclass(OrganizationRequiredError, Auth0Error) assert issubclass(OrganizationAccessDeniedError, Auth0Error) - assert issubclass(InvitationError, Auth0Error) + assert issubclass(OrganizationInvitationError, Auth0Error) def test_org_error_codes(): assert OrganizationRequiredError("x").code == "organization_required_error" assert OrganizationAccessDeniedError("x").code == "organization_access_denied_error" - assert InvitationError("x").code == "invitation_error" + assert OrganizationInvitationError("x").code == "organization_invitation_error" def test_org_error_names(): assert OrganizationRequiredError("x").name == "OrganizationRequiredError" assert OrganizationAccessDeniedError("x").name == "OrganizationAccessDeniedError" - assert InvitationError("x").name == "InvitationError" + assert OrganizationInvitationError("x").name == "OrganizationInvitationError" def test_org_error_stores_cause(): From e65876ab13d6abc7de129129b6ac62ea91f05a90 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 18 Jun 2026 13:04:25 +0530 Subject: [PATCH 10/17] SDK-8833 Docs and comments refinement --- examples/Organizations.md | 39 +++++++++---------- .../auth_types/__init__.py | 8 ++++ 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/examples/Organizations.md b/examples/Organizations.md index e219063..a4aad9c 100644 --- a/examples/Organizations.md +++ b/examples/Organizations.md @@ -1,17 +1,18 @@ # Organizations -[Organizations](https://auth0.com/docs/organizations) is a set of features that provide better support for developers building SaaS and B2B applications. This guide covers the two main deployment patterns and the invitation flow. +[Organizations](https://auth0.com/docs/organizations) is a set of features that provide better support for developers building SaaS and B2B applications. This guide covers org login, invitation flows, error handling, and reading org data from the session. -- [1. Dedicated-org instance](#1-dedicated-org-instance) -- [2. Multi-org — per-login override](#2-multi-org--per-login-override) -- [3. Log in using an organization name](#3-log-in-using-an-organization-name) -- [4. Accept user invitations](#4-accept-user-invitations) -- [5. Handling organization errors](#5-handling-organization-errors) -- [6. Reading organization data from the session](#6-reading-organization-data-from-the-session) +- [1. Configuring the organization](#1-configuring-the-organization) +- [2. Log in using an organization name](#2-log-in-using-an-organization-name) +- [3. Accept user invitations](#3-accept-user-invitations) +- [4. Handling organization errors](#4-handling-organization-errors) +- [5. Reading organization data from the session](#5-reading-organization-data-from-the-session) -## 1. Dedicated-org instance +## 1. Configuring the organization -When a single instance of your application serves one organization, set `organization` at client initialization. Every login from that instance will include the `organization` parameter in the `/authorize` request and validate the `org_id` claim in the returned token automatically. +The `organization` parameter can be set at client initialization (dedicated-org) or per login (multi-org). + +**Dedicated-org:** when a single instance of your application serves one organization, set `organization` at client initialization. Every login from that instance will enforce the org automatically. ```python from auth0_server_python.auth_server.server_client import ServerClient @@ -50,12 +51,7 @@ async def callback(request: Request, response: Response): return RedirectResponse(url="/dashboard") ``` -> [!NOTE] -> You do not need to pass `organization` to `complete_interactive_login`. The SDK stores it in the encrypted transaction at login time and reads it back at callback — the validation is automatic. - -## 2. Multi-org — per-login override - -When one application instance serves multiple organizations (for example, a B2B SaaS where different users belong to different orgs), pass `organization` at login time using `StartInteractiveLoginOptions`. This overrides any client-level default for that specific login. +**Multi-org:** when one application instance serves multiple organizations, pass `organization` at login time using `StartInteractiveLoginOptions`. This overrides any client-level default for that specific login. ```python from auth0_server_python.auth_types import StartInteractiveLoginOptions @@ -69,10 +65,13 @@ async def login(request: Request, response: Response, org_id: str): return RedirectResponse(url=authorization_url) ``` +> [!NOTE] +> You do not need to pass `organization` to `complete_interactive_login`. The SDK stores it in the encrypted transaction at login time and reads it back at callback — the validation is automatic. + > [!IMPORTANT] -> Validate that `org_id` comes from a trusted source (your own data, a verified session, or a registered tenant list) — never pass it unvalidated from a query parameter directly from an untrusted user. +> In the multi-org pattern, validate that `org_id` comes from a trusted source (your own data, a verified session, or a registered tenant list) — never pass it unvalidated from a query parameter directly from an untrusted user. -## 3. Log in using an organization name +## 2. Log in using an organization name `organization` accepts either an org ID (starts with `org_`) or an org name (any other value). The SDK uses the prefix to determine which token claim to validate at callback: @@ -94,7 +93,7 @@ authorization_url = await auth0.start_interactive_login( > [!NOTE] > Auth0 enforces that organization names cannot start with `org_`, so the prefix dispatch is unambiguous. When using org name, the SDK applies NFC Unicode normalization before comparison to prevent false rejections from visually identical characters with different byte representations. -## 4. Accept user invitations +## 3. Accept user invitations When a user follows an invitation link, extract the `invitation` and `organization` parameters from the URL and pass them at login time. Auth0 validates the invitation ticket server-side — your application does not need to verify it. @@ -123,7 +122,7 @@ async def login(request: Request, response: Response): > [!NOTE] > `organization` and `invitation` are forwarded to `/authorize`. Auth0 consumes the invitation ticket server-side — it is not stored in the encrypted transaction. If the ticket is expired or already used, `complete_interactive_login` raises `OrganizationInvitationError`. -## 5. Handling organization errors +## 4. Handling organization errors The SDK raises typed exceptions for org-specific failure modes. Catch them in your callback handler to return meaningful responses to your users. @@ -167,7 +166,7 @@ async def callback(request: Request, response: Response): | `OrganizationInvitationError` | Invitation ticket expired, already used, or invalid | | `OrganizationTokenValidationError` | `org_id` / `org_name` in the returned token does not match what was requested | -## 6. Reading organization data from the session +## 5. Reading organization data from the session After a successful org login, `org_id` and `org_name` are available on the user object. Use `get_user()` to retrieve them on subsequent requests: diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 6e14161..8343f95 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -220,7 +220,9 @@ class StartLinkUserOptions(BaseModel): authorization_params: Optional[dict[str, Any]] = None app_state: Optional[Any] = None +# ============================================================================= # Multiple Custom Domain +# ============================================================================= class DomainResolverContext(BaseModel): """ @@ -241,7 +243,9 @@ async def domain_resolver(context: DomainResolverContext) -> str: request_url: Optional[str] = None request_headers: Optional[dict[str, str]] = None +# ============================================================================= # Custom Token Exchange Types +# ============================================================================= class CustomTokenExchangeOptions(BaseModel): """ @@ -348,7 +352,9 @@ class LoginWithCustomTokenExchangeResult(BaseModel): state_data: dict[str, Any] authorization_details: Optional[list[AuthorizationDetails]] = None +# ============================================================================= # Connected Accounts Types +# ============================================================================= # BASE & SHARED class ConnectedAccountBase(BaseModel): @@ -421,7 +427,9 @@ class ListConnectedAccountConnectionsResponse(BaseModel): next: Optional[str] = None +# ============================================================================= # MFA Types +# ============================================================================= # Type aliases using Literal types AuthenticatorType = Literal["otp", "oob", "recovery-code"] From 8e8e2ac0aef9a75378ca8243d6eef0bf45add6d5 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 18 Jun 2026 15:57:20 +0530 Subject: [PATCH 11/17] SDK-8833 Review feedback implementations --- README.md | 25 +- examples/InteractiveLogin.md | 107 ++++++- examples/Organizations.md | 193 ------------- .../auth_server/server_client.py | 50 +--- .../auth_types/__init__.py | 1 + src/auth0_server_python/error/__init__.py | 40 --- .../tests/test_server_client.py | 261 ++---------------- 7 files changed, 126 insertions(+), 551 deletions(-) delete mode 100644 examples/Organizations.md diff --git a/README.md b/README.md index 4e4747a..ca65031 100644 --- a/README.md +++ b/README.md @@ -175,30 +175,7 @@ For more details and examples, see [examples/MultipleCustomDomains.md](examples/ ### 6. Organizations -To restrict login to a specific organization, set `organization` at client initialization (dedicated-org) or per login (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='', - client_id='', - client_secret='', - secret='', - organization='org_abc123', - authorization_params={"redirect_uri": ""} -) - -# Multi-org: pass organization per login -authorization_url = await auth0.start_interactive_login( - StartInteractiveLoginOptions(organization="org_xyz789"), - store_options={"request": request, "response": response} -) -``` - -The SDK validates the `org_id` (or `org_name`) claim in the returned token automatically — no extra code needed at callback. For invitation flows, per-org error handling, and reading org data from the session, see [examples/Organizations.md](examples/Organizations.md). +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). ## Feedback diff --git a/examples/InteractiveLogin.md b/examples/InteractiveLogin.md index ba8cb9e..4fc0f82 100644 --- a/examples/InteractiveLogin.md +++ b/examples/InteractiveLogin.md @@ -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 @@ -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 Auth0’s `/authorize` endpoint in two ways: +You can customize the parameters sent to Auth0's `/authorize` endpoint in two ways: ### Global Configuration @@ -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 Auth0’s /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) @@ -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 Auth0’s 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) @@ -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. \ No newline at end of file +>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` and `org_name` are available on the user object: + +```python +user = await auth0.get_user(store_options={"request": request, "response": response}) +if user: + print(user.get("org_id")) # e.g. "org_abc123" — use as stable identifier + print(user.get("org_name")) # e.g. "acme-corp" — display only, mutable +``` diff --git a/examples/Organizations.md b/examples/Organizations.md deleted file mode 100644 index a4aad9c..0000000 --- a/examples/Organizations.md +++ /dev/null @@ -1,193 +0,0 @@ -# Organizations - -[Organizations](https://auth0.com/docs/organizations) is a set of features that provide better support for developers building SaaS and B2B applications. This guide covers org login, invitation flows, error handling, and reading org data from the session. - -- [1. Configuring the organization](#1-configuring-the-organization) -- [2. Log in using an organization name](#2-log-in-using-an-organization-name) -- [3. Accept user invitations](#3-accept-user-invitations) -- [4. Handling organization errors](#4-handling-organization-errors) -- [5. Reading organization data from the session](#5-reading-organization-data-from-the-session) - -## 1. Configuring the organization - -The `organization` parameter can be set at client initialization (dedicated-org) or per login (multi-org). - -**Dedicated-org:** when a single instance of your application serves one organization, set `organization` at client initialization. Every login from that instance will enforce the org automatically. - -```python -from auth0_server_python.auth_server.server_client import ServerClient - -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", - } -) -``` - -```python -from fastapi import FastAPI, Request, Response -from starlette.responses import RedirectResponse - -app = FastAPI() - -@app.get("/auth/login") -async def login(request: Request, response: Response): - authorization_url = await auth0.start_interactive_login( - store_options={"request": request, "response": response} - ) - return RedirectResponse(url=authorization_url) - -@app.get("/auth/callback") -async def callback(request: Request, response: Response): - result = await auth0.complete_interactive_login( - str(request.url), - store_options={"request": request, "response": response} - ) - return RedirectResponse(url="/dashboard") -``` - -**Multi-org:** when one application instance serves multiple organizations, pass `organization` at login time using `StartInteractiveLoginOptions`. This overrides any client-level default for that specific login. - -```python -from auth0_server_python.auth_types import StartInteractiveLoginOptions - -@app.get("/auth/login") -async def login(request: Request, response: Response, org_id: str): - authorization_url = await auth0.start_interactive_login( - StartInteractiveLoginOptions(organization=org_id), - store_options={"request": request, "response": response} - ) - return RedirectResponse(url=authorization_url) -``` - -> [!NOTE] -> You do not need to pass `organization` to `complete_interactive_login`. The SDK stores it in the encrypted transaction at login time and reads it back at callback — the validation is automatic. - -> [!IMPORTANT] -> In the multi-org pattern, validate that `org_id` comes from a trusted source (your own data, a verified session, or a registered tenant list) — never pass it unvalidated from a query parameter directly from an untrusted user. - -## 2. Log in using an organization name - -`organization` accepts either an org ID (starts with `org_`) or an org name (any other value). The SDK uses the prefix to determine which token claim to validate at callback: - -- **`org_` prefix** → validates `org_id` claim (exact, case-sensitive match) -- **No `org_` prefix** → validates `org_name` claim (case-insensitive match) - -```python -# By org ID — validates the org_id claim in the token -authorization_url = await auth0.start_interactive_login( - StartInteractiveLoginOptions(organization="org_abc123") -) - -# By org name — validates the org_name claim in the token (case-insensitive) -authorization_url = await auth0.start_interactive_login( - StartInteractiveLoginOptions(organization="acme-corp") -) -``` - -> [!NOTE] -> Auth0 enforces that organization names cannot start with `org_`, so the prefix dispatch is unambiguous. When using org name, the SDK applies NFC Unicode normalization before comparison to prevent false rejections from visually identical characters with different byte representations. - -## 3. Accept user invitations - -When a user follows an invitation link, extract the `invitation` and `organization` parameters from the URL and pass them at login time. Auth0 validates the invitation ticket server-side — your application does not need to verify it. - -The invitation URL Auth0 generates has this shape: -``` -https://your-tenant.auth0.com/login?invitation={INVITATION_TOKEN}&organization={ORG_ID}&organization_name={ORG_NAME} -``` - -```python -@app.get("/auth/login") -async def login(request: Request, response: Response): - invitation = request.query_params.get("invitation") - organization = request.query_params.get("organization") - - options = StartInteractiveLoginOptions(organization=organization) - if invitation: - options.authorization_params = {"invitation": invitation} - - authorization_url = await auth0.start_interactive_login( - options, - store_options={"request": request, "response": response} - ) - return RedirectResponse(url=authorization_url) -``` - -> [!NOTE] -> `organization` and `invitation` are forwarded to `/authorize`. Auth0 consumes the invitation ticket server-side — it is not stored in the encrypted transaction. If the ticket is expired or already used, `complete_interactive_login` raises `OrganizationInvitationError`. - -## 4. Handling organization errors - -The SDK raises typed exceptions for org-specific failure modes. Catch them in your callback handler to return meaningful responses to your users. - -```python -from auth0_server_python.error import ( - OrganizationInvitationError, - OrganizationAccessDeniedError, - OrganizationRequiredError, - 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 OrganizationAccessDeniedError: - # User is not a member of the org, the connection is not enabled - # for the org, or the org member quota has been exceeded. - return RedirectResponse(url="/error?reason=not_org_member") - except OrganizationRequiredError: - # Configuration problem — invalid org format, Organizations feature - # disabled, or the client is not configured for organizations. - return RedirectResponse(url="/error?reason=org_config") - except OrganizationInvitationError: - # The invitation ticket is expired, already used, or invalid. - return RedirectResponse(url="/error?reason=invitation_invalid") - except OrganizationTokenValidationError: - # The org_id or org_name in the returned token does not match - # the organization that was requested at login. - return RedirectResponse(url="/error?reason=org_mismatch") -``` - -| Exception | When raised | -|-----------|-------------| -| `OrganizationAccessDeniedError` | User not a member, connection not enabled for org, member quota exceeded | -| `OrganizationRequiredError` | Invalid org format, feature disabled, client not configured for orgs | -| `OrganizationInvitationError` | Invitation ticket expired, already used, or invalid | -| `OrganizationTokenValidationError` | `org_id` / `org_name` in the returned token does not match what was requested | - -## 5. Reading organization data from the session - -After a successful org login, `org_id` and `org_name` are available on the user object. Use `get_user()` to retrieve them on subsequent requests: - -```python -user = await auth0.get_user(store_options={"request": request, "response": response}) -if user: - print(user.get("org_id")) # e.g. "org_abc123" - print(user.get("org_name")) # e.g. "acme-corp" -``` - -You can also read them immediately from the `complete_interactive_login` result: - -```python -result = await auth0.complete_interactive_login( - str(request.url), - store_options={"request": request, "response": response} -) -user = result["state_data"].get("user", {}) -print(user.get("org_id")) -print(user.get("org_name")) -``` - -> [!NOTE] -> `org_name` is mutable — Auth0 allows renaming an organization after creation. Use `org_id` as the stable identifier for any persistent storage (e.g., mapping users to tenants in your database). Surface `org_name` only for display purposes. diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 898b12a..623d812 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -51,13 +51,10 @@ CustomTokenExchangeErrorCode, DomainResolverError, InvalidArgumentError, - OrganizationInvitationError, IssuerValidationError, MfaRequiredError, MissingRequiredArgumentError, MissingTransactionError, - OrganizationAccessDeniedError, - OrganizationRequiredError, OrganizationTokenValidationError, PollingApiError, StartLinkUserError, @@ -77,48 +74,6 @@ "code_challenge", "code_challenge_method", "state", "nonce", "scope", "organization"] -_ORG_ACCESS_DENIED_DESCRIPTIONS = ( - "is not part of the", - "quota exceeded", - "organization member limit", - "user_id that longer than 1024", -) - -_ORG_INVALID_REQUEST_DESCRIPTIONS = ( - "organization must be an organization id", - "organizations feature is not enabled", - "organizations feature is not supported", - "parameter organization is required", - "parameter organization is not allowed", - "parameter organization is invalid", - "organization name must have between", - "organization must only contain lowercase", - "client is missing", -) - -_INVITATION_DESCRIPTIONS = ( - "invalid_user_invitation_ticket", - "invitation not found or already used", - "invalid invitation ticket", -) - - -def _classify_org_error(error: str, error_description: str) -> "ApiError": - desc_lower = error_description.lower() - - if error == "invalid_user_invitation_ticket" or any(desc in desc_lower for desc in _INVITATION_DESCRIPTIONS): - return OrganizationInvitationError(error_description) - - if error == "access_denied": - if any(desc in desc_lower for desc in _ORG_ACCESS_DENIED_DESCRIPTIONS): - return OrganizationAccessDeniedError(error_description) - - if error == "invalid_request": - if any(desc in desc_lower for desc in _ORG_INVALID_REQUEST_DESCRIPTIONS): - return OrganizationRequiredError(error_description) - - return ApiError(error, error_description) - class ServerClient(Generic[TStoreOptions]): """ @@ -597,6 +552,9 @@ async def start_interactive_login( 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, @@ -699,8 +657,6 @@ async def complete_interactive_login( if "error" in query_params: error = query_params.get("error", [""])[0] error_description = query_params.get("error_description", [""])[0] - if transaction_data.organization: - raise _classify_org_error(error, error_description) raise ApiError(error, error_description) # Get the authorization code from the URL diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 8343f95..5823364 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -151,6 +151,7 @@ class StartInteractiveLoginOptions(BaseModel): app_state: Optional[Any] = None authorization_params: Optional[dict[str, Any]] = None organization: Optional[str] = None + invitation: Optional[str] = None class LogoutOptions(BaseModel): diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 53ab65a..5c20fbf 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -197,46 +197,6 @@ def __init__(self, message: str): self.name = "OrganizationTokenValidationError" -class OrganizationRequiredError(Auth0Error): - """ - Raised when Auth0 rejects the authorization request due to an organization - configuration problem — invalid format, feature disabled, client not - configured for organizations, or the organization does not exist. - """ - code = "organization_required_error" - - def __init__(self, message: str, cause: Optional[Exception] = None): - super().__init__(message) - self.name = "OrganizationRequiredError" - self.cause = cause - - -class OrganizationAccessDeniedError(Auth0Error): - """ - Raised when Auth0 denies access to the requested organization — the user is - not a member, the connection is not enabled for the org, or the org member - quota has been exceeded. - """ - code = "organization_access_denied_error" - - def __init__(self, message: str, cause: Optional[Exception] = None): - super().__init__(message) - self.name = "OrganizationAccessDeniedError" - self.cause = cause - - -class OrganizationInvitationError(Auth0Error): - """ - Raised when an organization invitation is rejected by Auth0 — the ticket is - expired, already used, or otherwise invalid. - """ - code = "organization_invitation_error" - - def __init__(self, message: str, cause: Optional[Exception] = None): - super().__init__(message) - self.name = "OrganizationInvitationError" - self.cause = cause - # Error code enumerations - these can be used to identify specific error scenarios diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index a68bd97..1e12266 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9,7 +9,7 @@ from auth0_server_python.auth_server.mfa_client import MfaClient from auth0_server_python.auth_server.my_account_client import MyAccountClient -from auth0_server_python.auth_server.server_client import ServerClient, _classify_org_error +from auth0_server_python.auth_server.server_client import ServerClient from auth0_server_python.auth_types import ( CompleteConnectAccountRequest, ConnectAccountOptions, @@ -41,13 +41,10 @@ CustomTokenExchangeErrorCode, DomainResolverError, InvalidArgumentError, - OrganizationInvitationError, IssuerValidationError, MfaRequiredError, MissingRequiredArgumentError, MissingTransactionError, - OrganizationAccessDeniedError, - OrganizationRequiredError, OrganizationTokenValidationError, PollingApiError, StartLinkUserError, @@ -4989,7 +4986,7 @@ async def test_invitation_and_org_forwarded_to_authorize(mocker): url = await client.start_interactive_login( StartInteractiveLoginOptions( organization="org_abc123", - authorization_params={"invitation": "inv_token_xyz"}, + invitation="inv_token_xyz", ) ) @@ -5022,7 +5019,7 @@ async def test_invitation_without_org_forwarded_to_authorize(mocker): url = await client.start_interactive_login( StartInteractiveLoginOptions( - authorization_params={"invitation": "inv_token_xyz"} + invitation="inv_token_xyz", ) ) @@ -5461,161 +5458,7 @@ async def test_userinfo_non_dict_no_org_raises_api_error(mocker): # --------------------------------------------------------------------------- -# _classify_org_error unit tests -# --------------------------------------------------------------------------- - -def test_classify_org_error_invitation_ticket_in_description(): - err = _classify_org_error("invalid_request", "invalid_user_invitation_ticket: ticket has expired") - assert isinstance(err, OrganizationInvitationError) - assert err.code == "organization_invitation_error" - - -def test_classify_org_error_invitation_ticket_as_error_code(): - err = _classify_org_error("invalid_user_invitation_ticket", "The invitation ticket is invalid.") - assert isinstance(err, OrganizationInvitationError) - - -def test_classify_org_error_invitation_not_found_or_already_used(): - err = _classify_org_error("invalid_request", "invitation not found or already used") - assert isinstance(err, OrganizationInvitationError) - assert err.code == "organization_invitation_error" - - -def test_classify_org_error_invalid_invitation_ticket_client_mismatch(): - err = _classify_org_error("invalid_request", "invalid invitation ticket (client_id mismatch)") - assert isinstance(err, OrganizationInvitationError) - - -def test_classify_org_error_invalid_invitation_ticket_org_mismatch(): - err = _classify_org_error("invalid_request", "invalid invitation ticket (organization mismatch)") - assert isinstance(err, OrganizationInvitationError) - - -def test_classify_org_error_invalid_invitation_ticket_unknown_roles(): - err = _classify_org_error("invalid_request", "invalid invitation ticket (unknown roles: role_abc123)") - assert isinstance(err, OrganizationInvitationError) - - -def test_classify_org_error_user_not_member(): - desc = "user abc123 is not part of the org_xyz organization" - err = _classify_org_error("access_denied", desc) - assert isinstance(err, OrganizationAccessDeniedError) - assert err.code == "organization_access_denied_error" - - -def test_classify_org_error_connection_not_part_of_org(): - desc = "user abc123 belongs to connection google that is not part of the org_xyz organization" - err = _classify_org_error("access_denied", desc) - assert isinstance(err, OrganizationAccessDeniedError) - - -def test_classify_org_error_member_quota_exceeded(): - err = _classify_org_error("access_denied", "Quota exceeded: organization member limit has been reached") - assert isinstance(err, OrganizationAccessDeniedError) - - -def test_classify_org_error_user_id_too_long(): - err = _classify_org_error( - "access_denied", - "Organizations feature is not supported for users with user_id that longer than 1024 characters", - ) - assert isinstance(err, OrganizationAccessDeniedError) - - -def test_classify_org_error_org_must_be_id(): - err = _classify_org_error( - "invalid_request", - "authorization request parameter organization must be an organization id", - ) - assert isinstance(err, OrganizationRequiredError) - assert err.code == "organization_required_error" - - -def test_classify_org_error_org_must_be_id_or_name(): - err = _classify_org_error( - "invalid_request", - "authorization request parameter organization must be an organization id or name", - ) - assert isinstance(err, OrganizationRequiredError) - - -def test_classify_org_error_organizations_disabled(): - err = _classify_org_error("invalid_request", "organizations feature is not enabled") - assert isinstance(err, OrganizationRequiredError) - - -def test_classify_org_error_organizations_not_supported_for_client(): - err = _classify_org_error( - "invalid_request", - "organizations feature is not supported for non-first party or non-strict third party clients", - ) - assert isinstance(err, OrganizationRequiredError) - - -def test_classify_org_error_org_required_for_client(): - err = _classify_org_error("invalid_request", "parameter organization is required for this client") - assert isinstance(err, OrganizationRequiredError) - - -def test_classify_org_error_org_not_allowed_for_client(): - err = _classify_org_error("invalid_request", "parameter organization is not allowed for this client") - assert isinstance(err, OrganizationRequiredError) - - -def test_classify_org_error_org_invalid_not_found(): - err = _classify_org_error("invalid_request", "parameter organization is invalid: org_doesnotexist") - assert isinstance(err, OrganizationRequiredError) - assert err.code == "organization_required_error" - - -def test_classify_org_error_org_name_length_invalid(): - err = _classify_org_error( - "invalid_request", - "organization name must have between 1 and 50 characters", - ) - assert isinstance(err, OrganizationRequiredError) - - -def test_classify_org_error_org_name_format_invalid(): - err = _classify_org_error( - "invalid_request", - "organization must only contain lowercase characters, '-' and '_', and start and end with a letter or number", - ) - assert isinstance(err, OrganizationRequiredError) - - -def test_classify_org_error_client_missing(): - err = _classify_org_error("invalid_request", "client is missing the organizations feature") - assert isinstance(err, OrganizationRequiredError) - assert err.code == "organization_required_error" - - -def test_classify_org_error_unrelated_access_denied_passthrough(): - err = _classify_org_error("access_denied", "User cancelled the login.") - assert isinstance(err, ApiError) - assert err.code == "access_denied" - - -def test_classify_org_error_unrelated_invalid_request_passthrough(): - err = _classify_org_error("invalid_request", "redirect_uri does not match") - assert isinstance(err, ApiError) - assert err.code == "invalid_request" - - -def test_classify_org_error_server_error_passthrough(): - err = _classify_org_error("server_error", "Internal server error") - assert isinstance(err, ApiError) - assert err.code == "server_error" - - -def test_classify_org_error_preserves_description(): - desc = "user xyz is not part of the org_abc organization" - err = _classify_org_error("access_denied", desc) - assert err.message == desc - - -# --------------------------------------------------------------------------- -# complete_interactive_login — org error classification via callback URL +# complete_interactive_login — org errors raised as ApiError # --------------------------------------------------------------------------- def _make_org_callback_client(mock_tx_store, mock_state_store, org=None): @@ -5631,8 +5474,8 @@ def _make_org_callback_client(mock_tx_store, mock_state_store, org=None): @pytest.mark.asyncio -async def test_callback_org_access_denied_raises_typed_error(): - """access_denied from org membership check → OrganizationAccessDeniedError.""" +async def test_callback_org_access_denied_raises_api_error(): + """access_denied from org membership check → ApiError with original code and description.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" @@ -5640,14 +5483,15 @@ async def test_callback_org_access_denied_raises_typed_error(): client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") desc = "user u1 is not part of the org_abc organization" url = f"http://localhost/cb?state=xyz&error=access_denied&error_description={desc}" - with pytest.raises(OrganizationAccessDeniedError) as exc: + with pytest.raises(ApiError) as exc: await client.complete_interactive_login(url) + assert exc.value.code == "access_denied" assert desc in exc.value.message @pytest.mark.asyncio -async def test_callback_org_invalid_format_raises_typed_error(): - """invalid_request with bad org format → OrganizationRequiredError.""" +async def test_callback_org_invalid_format_raises_api_error(): + """invalid_request with bad org format → ApiError with original code and description.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( code_verifier="cv", domain="tenant.auth0.com", organization="my-org" @@ -5655,14 +5499,15 @@ async def test_callback_org_invalid_format_raises_typed_error(): client = _make_org_callback_client(mock_tx_store, AsyncMock()) desc = "authorization request parameter organization must be an organization id" url = f"http://localhost/cb?state=xyz&error=invalid_request&error_description={desc}" - with pytest.raises(OrganizationRequiredError) as exc: + with pytest.raises(ApiError) as exc: await client.complete_interactive_login(url) - assert exc.value.code == "organization_required_error" + assert exc.value.code == "invalid_request" + assert desc in exc.value.message @pytest.mark.asyncio -async def test_callback_invitation_error_raises_typed_error(): - """Expired/invalid invitation ticket → OrganizationInvitationError.""" +async def test_callback_invitation_error_raises_api_error(): + """Expired/invalid invitation ticket → ApiError with original code and description.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" @@ -5670,14 +5515,15 @@ async def test_callback_invitation_error_raises_typed_error(): client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") desc = "invalid_user_invitation_ticket: ticket has already been used" url = f"http://localhost/cb?state=xyz&error=invalid_request&error_description={desc}" - with pytest.raises(OrganizationInvitationError) as exc: + with pytest.raises(ApiError) as exc: await client.complete_interactive_login(url) - assert exc.value.code == "organization_invitation_error" + assert exc.value.code == "invalid_request" + assert desc in exc.value.message @pytest.mark.asyncio -async def test_callback_unrelated_access_denied_raises_api_error(): - """access_denied not matching any org error_description → plain ApiError.""" +async def test_callback_error_raises_api_error(): + """Any auth error → ApiError preserving the raw error code and description.""" mock_tx_store = AsyncMock() mock_tx_store.get.return_value = TransactionData( code_verifier="cv", domain="tenant.auth0.com", @@ -5690,73 +5536,6 @@ async def test_callback_unrelated_access_denied_raises_api_error(): assert exc.value.code == "access_denied" -@pytest.mark.asyncio -async def test_callback_connection_not_part_of_org_raises_typed_error(): - """access_denied for connection not part of org → OrganizationAccessDeniedError.""" - mock_tx_store = AsyncMock() - mock_tx_store.get.return_value = TransactionData( - code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" - ) - client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") - desc = "user u1 belongs to connection google that is not part of the org_abc organization" - url = f"http://localhost/cb?state=xyz&error=access_denied&error_description={desc}" - with pytest.raises(OrganizationAccessDeniedError): - await client.complete_interactive_login(url) - - -@pytest.mark.asyncio -async def test_callback_invitation_not_found_raises_typed_error(): - """invitation not found or already used → OrganizationInvitationError.""" - mock_tx_store = AsyncMock() - mock_tx_store.get.return_value = TransactionData( - code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" - ) - client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") - url = "http://localhost/cb?state=xyz&error=invalid_request&error_description=invitation+not+found+or+already+used" - with pytest.raises(OrganizationInvitationError) as exc: - await client.complete_interactive_login(url) - assert exc.value.code == "organization_invitation_error" - - -@pytest.mark.asyncio -async def test_callback_org_invalid_not_found_raises_typed_error(): - """parameter organization is invalid (org not found / third-party access denied) → OrganizationRequiredError.""" - mock_tx_store = AsyncMock() - mock_tx_store.get.return_value = TransactionData( - code_verifier="cv", domain="tenant.auth0.com", organization="org_abc" - ) - client = _make_org_callback_client(mock_tx_store, AsyncMock(), org="org_abc") - url = "http://localhost/cb?state=xyz&error=invalid_request&error_description=parameter+organization+is+invalid%3A+org_doesnotexist" - with pytest.raises(OrganizationRequiredError): - await client.complete_interactive_login(url) - - -def test_org_error_classes_are_auth0_errors(): - """All three new error classes inherit from Auth0Error.""" - from auth0_server_python.error import Auth0Error # noqa: PLC0415 - assert issubclass(OrganizationRequiredError, Auth0Error) - assert issubclass(OrganizationAccessDeniedError, Auth0Error) - assert issubclass(OrganizationInvitationError, Auth0Error) - - -def test_org_error_codes(): - assert OrganizationRequiredError("x").code == "organization_required_error" - assert OrganizationAccessDeniedError("x").code == "organization_access_denied_error" - assert OrganizationInvitationError("x").code == "organization_invitation_error" - - -def test_org_error_names(): - assert OrganizationRequiredError("x").name == "OrganizationRequiredError" - assert OrganizationAccessDeniedError("x").name == "OrganizationAccessDeniedError" - assert OrganizationInvitationError("x").name == "OrganizationInvitationError" - - -def test_org_error_stores_cause(): - cause = ValueError("upstream") - err = OrganizationAccessDeniedError("denied", cause=cause) - assert err.cause is cause - - # --------------------------------------------------------------------------- # Org resolution — per-login vs client-level precedence # --------------------------------------------------------------------------- From fd66529b95a7950015b3d68cdcc2502c39751369 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 18 Jun 2026 17:21:31 +0530 Subject: [PATCH 12/17] moved organisation under interactive login in readme --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ca65031..d0cefb7 100644 --- a/README.md +++ b/README.md @@ -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): @@ -173,10 +177,6 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md). -### 6. 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). - ## Feedback ### Contributing From 17a6352938e675bd3a17233805b7c26477a5a379 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 19 Jun 2026 19:04:41 +0530 Subject: [PATCH 13/17] Resoved PR comments SDK-8833 --- examples/InteractiveLogin.md | 6 +- .../auth_server/server_client.py | 9 +- .../auth_types/__init__.py | 2 + src/auth0_server_python/error/__init__.py | 25 ++-- .../tests/test_server_client.py | 119 ++++++++++++++++-- 5 files changed, 134 insertions(+), 27 deletions(-) diff --git a/examples/InteractiveLogin.md b/examples/InteractiveLogin.md index 4fc0f82..9635647 100644 --- a/examples/InteractiveLogin.md +++ b/examples/InteractiveLogin.md @@ -225,11 +225,11 @@ Common `ApiError.code` values for org flows: ### Reading organization data from the session -After a successful org login, `org_id` and `org_name` are available on the user object: +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}) if user: - print(user.get("org_id")) # e.g. "org_abc123" — use as stable identifier - print(user.get("org_name")) # e.g. "acme-corp" — display only, mutable + print(user.get("org_id")) # always present; use as stable identifier + print(user.get("org_name")) # present when org name is enabled ``` diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 623d812..93f69fa 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -71,8 +71,7 @@ # redirect_uri is intentionally excluded — in MCD mode it is built # dynamically from the resolved domain at login time. INTERNAL_AUTHORIZE_PARAMS = ["client_id", "response_type", - "code_challenge", "code_challenge_method", "state", "nonce", "scope", - "organization"] + "code_challenge", "code_challenge_method", "state", "nonce", "scope"] class ServerClient(Generic[TStoreOptions]): @@ -545,7 +544,7 @@ async def start_interactive_login( merged_scope = self._merge_scope_with_defaults(requested_scope, audience) auth_params["scope"] = merged_scope - # Resolve organization: per-login value takes precedence over client-level default. + # 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") @@ -2353,6 +2352,9 @@ async def custom_token_exchange( params["actor_token"] = options.actor_token params["actor_token_type"] = options.actor_token_type + if options.organization: + params["organization"] = options.organization + # Merge additional authorization params if options.authorization_params: # Prevent override of critical parameters @@ -2449,6 +2451,7 @@ async def login_with_custom_token_exchange( scope=options.scope, actor_token=options.actor_token, actor_token_type=options.actor_token_type, + organization=options.organization, authorization_params=options.authorization_params ) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 5823364..0cb77cd 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -268,6 +268,7 @@ class CustomTokenExchangeOptions(BaseModel): scope: Optional[str] = None actor_token: Optional[str] = None actor_token_type: Optional[str] = None + organization: Optional[str] = None authorization_params: Optional[dict[str, Any]] = None @field_validator('subject_token', 'actor_token') @@ -323,6 +324,7 @@ class LoginWithCustomTokenExchangeOptions(BaseModel): scope: Optional[str] = None actor_token: Optional[str] = None actor_token_type: Optional[str] = None + organization: Optional[str] = None authorization_params: Optional[dict[str, Any]] = None @field_validator('subject_token', 'actor_token') diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 5c20fbf..f9e4009 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -185,19 +185,6 @@ def __init__(self, message: str): self.name = "StartLinkUserError" -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" - - - # Error code enumerations - these can be used to identify specific error scenarios class AccessTokenErrorCode: @@ -213,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" diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 1e12266..976c58e 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -2983,6 +2983,56 @@ async def test_custom_token_exchange_with_actor_token(mocker): assert call_args[1]["data"]["actor_token_type"] == "urn:ietf:params:oauth:token-type:access_token" +@pytest.mark.asyncio +async def test_custom_token_exchange_with_organization(mocker): + """Test token exchange with organization parameter.""" + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret" + ) + + mocker.patch.object( + client, + "_fetch_oidc_metadata", + return_value={"token_endpoint": "https://auth0.local/oauth/token"} + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "org_scoped_token", + "token_type": "Bearer", + "expires_in": 3600 + } + mock_response.headers.get.return_value = "application/json" + + mock_httpx_client = AsyncMock() + mock_httpx_client.__aenter__.return_value = mock_httpx_client + mock_httpx_client.__aexit__.return_value = None + mock_httpx_client.post.return_value = mock_response + + mocker.patch("httpx.AsyncClient", return_value=mock_httpx_client) + + options = CustomTokenExchangeOptions( + subject_token="custom-token", + subject_token_type="urn:acme:mcp-token", + organization="org_abc1234" + ) + result = await client.custom_token_exchange(options) + + assert result.access_token == "org_scoped_token" + + call_args = mock_httpx_client.post.call_args + assert call_args[1]["data"]["organization"] == "org_abc1234" + + @pytest.mark.asyncio async def test_custom_token_exchange_empty_token(): """Test that empty/whitespace tokens are rejected.""" @@ -5153,8 +5203,8 @@ async def test_adv_empty_string_org_id_raises(mocker): @pytest.mark.asyncio -async def test_adv_org_in_untyped_dict_is_ignored(mocker): - """organization passed via authorization_params dict is stripped; typed field is the only path.""" +async def test_adv_org_in_authorization_params_is_forwarded(mocker): + """organization passed via authorization_params is forwarded to /authorize.""" mock_tx_store = AsyncMock() mock_state_store = AsyncMock() client = ServerClient( @@ -5171,20 +5221,73 @@ async def test_adv_org_in_untyped_dict_is_ignored(mocker): "authorization_endpoint": "https://tenant.auth0.com/authorize", }) - # org passed only via authorization_params — must be ignored entirely url = await client.start_interactive_login( StartInteractiveLoginOptions( authorization_params={"organization": "org_via_dict"}, ) ) - # TransactionData must not store any org (dict path is blocked) - stored = mock_tx_store.set.call_args[0][1] - assert stored.organization is None + parsed = parse_qs(urlparse(url).query) + assert parsed["organization"] == ["org_via_dict"] + + +@pytest.mark.asyncio +async def test_adv_invitation_in_authorization_params_is_forwarded(mocker): + """invitation passed via authorization_params is forwarded to /authorize.""" + mock_tx_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "authorization_endpoint": "https://tenant.auth0.com/authorize", + }) + + url = await client.start_interactive_login( + StartInteractiveLoginOptions( + authorization_params={"invitation": "inv_via_dict"}, + ) + ) + + parsed = parse_qs(urlparse(url).query) + assert parsed["invitation"] == ["inv_via_dict"] + + +@pytest.mark.asyncio +async def test_adv_typed_invitation_wins_over_dict(mocker): + """Typed invitation field wins when both typed and dict values are present.""" + mock_tx_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + redirect_uri="https://app.example.com/callback", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value={ + "issuer": "https://tenant.auth0.com/", + "authorization_endpoint": "https://tenant.auth0.com/authorize", + }) + + url = await client.start_interactive_login( + StartInteractiveLoginOptions( + invitation="inv_typed", + authorization_params={"invitation": "inv_via_dict"}, + ) + ) - # URL must not contain the dict value parsed = parse_qs(urlparse(url).query) - assert "organization" not in parsed + assert parsed["invitation"] == ["inv_typed"] @pytest.mark.asyncio From 74d7e4db5d35a05e04ba3480fab841df879a2f52 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 19 Jun 2026 19:33:55 +0530 Subject: [PATCH 14/17] SDK-8833 Reverting comments and separator lines deleted earlier --- src/auth0_server_python/error/__init__.py | 2 ++ .../tests/test_server_client.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index f9e4009..600d7e4 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -241,7 +241,9 @@ class CustomTokenExchangeErrorCode: INVALID_RESPONSE = "invalid_response" +# ============================================================================= # MFA Error Classes +# ============================================================================= class MfaApiError(Auth0Error): """Base class for MFA API errors.""" diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 976c58e..1c76d49 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -2323,7 +2323,9 @@ async def test_get_token_by_refresh_token_exchange_failed(mocker): args, kwargs = mock_post.call_args assert kwargs["data"]["refresh_token"] == "" +# ============================================================================= # Connected Accounts Tests (My Account Client) +# ============================================================================= @pytest.mark.asyncio @@ -2857,7 +2859,9 @@ async def test_list_connected_account_connections_with_invalid_take_param(mocker assert "The 'take' parameter must be a positive integer." in str(exc.value) mock_my_account_client.list_connected_account_connections.assert_not_awaited() +# ============================================================================= # Custom Token Exchange Tests +# ============================================================================= @pytest.mark.asyncio async def test_custom_token_exchange_success(mocker): @@ -2986,6 +2990,7 @@ async def test_custom_token_exchange_with_actor_token(mocker): @pytest.mark.asyncio async def test_custom_token_exchange_with_organization(mocker): """Test token exchange with organization parameter.""" + # Setup mock_transaction_store = AsyncMock() mock_state_store = AsyncMock() @@ -3020,6 +3025,7 @@ async def test_custom_token_exchange_with_organization(mocker): mocker.patch("httpx.AsyncClient", return_value=mock_httpx_client) + # Act options = CustomTokenExchangeOptions( subject_token="custom-token", subject_token_type="urn:acme:mcp-token", @@ -3027,8 +3033,10 @@ async def test_custom_token_exchange_with_organization(mocker): ) result = await client.custom_token_exchange(options) + # Assert assert result.access_token == "org_scoped_token" + # Verify organization param was sent call_args = mock_httpx_client.post.call_args assert call_args[1]["data"]["organization"] == "org_abc1234" @@ -3346,7 +3354,9 @@ async def test_custom_token_exchange_forbidden_params_filtered(mocker): assert call_args[1]["data"]["allowed_param"] == "value" +# ============================================================================= # Login with Custom Token Exchange Tests +# ============================================================================= @pytest.mark.asyncio async def test_login_with_custom_token_exchange_success(mocker): @@ -3534,7 +3544,9 @@ async def test_login_with_custom_token_exchange_failure_propagates(mocker): assert exc.value.code == "unauthorized" +# ============================================================================= # OIDC Metadata and JWKS Fetching Tests +# ============================================================================= @pytest.mark.asyncio @@ -3835,7 +3847,9 @@ async def mock_fetch(domain): assert "domain3.auth0.com" in client._discovery_cache +# ============================================================================= # Issuer Validation Tests +# ============================================================================= @pytest.mark.asyncio @@ -4039,7 +4053,9 @@ async def test_normalize_url_handles_edge_cases(): assert client._normalize_url(None) is None +# ============================================================================= # MCD Tests : Multiple Issuer Configuration Methods Tests +# ============================================================================= @pytest.mark.asyncio async def test_domain_as_static_string(): @@ -4108,7 +4124,9 @@ async def test_empty_domain_string(): ) +# ============================================================================= # MCD Tests : Domain Resolver Context Tests +# ============================================================================= @pytest.mark.asyncio async def test_domain_resolver_receives_context(mocker): @@ -4321,7 +4339,9 @@ async def resolver_with_scheme(context): assert user["sub"] == "user123" +# ============================================================================= # MCD Tests : Domain-specific Session Management Tests +# ============================================================================= @pytest.mark.asyncio From ccdc0ba3f89d56129db36a94770e0b8c57bebd3c Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 23 Jun 2026 13:46:00 +0530 Subject: [PATCH 15/17] SDK-8833. Moved org claim validation to helpers --- .../auth_server/server_client.py | 36 +---------------- src/auth0_server_python/utils/helpers.py | 39 ++++++++++++++++++- 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 93f69fa..ce2b1cc 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -6,7 +6,6 @@ import asyncio import json import time -import unicodedata from collections import OrderedDict from typing import Any, Callable, Generic, Optional, TypeVar, Union from urllib.parse import parse_qs, urlencode, urlparse, urlunparse @@ -63,6 +62,7 @@ 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, ) @@ -215,39 +215,7 @@ def _normalize_url(self, value: str) -> str: return value.rstrip('/') def _validate_org_claims(self, claims: dict, expected_org: str) -> None: - """ - Validate org_id or org_name in token claims against the requested organization. - - Uses expected_org prefix to determine which claim to check: - - 'org_' prefix → validate claims['org_id'] exact match - - no prefix → validate claims['org_name'] case-insensitive match - - Raises: - OrganizationTokenValidationError: if the claim is missing or mismatched. - """ - if expected_org.startswith("org_"): - actual = claims.get("org_id") - if not isinstance(actual, str): - raise OrganizationTokenValidationError( - "Organization Id (org_id) claim must be a string present in the ID token" - ) - if actual != expected_org: - raise OrganizationTokenValidationError( - "Organization Id (org_id) claim value mismatch in the ID token" - ) - else: - actual = claims.get("org_name") - if not isinstance(actual, str): - raise OrganizationTokenValidationError( - "Organization Name (org_name) claim must be a string present in the ID token" - ) - # NFC-normalize before comparison: the same visual character (e.g. é) can have - # multiple byte representations in Unicode. Normalizing both sides prevents - # false rejections without risk of false matches. - if unicodedata.normalize("NFC", actual).lower() != unicodedata.normalize("NFC", expected_org).lower(): - raise OrganizationTokenValidationError( - "Organization Name (org_name) claim value mismatch in the ID token" - ) + validate_org_claims(claims, expected_org) async def _resolve_current_domain(self, store_options=None) -> str: """Resolve domain from resolver function or return static domain.""" diff --git a/src/auth0_server_python/utils/helpers.py b/src/auth0_server_python/utils/helpers.py index 05cb0f8..a1582ba 100644 --- a/src/auth0_server_python/utils/helpers.py +++ b/src/auth0_server_python/utils/helpers.py @@ -3,11 +3,12 @@ import secrets import string import time +import unicodedata from typing import Any, Optional from urllib.parse import parse_qs, urlencode, urlparse from auth0_server_python.auth_types import DomainResolverContext -from auth0_server_python.error import DomainResolverError +from auth0_server_python.error import DomainResolverError, OrganizationTokenValidationError class PKCE: @@ -293,3 +294,39 @@ def validate_resolved_domain_value(domain_value: Any) -> str: ) return domain_value + + +def validate_org_claims(claims: dict, expected_org: str) -> None: + """ + Validate org_id or org_name in token claims against the requested organization. + + Uses expected_org prefix to determine which claim to check: + - 'org_' prefix → validate claims['org_id'] exact match (case-sensitive) + - no prefix → validate claims['org_name'] case-insensitive match (NFC-normalized) + + Raises: + OrganizationTokenValidationError: if the claim is missing, not a string, or mismatched. + """ + if expected_org.startswith("org_"): + actual = claims.get("org_id") + if not isinstance(actual, str): + raise OrganizationTokenValidationError( + "Organization Id (org_id) claim must be a string present in the ID token" + ) + if actual != expected_org: + raise OrganizationTokenValidationError( + "Organization Id (org_id) claim value mismatch in the ID token" + ) + else: + actual = claims.get("org_name") + if not isinstance(actual, str): + raise OrganizationTokenValidationError( + "Organization Name (org_name) claim must be a string present in the ID token" + ) + # NFC-normalize before comparison: the same visual character (e.g. é) can have + # multiple byte representations in Unicode. Normalizing both sides prevents + # false rejections without risk of false matches. + if unicodedata.normalize("NFC", actual).lower() != unicodedata.normalize("NFC", expected_org).lower(): + raise OrganizationTokenValidationError( + "Organization Name (org_name) claim value mismatch in the ID token" + ) From 885e13c9d98b73a1c92eaea95bd3364223b6878f Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 23 Jun 2026 14:02:51 +0530 Subject: [PATCH 16/17] Removed wrapper function for org claim validation SDK-8833 --- src/auth0_server_python/auth_server/server_client.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index ce2b1cc..68fbccf 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -214,8 +214,6 @@ def _normalize_url(self, value: str) -> str: return value.rstrip('/') - def _validate_org_claims(self, claims: dict, expected_org: str) -> None: - validate_org_claims(claims, expected_org) async def _resolve_current_domain(self, store_options=None) -> str: """Resolve domain from resolver function or return static domain.""" @@ -678,7 +676,7 @@ async def complete_interactive_login( "Userinfo response is not a valid claims dictionary" ) if expected_org: - self._validate_org_claims(user_info, expected_org) + validate_org_claims(user_info, expected_org) user_claims = UserClaims.parse_obj(user_info) elif id_token: # Fetch JWKS for signature verification @@ -697,7 +695,7 @@ async def complete_interactive_login( # Organization claim validation — mandatory when org was requested. if expected_org: - self._validate_org_claims(claims, expected_org) + validate_org_claims(claims, expected_org) user_claims = UserClaims.parse_obj(claims) except ValueError as e: From 4562e3a7391ce183c4e2db8be1fc28f05fbd02fb Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 23 Jun 2026 14:12:09 +0530 Subject: [PATCH 17/17] Comment for utilities grouping --- src/auth0_server_python/utils/helpers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/auth0_server_python/utils/helpers.py b/src/auth0_server_python/utils/helpers.py index a1582ba..1fff76a 100644 --- a/src/auth0_server_python/utils/helpers.py +++ b/src/auth0_server_python/utils/helpers.py @@ -296,6 +296,10 @@ def validate_resolved_domain_value(domain_value: Any) -> str: return domain_value +# ============================================================================= +# Claim Validation Utilities +# ============================================================================= + def validate_org_claims(claims: dict, expected_org: str) -> None: """ Validate org_id or org_name in token claims against the requested organization.