From c49d8508be6bd66800052557315da04047efb5de Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 25 May 2026 00:48:12 +0530 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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)