Skip to content

Commit f4b287d

Browse files
fix(org): validate organization_id and report org failures as invalid_token
- Export MissingOrganizationError and OrganizationNotAllowedError from the package root - Keep invalid_token as the error code for organization failures - Validate organization_id at construction and normalize it once - Reject a non-string org_id claim and read it only under the required policy - Keep the org_id out of the error message and log it instead - Type organization_policy as Literal["required", "allow"] - Add tests for the single-string allowlist, no allowlist, allow policy, invalid values and the response header
1 parent cafde3e commit f4b287d

6 files changed

Lines changed: 258 additions & 29 deletions

File tree

‎docs/OrganizationPolicy.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ api_client = ApiClient(ApiClientOptions(
4949
))
5050
```
5151

52-
`organization_id` compares the opaque `org_id` claim value directly (string comparison, no network call). It does not accept or resolve the human-readable Organization name.
52+
`organization_id` compares the opaque `org_id` claim value directly (string comparison, no network call). It does not accept or resolve the human-readable Organization name. It must be a non-empty string or a non-empty list of non-empty strings, otherwise `ConfigurationError` is raised at construction.
5353

5454
## Error Handling
5555

@@ -85,7 +85,7 @@ try:
8585
except MissingOrganizationError as e:
8686
print(e) # "Token missing required 'org_id' claim"
8787
e.get_status_code() # 401
88-
e.get_error_code() # "missing_organization"
88+
e.get_error_code() # "invalid_token"
8989
```
9090

