Skip to content

Commit 5586667

Browse files
authored
feat: implement OAuth 2.0 private_key_jwt client authentication (#43)
* feat: implement OAuth 2.0 private_key_jwt client authentication (RFC 7523) * Add JWKS storage to OAuth clients with database migrations * Implement JWT client assertion validation with ES256 signature verification * Update PAR endpoint to support private_key_jwt authentication * Fix client type classification for private_key_jwt clients as confidential * Add comprehensive test coverage and example scripts * Update well-known metadata to advertise private_key_jwt support * Fix SQLite session storage upsert behavior and database constraints * update port in readme
1 parent 5923f11 commit 5586667

22 files changed

Lines changed: 1592 additions & 30 deletions

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
target/
2-
.env.test
3-
.env.dev
2+
.env*
43
.vscode
54
sites/
65
*.db*
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
private_key_jwt_client

examples/private-key-jwt/README.md

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
# Private Key JWT OAuth Client Example
2+
3+
This example demonstrates how to use private_key_jwt client authentication with
4+
the AIP OAuth server, as defined in
5+
[RFC 7523](https://tools.ietf.org/html/rfc7523).
6+
7+
## Overview
8+
9+
Private Key JWT authentication allows OAuth clients to authenticate using a
10+
cryptographically signed JWT instead of a client secret. This provides enhanced
11+
security and is particularly useful for:
12+
13+
- Public clients that cannot securely store secrets
14+
- Server-to-server applications requiring strong authentication
15+
- Applications requiring non-repudiation
16+
17+
## Prerequisites
18+
19+
- OpenSSL (for key generation and JWT signing)
20+
- jq (for JSON processing)
21+
- AIP server running on `http://localhost:8080` (or set `AIP_BASE_URL`)
22+
23+
### Install Dependencies
24+
25+
```bash
26+
# macOS
27+
brew install openssl jq
28+
29+
# Ubuntu/Debian
30+
sudo apt-get install openssl jq
31+
32+
# Alpine Linux
33+
apk add openssl jq
34+
```
35+
36+
## Quick Start
37+
38+
### 1. Start the AIP Server
39+
40+
First, start your AIP server:
41+
42+
```bash
43+
# From the project root
44+
cargo run --bin aip
45+
```
46+
47+
### 2. Create and Register a Private Key JWT Client
48+
49+
```bash
50+
cd examples/private-key-jwt
51+
52+
# Create a client with default name
53+
./create_private_key_jwt_client.sh
54+
55+
# Or with a custom name
56+
./create_private_key_jwt_client.sh "My Custom Client"
57+
```
58+
59+
This script will:
60+
61+
- Generate an ES256 key pair (P-256 curve)
62+
- Create a JWK Set with the public key
63+
- Register the client with the AIP server
64+
- Save all artifacts to `private_key_jwt_client/`
65+
66+
### 3. Test the OAuth Flow
67+
68+
```bash
69+
./test_private_key_jwt.sh
70+
```
71+
72+
This script demonstrates:
73+
74+
- JWT client assertion creation and signing
75+
- Pushed Authorization Request (PAR) with private_key_jwt auth
76+
- Authorization URL generation
77+
- Client credentials grant token exchange
78+
- API endpoint testing with the access token
79+
80+
## Files Created
81+
82+
After running the setup script, you'll have:
83+
84+
```
85+
private_key_jwt_client/
86+
├── private_key.pem # Private key (keep secret!)
87+
├── public_key.pem # Public key
88+
├── jwks.json # JWK Set for client registration
89+
└── client_registration.json # Client registration response
90+
```
91+
92+
## How It Works
93+
94+
### 1. Client Registration
95+
96+
The client registers with `token_endpoint_auth_method: "private_key_jwt"` and
97+
provides a JWK Set containing its public key:
98+
99+
```json
100+
{
101+
"client_name": "My Private Key JWT Client",
102+
"token_endpoint_auth_method": "private_key_jwt",
103+
"grant_types": ["authorization_code", "refresh_token", "client_credentials"],
104+
"response_types": ["code"],
105+
"redirect_uris": ["http://localhost:8080/callback"],
106+
"scope": "atproto:atproto",
107+
"jwks": {
108+
"keys": [
109+
{
110+
"kty": "EC",
111+
"crv": "P-256",
112+
"x": "...",
113+
"y": "...",
114+
"use": "sig",
115+
"alg": "ES256",
116+
"kid": "key-1"
117+
}
118+
]
119+
}
120+
}
121+
```
122+
123+
### 2. JWT Client Assertion
124+
125+
For each OAuth request (PAR, token exchange), the client creates a signed JWT
126+
with:
127+
128+
**Header:**
129+
130+
```json
131+
{
132+
"typ": "JWT",
133+
"alg": "ES256",
134+
"kid": "key-1"
135+
}
136+
```
137+
138+
**Claims:**
139+
140+
```json
141+
{
142+
"iss": "client_id", // Client ID as issuer
143+
"sub": "client_id", // Client ID as subject
144+
"aud": "https://server/oauth/token", // Token endpoint as audience
145+
"iat": 1234567890, // Issued at time
146+
"exp": 1234568190, // Expiration (max 5 minutes)
147+
"jti": "unique-jwt-id" // Unique JWT ID (prevents replay)
148+
}
149+
```
150+
151+
### 3. Client Authentication
152+
153+
Instead of using `client_secret`, requests include:
154+
155+
```
156+
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
157+
client_assertion=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0xIn0...
158+
```
159+
160+
## Example Requests
161+
162+
### PAR Request with Private Key JWT
163+
164+
```bash
165+
curl -X POST "http://localhost:8080/oauth/par" \
166+
-H "Content-Type: application/x-www-form-urlencoded" \
167+
-d "response_type=code" \
168+
-d "client_id=your_client_id" \
169+
-d "redirect_uri=http://localhost:8080/callback" \
170+
-d "scope=atproto:atproto" \
171+
-d "state=test-state" \
172+
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
173+
-d "client_assertion=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0xIn0..."
174+
```
175+
176+
### Token Exchange with Private Key JWT
177+
178+
```bash
179+
curl -X POST "http://localhost:8080/oauth/token" \
180+
-H "Content-Type: application/x-www-form-urlencoded" \
181+
-d "grant_type=authorization_code" \
182+
-d "code=authorization_code_here" \
183+
-d "redirect_uri=http://localhost:8080/callback" \
184+
-d "client_id=your_client_id" \
185+
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
186+
-d "client_assertion=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0xIn0..."
187+
```
188+
189+
## Security Considerations
190+
191+
1. **Private Key Security**: Keep `private_key.pem` secure and never expose it
192+
2. **JWT Expiration**: JWTs should have short expiration times (≤ 5 minutes)
193+
3. **JTI Uniqueness**: Each JWT should have a unique `jti` to prevent replay
194+
attacks
195+
4. **Key Rotation**: Regularly rotate your key pairs and update the JWK Set
196+
5. **Audience Validation**: Always include the correct audience in your JWTs
197+
198+
## Troubleshooting
199+
200+
### Common Issues
201+
202+
1. **Invalid JWT Format**: Ensure proper base64url encoding without padding
203+
2. **Signature Verification Failed**: Check that your JWK Set matches your
204+
private key
205+
3. **Expired JWT**: JWTs are only valid for a short time (5 minutes max)
206+
4. **Wrong Audience**: JWT audience must match the token endpoint URL
207+
208+
### Debug Mode
209+
210+
Set environment variable for verbose output:
211+
212+
```bash
213+
export AIP_LOG=debug
214+
./test_private_key_jwt.sh
215+
```
216+
217+
## Integration with Other Examples
218+
219+
This example works with the other OAuth examples:
220+
221+
- Use the generated `client_id` with the `simple-website` example
222+
- Configure the `dpop-website` example to use private_key_jwt
223+
- Test with the `lifecycle-website` for complete OAuth lifecycle management
224+
225+
## RFC Compliance
226+
227+
This implementation follows:
228+
229+
- [RFC 7523](https://tools.ietf.org/html/rfc7523): JSON Web Token (JWT) Profile
230+
for OAuth 2.0 Client Authentication and Authorization Grants
231+
- [RFC 7517](https://tools.ietf.html/rfc7517): JSON Web Key (JWK)
232+
- [RFC 9126](https://tools.ietf.org/html/rfc9126): OAuth 2.0 Pushed
233+
Authorization Requests (PAR)
234+
- [RFC 6749](https://tools.ietf.org/html/rfc6749): The OAuth 2.0 Authorization
235+
Framework
236+
237+
## Next Steps
238+
239+
1. Integrate private_key_jwt into your OAuth client application
240+
2. Set up proper key management and rotation
241+
3. Test with different grant types (authorization_code, client_credentials)
242+
4. Monitor JWT authentication in production logs
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/bin/bash
2+
3+
# Create Private Key JWT OAuth Client
4+
# Simple script to register a client with private_key_jwt authentication
5+
# Usage: bash create_private_key_jwt_client.sh [client_name]
6+
7+
set -e
8+
9+
# Configuration
10+
AIP_BASE="${AIP_BASE_URL:-http://localhost:8080}"
11+
CLIENT_NAME="${1:-My Private Key JWT Client}"
12+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13+
OUTPUT_DIR="$SCRIPT_DIR/private_key_jwt_client"
14+
15+
echo "🔐 Creating Private Key JWT OAuth Client"
16+
echo "Client Name: $CLIENT_NAME"
17+
echo "AIP Server: $AIP_BASE"
18+
echo
19+
20+
# Create output directory
21+
mkdir -p "$OUTPUT_DIR"
22+
23+
# Check dependencies
24+
if ! command -v openssl >/dev/null 2>&1; then
25+
echo "❌ OpenSSL is required but not installed"
26+
exit 1
27+
fi
28+
29+
if ! command -v jq >/dev/null 2>&1; then
30+
echo "❌ jq is required but not installed (brew install jq)"
31+
exit 1
32+
fi
33+
34+
# Generate key pair
35+
echo "🔑 Generating ES256 key pair..."
36+
openssl ecparam -genkey -name prime256v1 -noout -out "$OUTPUT_DIR/private_key.pem"
37+
openssl ec -in "$OUTPUT_DIR/private_key.pem" -pubout -out "$OUTPUT_DIR/public_key.pem"
38+
39+
# Create JWK Set using bash (more reliable hex parsing)
40+
echo "🔧 Creating JWK Set..."
41+
42+
# Extract public key coordinates using reliable bash method
43+
PUB_HEX=$(openssl ec -in "$OUTPUT_DIR/private_key.pem" -noout -text 2>/dev/null | grep -A 10 "pub:" | grep ":" | tr -d ' :' | tr -d '\n' | sed 's/pub//' | sed 's/ASN1OID.*//')
44+
45+
# Remove the '04' prefix and split into x,y coordinates
46+
COORDS=${PUB_HEX:2}
47+
X_HEX=${COORDS:0:64}
48+
Y_HEX=${COORDS:64:64}
49+
50+
# Convert to base64url
51+
X_B64=$(printf "%s" "$X_HEX" | xxd -r -p | base64 | tr '+/' '-_' | tr -d '=')
52+
Y_B64=$(printf "%s" "$Y_HEX" | xxd -r -p | base64 | tr '+/' '-_' | tr -d '=')
53+
54+
# Create JWK Set JSON
55+
cat > "$OUTPUT_DIR/jwks.json" << EOF
56+
{
57+
"keys": [
58+
{
59+
"kty": "EC",
60+
"crv": "P-256",
61+
"x": "$X_B64",
62+
"y": "$Y_B64",
63+
"use": "sig",
64+
"alg": "ES256",
65+
"kid": "key-1"
66+
}
67+
]
68+
}
69+
EOF
70+
71+
echo '✅ JWK Set created'
72+
73+
# Register client
74+
echo "📝 Registering OAuth client..."
75+
RESPONSE=$(curl -s -X POST "$AIP_BASE/oauth/clients/register" \
76+
-H "Content-Type: application/json" \
77+
-d "{
78+
\"client_name\": \"$CLIENT_NAME\",
79+
\"token_endpoint_auth_method\": \"private_key_jwt\",
80+
\"grant_types\": [\"authorization_code\", \"refresh_token\", \"client_credentials\"],
81+
\"response_types\": [\"code\"],
82+
\"redirect_uris\": [\"http://localhost:8080/callback\"],
83+
\"scope\": \"atproto:atproto\",
84+
\"jwks\": $(cat "$OUTPUT_DIR/jwks.json")
85+
}")
86+
87+
if echo "$RESPONSE" | jq -e .client_id >/dev/null 2>&1; then
88+
CLIENT_ID=$(echo "$RESPONSE" | jq -r .client_id)
89+
echo "$RESPONSE" | jq . > "$OUTPUT_DIR/client_registration.json"
90+
91+
echo "✅ Client registered successfully!"
92+
echo " Client ID: $CLIENT_ID"
93+
echo
94+
echo "📁 Files created in $OUTPUT_DIR:"
95+
echo " - private_key.pem (keep this secret!)"
96+
echo " - public_key.pem"
97+
echo " - jwks.json"
98+
echo " - client_registration.json"
99+
echo
100+
echo "🔗 Use this client for private_key_jwt authentication"
101+
echo " See test_private_key_jwt.sh for usage examples"
102+
else
103+
echo "❌ Client registration failed:"
104+
echo "$RESPONSE" | jq .
105+
exit 1
106+
fi

0 commit comments

Comments
 (0)