Skip to content

Commit aefaab2

Browse files
authored
docs: Add DPoP jti replay prevention examples to EXAMPLES.md (#215)
1 parent 16d43af commit aefaab2

File tree

2 files changed

+134
-9
lines changed

2 files changed

+134
-9
lines changed

packages/express-oauth2-jwt-bearer/EXAMPLES.md

Lines changed: 133 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
# Examples
22

3-
- [DPoP Authentication (Early Access)](#dpop-authentication-early-access)
3+
- [DPoP Authentication](#dpop-authentication)
44
- [Accept both Bearer and DPoP tokens (default)](#accept-both-bearer-and-dpop-tokens-default)
55
- [Require only DPoP tokens](#require-only-dpop-tokens)
66
- [Require only Bearer tokens](#require-only-bearer-tokens)
77
- [Customize DPoP validation behavior](#customize-dpop-validation-behavior)
88
- [Hostname Resolution (`req.host` and `req.protocol`)](#hostname-resolution-reqhost-and-reqprotocol)
9-
9+
- [DPoP jti Replay Prevention](#dpop-jti-replay-prevention)
1010
- [Restrict access with scopes](#restrict-access-with-scopes)
1111
- [Restrict access with claims](#restrict-access-with-claims)
1212
- [Matching a specific value](#matching-a-specific-value)
1313
- [Matching multiple values](#matching-multiple-values)
1414
- [Matching custom logic](#matching-custom-logic)
1515

1616

17-
## DPoP Authentication (Early Access)
18-
> [!NOTE]
19-
> DPoP (Demonstration of Proof-of-Possession) support is currently in [**Early Access**](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Contact Auth0 support to enable it for your tenant.
20-
>
17+
## DPoP Authentication
18+
2119
> If DPoP is disabled (`dpop: { enabled: false }`), only standard Bearer tokens will be accepted.
2220
2321
[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.
@@ -135,6 +133,135 @@ This SDK uses `req.protocol` and `req.host` to construct the `htu` (HTTP URI) cl
135133
app.enable('trust proxy');
136134
```
137135

136+
### DPoP jti Replay Prevention
137+
138+
> [!WARNING]
139+
> **Security Notice**: The SDK validates that the `jti` (JWT ID) claim exists in DPoP proofs and verifies the proof signature, but it does **not** cache or validate `jti` uniqueness. This means the same DPoP proof can be replayed multiple times within its validity window.
140+
>
141+
> **For production use, you MUST implement your own `jti` validation logic to prevent replay attacks.**
142+
143+
#### What the SDK validates
144+
- DPoP proof signature and structure
145+
- `ath` (access token hash) matches the access token
146+
- `htm` (HTTP method) and `htu` (HTTP URI) match the request
147+
- `iat` (issued at) is within the acceptable time range
148+
- `jti` claim exists
149+
150+
#### What the SDK does not validate
151+
- `jti` uniqueness across requests (replay prevention)
152+
153+
#### Implementation Example: In-Memory Cache (Development/Single Instance)
154+
155+
```js
156+
const express = require('express');
157+
const { auth } = require('express-oauth2-jwt-bearer');
158+
159+
const jtiCache = new Map();
160+
161+
const validateDPoPJti = (req, res, next) => {
162+
const dpopProof = req.headers['dpop'];
163+
if (!dpopProof) return next();
164+
165+
const [, payloadB64] = dpopProof.split('.');
166+
const payload = JSON.parse(
167+
Buffer.from(payloadB64, 'base64url').toString()
168+
);
169+
170+
const { jti, iat } = payload;
171+
172+
if (jtiCache.has(jti)) {
173+
return res.status(401).json({
174+
error: 'invalid_token',
175+
error_description: 'DPoP proof has already been used'
176+
});
177+
}
178+
179+
// Default validity window: 300s + 30s
180+
jtiCache.set(jti, (iat + 330) * 1000);
181+
next();
182+
};
183+
184+
const app = express();
185+
186+
app.use(auth({
187+
issuerBaseURL: 'https://YOUR_ISSUER_DOMAIN',
188+
audience: 'https://my-api.com',
189+
dpop: { enabled: true }
190+
}));
191+
192+
app.use(validateDPoPJti);
193+
194+
app.get('/api/protected', (req, res) => {
195+
res.json({ message: 'Access granted' });
196+
});
197+
```
198+
199+
#### Implementation Example: Redis (Production/Multi-Instance)
200+
201+
For production deployments with multiple server instances, use a shared cache like Redis:
202+
203+
```js
204+
const express = require('express');
205+
const { auth } = require('express-oauth2-jwt-bearer');
206+
const Redis = require('ioredis');
207+
208+
const redis = new Redis({
209+
host: process.env.REDIS_HOST || 'localhost',
210+
port: process.env.REDIS_PORT || 6379,
211+
});
212+
213+
const validateDPoPJtiWithRedis = async (req, res, next) => {
214+
const dpopProof = req.headers['dpop'];
215+
216+
if (!dpopProof) {
217+
return next();
218+
}
219+
220+
try {
221+
const [, payloadB64] = dpopProof.split('.');
222+
const payload = JSON.parse(
223+
Buffer.from(payloadB64, 'base64url').toString()
224+
);
225+
const { jti, iat } = payload;
226+
227+
// Check if jti exists in Redis
228+
const exists = await redis.exists(`dpop:jti:${jti}`);
229+
230+
if (exists) {
231+
return res.status(401)
232+
.set('WWW-Authenticate', 'DPoP error="use_dpop_nonce", error_description="DPoP proof has already been used"')
233+
.json({
234+
error: 'use_dpop_nonce',
235+
error_description: 'DPoP proof has already been used'
236+
});
237+
}
238+
239+
// Store jti with TTL matching the proof's validity window
240+
const now = Math.floor(Date.now() / 1000);
241+
const ttlSeconds = Math.max(1, (iat + 330) - now); // iat + iatOffset + iatLeeway
242+
await redis.setex(`dpop:jti:${jti}`, ttlSeconds, '1');
243+
244+
next();
245+
} catch (err) {
246+
next(err);
247+
}
248+
};
249+
250+
const app = express();
251+
252+
app.use(auth({
253+
issuerBaseURL: 'https://YOUR_ISSUER_DOMAIN',
254+
audience: 'https://my-api.com',
255+
dpop: { enabled: true }
256+
}));
257+
258+
app.use(validateDPoPJtiWithRedis);
259+
260+
app.get('/api/protected', (req, res) => {
261+
res.json({ message: 'Access granted' });
262+
});
263+
```
264+
138265
## Restrict access with scopes
139266

140267
To restrict access based on the scopes a user has, use the `requiredScopes` middleware, raising a 403 `insufficient_scope` error if the value of the scope claim does not include all the given scopes.

packages/express-oauth2-jwt-bearer/README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,7 @@ app.get('/api/messages', (req, res, next) => {
8585
});
8686
```
8787

88-
#### DPoP Authentication (Early Access)
89-
90-
> **Note:** DPoP support is currently in **Early Access**. Contact [Auth0 Support](https://support.auth0.com/) to have it enabled for your tenant.
88+
#### DPoP Authentication
9189

9290
This SDK supports [DPoP (Demonstration of Proof-of-Possession)](https://datatracker.ietf.org/doc/html/rfc9449), which enhances access token security by requiring clients to prove possession of a private key associated with each request.
9391

0 commit comments

Comments
 (0)