9191
### Organization Not Allowed
@@ -98,10 +98,10 @@ from auth0_api_python import OrganizationNotAllowedError
9898
try:
9999
claims = await api_client.verify_access_token(access_token)
100100
except OrganizationNotAllowedError as e:
101-
print(e) # "Organization 'org_xyz' is not in the allowed list"
101+
print(e) # "Token org_id is not in the allowed list"
102102
e.get_status_code() # 401
103-
e.get_error_code() # "organization_not_allowed"
103+
e.get_error_code() # "invalid_token"
104104
```
105105

106106
> [!NOTE]
107-
> `MissingOrganizationError` and `OrganizationNotAllowedError` are both subclasses of `VerifyAccessTokenError`. `WWW-Authenticate` response headers (via `get_headers()`) are only populated when the token is verified through `verify_request()`, which wraps these errors before re-raising. Calling `verify_access_token()` directly does not attach response headers.
107+
> `MissingOrganizationError` and `OrganizationNotAllowedError` are both subclasses of `VerifyAccessTokenError`, so they carry the same `invalid_token` error code and 401 status. The rejected `org_id` is not included in the error message or response headers. It is logged as a warning instead. `WWW-Authenticate` response headers (via `get_headers()`) are only populated when the token is verified through `verify_request()`, which wraps these errors before re-raising. Calling `verify_access_token()` directly does not attach response headers.

‎src/auth0_api_python/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
ConfigurationError,
1515
DomainsResolverError,
1616
GetTokenByExchangeProfileError,
17+
MissingOrganizationError,
18+
OrganizationNotAllowedError,
1719
)
1820
from .types import (
1921
DomainsResolver,
@@ -34,5 +36,7 @@
3436
"get_current_actor",
3537
"get_delegation_chain",
3638
"InMemoryCache",
39+
"MissingOrganizationError",
3740
"OnBehalfOfTokenResult",
41+
"OrganizationNotAllowedError",
3842
]

‎src/auth0_api_python/api_client.py‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import logging
23
import time
34
from collections.abc import Mapping, Sequence
45
from typing import Any, Optional, Union
@@ -111,10 +112,23 @@ def __init__(self, options: ApiClientOptions):
111112
raise ConfigurationError(
112113
"organization_policy must be either 'required' or 'allow'"
113114
)
114-
if options.organization_id is not None and options.organization_policy != "required":
115+
if options.organization_id is None:
116+
self._allowed_org_ids = None
117+
elif options.organization_policy != "required":
115118
raise ConfigurationError(
116119
"organization_id is only valid when organization_policy is 'required'"
117120
)
121+
else:
122+
org_ids = options.organization_id
123+
if isinstance(org_ids, str):
124+
org_ids = [org_ids]
125+
if not isinstance(org_ids, list) or not org_ids or not all(
126+
isinstance(o, str) and o.strip() for o in org_ids
127+
):
128+
raise ConfigurationError(
129+
"organization_id must be a non-empty string or a non-empty list of non-empty strings"
130+
)
131+
self._allowed_org_ids = frozenset(org_ids)
118132

119133
if options.cache_adapter:
120134
self._discovery_cache = options.cache_adapter
@@ -577,18 +591,13 @@ async def verify_access_token(
577591
raise VerifyAccessTokenError(f"Missing required claim: {rc}")
578592

579593
# Organization policy enforcement
580-
org_id = claims.get("org_id")
581594
if self.options.organization_policy == "required":
582-
if not org_id:
595+
org_id = claims.get("org_id")
596+
if not isinstance(org_id, str) or not org_id:
583597
raise MissingOrganizationError("Token missing required 'org_id' claim")
584-
allowed_orgs = self.options.organization_id
585-
if allowed_orgs is not None:
586-
if isinstance(allowed_orgs, str):
587-
allowed_orgs = [allowed_orgs]
588-
if org_id not in allowed_orgs:
589-
raise OrganizationNotAllowedError(
590-
f"Organization '{org_id}' is not in the allowed list"
591-
)
598+
if self._allowed_org_ids is not None and org_id not in self._allowed_org_ids:
599+
logging.warning("Rejected token with org_id %r not in the organization_id allowlist", org_id)
600+
raise OrganizationNotAllowedError("Token org_id is not in the allowed list")
592601

593602
return claims
594603

‎src/auth0_api_python/config.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Configuration classes and utilities for auth0-api-python.
33
"""
44

5-
from typing import TYPE_CHECKING, Callable, Optional, Union
5+
from typing import TYPE_CHECKING, Callable, Literal, Optional, Union
66

77
if TYPE_CHECKING:
88
from .cache import CacheAdapter
@@ -36,9 +36,10 @@ class ApiClientOptions:
3636
"allow" (default) uses org_id when present but does not require it,
3737
matching the pre-existing behavior of verify_access_token.
3838
"required" rejects any token without an org_id claim.
39-
organization_id: Optional allowlist of org_id claim values (a single value or a list).
40-
Only valid when organization_policy is "required" - passing it with
41-
"allow" raises ConfigurationError at construction time.
39+
organization_id: Optional allowlist of org_id claim values (a non-empty string or a non-empty
40+
list of non-empty strings). Only valid when organization_policy is
41+
"required" - passing it with "allow" raises ConfigurationError at
42+
construction time, as does an empty or non-string value.
4243
"""
4344
def __init__(
4445
self,
@@ -56,7 +57,7 @@ def __init__(
5657
client_id: Optional[str] = None,
5758
client_secret: Optional[str] = None,
5859
timeout: float = 10.0,
59-
organization_policy: str = "allow",
60+
organization_policy: Literal["required", "allow"] = "allow",
6061
organization_id: Optional[Union[str, list[str]]] = None,
6162
):
6263
self.domain = domain

‎src/auth0_api_python/errors.py‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,10 @@ def get_error_code(self) -> str:
5959
class MissingOrganizationError(VerifyAccessTokenError):
6060
"""Error raised when organization_policy is 'required' but the token has no org_id claim."""
6161

62-
def get_error_code(self) -> str:
63-
return "missing_organization"
64-
6562

6663
class OrganizationNotAllowedError(VerifyAccessTokenError):
6764
"""Error raised when the token's org_id claim is not in the organization_id allowlist."""
6865

69-
def get_error_code(self) -> str:
70-
return "organization_not_allowed"
71-
7266

7367
class InvalidAuthSchemeError(BaseAuthError):
7468
"""Error raised when the provided authentication scheme is unsupported."""

0 commit comments

Comments
 (0)