|
1 | 1 | # Examples |
2 | 2 |
|
3 | | -- [DPoP Authentication (Early Access)](#dpop-authentication-early-access) |
| 3 | +- [DPoP Authentication](#dpop-authentication) |
4 | 4 | - [Accept both Bearer and DPoP tokens (default)](#accept-both-bearer-and-dpop-tokens-default) |
5 | 5 | - [Require only DPoP tokens](#require-only-dpop-tokens) |
6 | 6 | - [Require only Bearer tokens](#require-only-bearer-tokens) |
7 | 7 | - [Customize DPoP validation behavior](#customize-dpop-validation-behavior) |
8 | 8 | - [Hostname Resolution (`req.host` and `req.protocol`)](#hostname-resolution-reqhost-and-reqprotocol) |
9 | | - |
| 9 | + - [DPoP jti Replay Prevention](#dpop-jti-replay-prevention) |
10 | 10 | - [Restrict access with scopes](#restrict-access-with-scopes) |
11 | 11 | - [Restrict access with claims](#restrict-access-with-claims) |
12 | 12 | - [Matching a specific value](#matching-a-specific-value) |
13 | 13 | - [Matching multiple values](#matching-multiple-values) |
14 | 14 | - [Matching custom logic](#matching-custom-logic) |
15 | 15 |
|
16 | 16 |
|
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 | + |
21 | 19 | > If DPoP is disabled (`dpop: { enabled: false }`), only standard Bearer tokens will be accepted. |
22 | 20 |
|
23 | 21 | [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 |
135 | 133 | app.enable('trust proxy'); |
136 | 134 | ``` |
137 | 135 |
|
| 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 | + |
138 | 265 | ## Restrict access with scopes |
139 | 266 |
|
140 | 267 | 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. |
|
0 commit comments