Skip to content

Commit 00a33c5

Browse files
feat: Add DPoP authentication support (#22)
* feat: Add DPoP authentication support * docs: add early access note for DPoP authentication feature * ci: add GitHub Actions workflow for testing auth0-api-python package * fix: update import paths to use package namespace instead of src directory * chore: add ruff linting and apply code style fixes * docs: add examples for bearer and DPoP token authentication * docs: remove DPoP documentation link from README * feat: implement URL normalization using ada-url library and add test script * chore: remove unused URL normalization test script * test: add validation tests for edge case * test: verify error message for htu mismatch in dpop proof validation * refactor: improve URL normalization and DPoP verification * refactor: simplified JWK handling and iat error messages * refactor: reorganize test cases * test: update error message assertions for DPoP validation failures * feat: add include_jti flag to control jti claim inclusion in DPoP proof generation * fix: improve error message formatting for DPoP scheme validation * Update packages/auth0_api_python/EXAMPLES.md Co-authored-by: Rita Zerrizuela <[email protected]> * fix: preserve trailing slashes in DPoP proof URL normalization and add error handling * fix: remove trailing whitespace in test_api_client.py URL parameter * fix: update error handling for authorization and DPoP validation to return 400 status code with appropriate error messages * fix: improve error message for unsupported algorithm in DPoP proof validation * fix: improve error handling for invalid authorization scheme and update test assertions --------- Co-authored-by: Rita Zerrizuela <[email protected]>
1 parent 5d4f85e commit 00a33c5

File tree

14 files changed

+2712
-353
lines changed

14 files changed

+2712
-353
lines changed

.github/workflows/release.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ jobs:
4646
working-directory: packages/${{ github.event.inputs.sdk }}
4747
run: poetry install --no-root
4848

49+
- name: Run tests with pytest
50+
working-directory: packages/${{ github.event.inputs.sdk }}
51+
run: |
52+
poetry run pytest -v --cov=src --cov-report=term-missing --cov-report=xml
53+
4954
- name: Build package
5055
working-directory: packages/${{ github.event.inputs.sdk }}
5156
run: poetry build
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: Test auth0-api-python
2+
3+
on:
4+
push:
5+
branches:
6+
- feature/auth0-api-python
7+
paths:
8+
- 'packages/auth0_api_python/**'
9+
pull_request:
10+
branches:
11+
- main
12+
paths:
13+
- 'packages/auth0_api_python/**'
14+
15+
jobs:
16+
test:
17+
runs-on: ubuntu-latest
18+
strategy:
19+
matrix:
20+
python-version: [3.9, "3.10", "3.11", "3.12"]
21+
22+
steps:
23+
- name: Checkout code
24+
uses: actions/checkout@v4
25+
26+
- name: Set up Python ${{ matrix.python-version }}
27+
uses: actions/setup-python@v4
28+
with:
29+
python-version: ${{ matrix.python-version }}
30+
31+
- name: Install Poetry
32+
uses: snok/install-poetry@v1
33+
with:
34+
version: latest
35+
virtualenvs-create: true
36+
virtualenvs-in-project: true
37+
installer-parallel: true
38+
39+
- name: Load cached venv
40+
id: cached-poetry-dependencies
41+
uses: actions/cache@v3
42+
with:
43+
path: packages/auth0_api_python/.venv
44+
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
45+
46+
- name: Install dependencies
47+
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
48+
working-directory: ./packages/auth0_api_python
49+
run: poetry install --no-interaction --no-root
50+
51+
- name: Install package
52+
working-directory: ./packages/auth0_api_python
53+
run: poetry install --no-interaction
54+
55+
- name: Run tests with pytest
56+
working-directory: ./packages/auth0_api_python
57+
run: |
58+
poetry run pytest -v --cov=src --cov-report=term-missing --cov-report=xml
59+
60+
- name: Run ruff linting
61+
working-directory: ./packages/auth0_api_python
62+
run: |
63+
poetry run ruff check .
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
line-length = 100
2+
target-version = "py39"
3+
select = [
4+
"E", # pycodestyle errors
5+
"W", # pycodestyle warnings
6+
"F", # pyflakes
7+
"I", # isort
8+
"B", # flake8-bugbear
9+
"C4", # flake8-comprehensions
10+
"UP", # pyupgrade
11+
"S", # bandit (security)
12+
]
13+
ignore = ["E501", "B904"] # Line too long (handled by black), Exception handling without from
14+
15+
[per-file-ignores]
16+
"tests/*" = ["S101", "S105", "S106"] # Allow assert and ignore hardcoded password warnings in test files
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Auth0 API Python Examples
2+
3+
This document provides examples for using the `auth0-api-python` package to validate Auth0 tokens in your API.
4+
5+
## Bearer Authentication
6+
7+
Bearer authentication is the standard OAuth 2.0 token authentication method.
8+
9+
### Using verify_access_token
10+
11+
```python
12+
import asyncio
13+
from auth0_api_python import ApiClient, ApiClientOptions
14+
15+
async def validate_bearer_token(headers):
16+
api_client = ApiClient(ApiClientOptions(
17+
domain="your-tenant.auth0.com",
18+
audience="https://api.example.com"
19+
))
20+
21+
try:
22+
# Extract the token from the Authorization header
23+
auth_header = headers.get("authorization", "")
24+
if not auth_header.startswith("Bearer "):
25+
return {"error": "Missing or invalid authorization header"}, 401
26+
27+
token = auth_header.split(" ")[1]
28+
29+
# Verify the access token
30+
claims = await api_client.verify_access_token(token)
31+
return {"success": True, "user": claims["sub"]}
32+
except Exception as e:
33+
return {"error": str(e)}, getattr(e, "get_status_code", lambda: 401)()
34+
35+
# Example usage
36+
headers = {"authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."}
37+
result = asyncio.run(validate_bearer_token(headers))
38+
```
39+
40+
### Using verify_request
41+
42+
```python
43+
import asyncio
44+
from auth0_api_python import ApiClient, ApiClientOptions
45+
from auth0_api_python.errors import BaseAuthError
46+
47+
async def validate_request(headers):
48+
api_client = ApiClient(ApiClientOptions(
49+
domain="your-tenant.auth0.com",
50+
audience="https://api.example.com"
51+
))
52+
53+
try:
54+
# Verify the request with Bearer token
55+
claims = await api_client.verify_request(
56+
headers=headers
57+
)
58+
return {"success": True, "user": claims["sub"]}
59+
except BaseAuthError as e:
60+
return {"error": str(e)}, e.get_status_code(), e.get_headers()
61+
62+
# Example usage
63+
headers = {"authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."}
64+
result = asyncio.run(validate_request(headers))
65+
```
66+
67+
68+
## DPoP Authentication
69+
70+
[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Posession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the client application is in possession of a certain private key.
71+
72+
This guide covers the DPoP implementation in `auth0-api-python` with complete examples for both operational modes.
73+
74+
For more information about DPoP specification, see [RFC 9449](https://tools.ietf.org/html/rfc9449).
75+
76+
## Configuration Modes
77+
78+
### 1. Allowed Mode (Default)
79+
```python
80+
from auth0_api_python import ApiClient, ApiClientOptions
81+
82+
api_client = ApiClient(ApiClientOptions(
83+
domain="your-tenant.auth0.com",
84+
audience="https://api.example.com",
85+
dpop_enabled=True, # Default: enables DPoP support
86+
dpop_required=False # Default: allows both Bearer and DPoP
87+
))
88+
```
89+
90+
### 2. Required Mode
91+
```python
92+
api_client = ApiClient(ApiClientOptions(
93+
domain="your-tenant.auth0.com",
94+
audience="https://api.example.com",
95+
dpop_required=True # Enforces DPoP-only authentication
96+
))
97+
```
98+
99+
## Getting Started
100+
101+
### Basic Usage with verify_request()
102+
103+
The `verify_request()` method automatically detects the authentication scheme:
104+
105+
```python
106+
import asyncio
107+
from auth0_api_python import ApiClient, ApiClientOptions
108+
109+
async def handle_api_request(headers, http_method, http_url):
110+
api_client = ApiClient(ApiClientOptions(
111+
domain="your-tenant.auth0.com",
112+
audience="https://api.example.com"
113+
))
114+
115+
try:
116+
# Automatically handles both Bearer and DPoP schemes
117+
claims = await api_client.verify_request(
118+
headers=headers,
119+
http_method=http_method,
120+
http_url=http_url
121+
)
122+
return {"success": True, "user": claims["sub"]}
123+
except Exception as e:
124+
return {"error": str(e)}, e.get_status_code()
125+
126+
# Example usage
127+
headers = {
128+
"authorization": "DPoP eyJ0eXAiOiJKV1Q...",
129+
"dpop": "eyJ0eXAiOiJkcG9wK2p3dC..."
130+
}
131+
result = asyncio.run(handle_api_request(headers, "GET", "https://api.example.com/data"))
132+
```
133+
134+
### Direct DPoP Proof Verification
135+
136+
For more control, use `verify_dpop_proof()` directly:
137+
138+
```python
139+
async def verify_dpop_token(access_token, dpop_proof, http_method, http_url):
140+
api_client = ApiClient(ApiClientOptions(
141+
domain="your-tenant.auth0.com",
142+
audience="https://api.example.com"
143+
))
144+
145+
# First verify the access token
146+
token_claims = await api_client.verify_access_token(access_token)
147+
148+
# Then verify the DPoP proof
149+
proof_claims = await api_client.verify_dpop_proof(
150+
access_token=access_token,
151+
proof=dpop_proof,
152+
http_method=http_method,
153+
http_url=http_url
154+
)
155+
156+
return {
157+
"token_claims": token_claims,
158+
"proof_claims": proof_claims
159+
}
160+
```

packages/auth0_api_python/README.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ It’s intended as a foundation for building more framework-specific integration
66

77
📚 [Documentation](#documentation) - 🚀 [Getting Started](#getting-started) - 💬 [Feedback](#feedback)
88

9+
## Features & Authentication Schemes
10+
11+
This SDK provides comprehensive support for securing APIs with Auth0-issued access tokens:
12+
13+
### **Authentication Schemes**
14+
- **Bearer Token Authentication** - Traditional OAuth 2.0 Bearer tokens (RS256)
15+
- **DPoP Authentication** - Enhanced security with Demonstrating Proof-of-Possession (ES256)
16+
- **Mixed Mode Support** - Seamlessly handles both Bearer and DPoP in the same API
17+
18+
### **Core Features**
19+
- **Unified Entry Point**: `verify_request()` - automatically detects and validates Bearer or DPoP schemes
20+
- **OIDC Discovery** - Automatic fetching of Auth0 metadata and JWKS
21+
- **JWT Validation** - Complete RS256 signature verification with claim validation
22+
- **DPoP Proof Verification** - Full RFC 9449 compliance with ES256 signature validation
23+
- **Flexible Configuration** - Support for both "Allowed" and "Required" DPoP modes
24+
- **Comprehensive Error Handling** - Detailed errors with proper HTTP status codes and WWW-Authenticate headers
25+
- **Framework Agnostic** - Works with FastAPI, Django, Flask, or any Python web framework
26+
927
## Documentation
1028

1129
- [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0.
@@ -80,6 +98,61 @@ decoded_and_verified_token = await api_client.verify_access_token(
8098

8199
If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`.
82100

101+
### 4. DPoP Authentication
102+
103+
> [!NOTE]
104+
> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant.
105+
106+
This library supports **DPoP (Demonstrating Proof-of-Possession)** for enhanced security, allowing clients to prove possession of private keys bound to access tokens.
107+
108+
#### Allowed Mode (Default)
109+
110+
Accepts both Bearer and DPoP tokens - ideal for gradual migration:
111+
112+
```python
113+
api_client = ApiClient(ApiClientOptions(
114+
domain="<AUTH0_DOMAIN>",
115+
audience="<AUTH0_AUDIENCE>",
116+
dpop_enabled=True, # Default - enables DPoP support
117+
dpop_required=False # Default - allows both Bearer and DPoP
118+
))
119+
120+
# Use verify_request() for automatic scheme detection
121+
result = await api_client.verify_request(
122+
headers={
123+
"authorization": "DPoP eyJ0eXAiOiJKV1Q...", # DPoP scheme
124+
"dpop": "eyJ0eXAiOiJkcG9wK2p3dC...", # DPoP proof
125+
},
126+
http_method="GET",
127+
http_url="https://api.example.com/resource"
128+
)
129+
```
130+
131+
#### Required Mode
132+
133+
Enforces DPoP-only authentication, rejecting Bearer tokens:
134+
135+
```python
136+
api_client = ApiClient(ApiClientOptions(
137+
domain="<AUTH0_DOMAIN>",
138+
audience="<AUTH0_AUDIENCE>",
139+
dpop_required=True # Rejects Bearer tokens
140+
))
141+
```
142+
143+
#### Configuration Options
144+
145+
```python
146+
api_client = ApiClient(ApiClientOptions(
147+
domain="<AUTH0_DOMAIN>",
148+
audience="<AUTH0_AUDIENCE>",
149+
dpop_enabled=True, # Enable/disable DPoP support
150+
dpop_required=False, # Require DPoP (reject Bearer)
151+
dpop_iat_leeway=30, # Clock skew tolerance (seconds)
152+
dpop_iat_offset=300, # Maximum proof age (seconds)
153+
))
154+
```
155+
83156
## Feedback
84157

85158
### Contributing

0 commit comments

Comments
 (0)