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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
run: exit 0 # Skip unnecessary test runs for dependabot and merge queues. Artifically flag as successful, as this is a required check for branch protection.

- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Initialize CodeQL
uses: github/codeql-action/init@v4
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
Expand Down Expand Up @@ -61,7 +61,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
Expand All @@ -43,7 +43,7 @@ jobs:

- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ./.venv
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
Expand Down
29 changes: 28 additions & 1 deletion examples/CustomTokenExchange.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ print(f"User logged in: {user['sub']}")

## 3. Actor Tokens (Delegation)

Enable delegation scenarios where one service acts on behalf of a user.
Enable delegation scenarios where one party acts on behalf of a user. The acting party is supplied via `actor_token`, and Auth0 records it in the [`act` claim](https://datatracker.ietf.org/doc/html/rfc8693#section-4.1) on the issued tokens.

```python
# Service acting on behalf of a user
Expand All @@ -77,8 +77,34 @@ response = await auth0.custom_token_exchange(
audience="https://api.example.com"
)
)

# The actor claim is surfaced on the response. It may nest for delegation chains.
if response.act:
print(f"Acting party: {response.act['sub']}")
```

> **NOTE**: `response.act` is read from the ID token. Auth0 writes the same `act` claim onto the issued access token as well, so they reflect the same acting party. The access token may be opaque, in which case `act` cannot be read off it directly - the ID token is where you read it.

When you establish a session with `login_with_custom_token_exchange()`, the `act` claim is persisted on the session user and can be read back later via `get_user()`:

```python
result = await auth0.login_with_custom_token_exchange(
LoginWithCustomTokenExchangeOptions(
subject_token="user-access-token",
subject_token_type="urn:ietf:params:oauth:token-type:access_token",
actor_token="service-access-token",
actor_token_type="urn:ietf:params:oauth:token-type:access_token",
),
store_options={"request": request, "response": response}
)

user = result.state_data["user"]
if user.get("act"):
print(f"Acting party: {user['act']['sub']}")
```

> **NOTE**: When an `actor_token` is present, Auth0 does not issue a refresh token (the `offline_access` scope is dropped). A subsequent refresh-token grant therefore cannot re-emit the `act` claim, so the acting party is fixed at exchange time.

## 4. Custom Authorization Parameters

Pass additional parameters to the token endpoint.
Expand Down Expand Up @@ -133,6 +159,7 @@ except CustomTokenExchangeError as e:

- `INVALID_TOKEN_FORMAT`: Token is empty, whitespace-only, or has "Bearer " prefix
- `MISSING_ACTOR_TOKEN_TYPE`: `actor_token` provided without `actor_token_type`
- `MISSING_ACTOR_TOKEN`: `actor_token_type` provided without `actor_token`
- `TOKEN_EXCHANGE_FAILED`: General token exchange failure
- `INVALID_RESPONSE`: Auth0 returned a non-JSON response

Expand Down
699 changes: 158 additions & 541 deletions poetry.lock

Large diffs are not rendered by default.

60 changes: 57 additions & 3 deletions src/auth0_server_python/auth_server/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2287,10 +2287,45 @@ async def custom_token_exchange(
https://datatracker.ietf.org/doc/html/rfc8693
"""
try:
# Validate options (Pydantic handles this automatically)
if not isinstance(options, CustomTokenExchangeOptions):
options = CustomTokenExchangeOptions(**options)

if not options.subject_token.strip():
Comment thread
kishore7snehil marked this conversation as resolved.
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
"subject_token cannot be empty or whitespace-only"
)
if not options.subject_token_type.strip():
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
"subject_token_type cannot be empty or whitespace-only"
)
if options.subject_token.strip().startswith("Bearer "):
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
"subject_token should not include 'Bearer ' prefix"
)
if options.actor_token is not None and not options.actor_token.strip():
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
"actor_token cannot be empty or whitespace-only"
)
if options.actor_token and options.actor_token.strip().startswith("Bearer "):
Comment thread
kishore7snehil marked this conversation as resolved.
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
"actor_token should not include 'Bearer ' prefix"
)
if options.actor_token and not options.actor_token_type:
Comment thread
kishore7snehil marked this conversation as resolved.
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.MISSING_ACTOR_TOKEN_TYPE,
"actor_token_type is required when actor_token is provided"
)
if options.actor_token_type and not options.actor_token:
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.MISSING_ACTOR_TOKEN,
"actor_token is required when actor_token_type is provided"
)

# Resolve domain
domain = await self._resolve_current_domain(store_options)
metadata = await self._get_oidc_metadata_cached(domain)
Expand Down Expand Up @@ -2353,8 +2388,25 @@ async def custom_token_exchange(
"Failed to parse token response as JSON"
)

# Validate and return response
return TokenExchangeResponse(**token_data)
token_response = TokenExchangeResponse(**token_data)

# Surface the actor claim for delegation exchanges. Best-effort:
# a decode/verify hiccup must not fail an exchange the token
# endpoint already accepted, so act stays None on any failure.
if options.actor_token and token_response.id_token:
Comment thread
kishore7snehil marked this conversation as resolved.
try:
jwks = await self._get_jwks_cached(domain, metadata)
Comment thread
kishore7snehil marked this conversation as resolved.
claims = await self._verify_and_decode_jwt(
token_response.id_token, jwks, audience=self._client_id
)
# Apply the same normalized issuer check the login path uses
# before trusting any claim from the token.
if self._normalize_url(claims.get("iss", "")) == self._normalize_url(metadata.get("issuer")):
token_response.act = claims.get("act")
Comment thread
kishore7snehil marked this conversation as resolved.
except Exception:
token_response.act = None

return token_response

except ValidationError as e:
raise CustomTokenExchangeError(
Expand Down Expand Up @@ -2447,6 +2499,8 @@ async def login_with_custom_token_exchange(
"ID token issuer mismatch. Ensure your Auth0 domain is configured correctly."
)

# UserClaims allows extra fields, so any act claim in the
# verified id_token is carried onto the session user here.
user_claims = UserClaims.parse_obj(claims)
# Extract sid from token if available
sid = claims.get("sid", sid)
Expand Down
48 changes: 7 additions & 41 deletions src/auth0_server_python/auth_types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from typing import Any, Literal, Optional, Union

from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, Field


class UserClaims(BaseModel):
Expand Down Expand Up @@ -262,33 +262,15 @@ class CustomTokenExchangeOptions(BaseModel):
organization: Organization identifier for the token exchange (optional)
authorization_params: Additional OAuth parameters (optional)
"""
subject_token: str = Field(..., min_length=1)
subject_token_type: str = Field(..., min_length=1)
subject_token: str
Comment thread
kishore7snehil marked this conversation as resolved.
subject_token_type: str
audience: Optional[str] = None
scope: Optional[str] = None
actor_token: Optional[str] = None
actor_token_type: Optional[str] = None
organization: Optional[str] = None
authorization_params: Optional[dict[str, Any]] = None

@field_validator('subject_token', 'actor_token')
@classmethod
def validate_token_format(cls, v: Optional[str]) -> Optional[str]:
"""Validate token doesn't have Bearer prefix and isn't whitespace-only."""
if v is not None:
if not v.strip():
raise ValueError("Token cannot be empty or whitespace-only")
if v.strip().startswith("Bearer "):
raise ValueError("Token should not include 'Bearer ' prefix")
return v

@model_validator(mode='after')
def validate_actor_token_type(self) -> 'CustomTokenExchangeOptions':
"""Ensure actor_token_type is provided if actor_token is present."""
if self.actor_token and not self.actor_token_type:
raise ValueError("actor_token_type is required when actor_token is provided")
return self


class TokenExchangeResponse(BaseModel):
"""
Expand All @@ -302,6 +284,7 @@ class TokenExchangeResponse(BaseModel):
issued_token_type: Format of issued token
id_token: OpenID Connect ID token (optional)
refresh_token: Refresh token (optional)
act: Actor claim for delegation/impersonation exchanges (optional)
"""
access_token: str
token_type: str = "Bearer"
Expand All @@ -310,6 +293,7 @@ class TokenExchangeResponse(BaseModel):
issued_token_type: Optional[str] = None
id_token: Optional[str] = None
refresh_token: Optional[str] = None
act: Optional[dict[str, Any]] = None


class LoginWithCustomTokenExchangeOptions(BaseModel):
Expand All @@ -318,33 +302,15 @@ class LoginWithCustomTokenExchangeOptions(BaseModel):

Combines token exchange parameters with session management.
"""
subject_token: str = Field(..., min_length=1)
subject_token_type: str = Field(..., min_length=1)
subject_token: str
subject_token_type: str
audience: Optional[str] = None
scope: Optional[str] = None
actor_token: Optional[str] = None
actor_token_type: Optional[str] = None
organization: Optional[str] = None
authorization_params: Optional[dict[str, Any]] = None

@field_validator('subject_token', 'actor_token')
@classmethod
def validate_token_format(cls, v: Optional[str]) -> Optional[str]:
"""Validate token doesn't have Bearer prefix and isn't whitespace-only."""
if v is not None:
if not v.strip():
raise ValueError("Token cannot be empty or whitespace-only")
if v.strip().startswith("Bearer "):
raise ValueError("Token should not include 'Bearer ' prefix")
return v

@model_validator(mode='after')
def validate_actor_token_type(self) -> 'LoginWithCustomTokenExchangeOptions':
"""Ensure actor_token_type is provided if actor_token is present."""
if self.actor_token and not self.actor_token_type:
raise ValueError("actor_token_type is required when actor_token is provided")
return self


class LoginWithCustomTokenExchangeResult(BaseModel):
"""
Expand Down
1 change: 1 addition & 0 deletions src/auth0_server_python/error/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ class CustomTokenExchangeErrorCode:
"""Error codes for custom token exchange operations."""
INVALID_TOKEN_FORMAT = "invalid_token_format"
MISSING_ACTOR_TOKEN_TYPE = "missing_actor_token_type"
MISSING_ACTOR_TOKEN = "missing_actor_token"
TOKEN_EXCHANGE_FAILED = "token_exchange_failed"
INVALID_RESPONSE = "invalid_response"

Expand Down
Loading
Loading