Skip to content

Commit 42bacb7

Browse files
committed
feat(jwe): decrypt JWE-encrypted access tokens
Decrypt JWE-encrypted access tokens before writing them to the session, at both the callback and on token refresh, so req.oidc.accessToken and afterCallback always receive a plaintext JWT. Enabled by setting accessTokenDecryptionKey. The key-management algorithm is read from the JWE header and validated against a strong-algorithm allowlist; accessTokenDecryptionAlg can pin it. Decryption failures surface a clear error.
1 parent c9b7a7b commit 42bacb7

10 files changed

Lines changed: 589 additions & 3 deletions

File tree

EXAMPLES.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
13. [Use a proxy for OIDC requests](#13-use-a-proxy-for-oidc-requests)
1616
14. [Session expiry from upstream IdP (IPSIE `session_expiry`)](#14-session-expiry-from-upstream-idp-ipsie-session_expiry)
1717
15. [JWT-Secured Authorization Requests (JAR)](#15-jwt-secured-authorization-requests-jar)
18+
16. [Decrypt JWE-encrypted access tokens](#16-decrypt-jwe-encrypted-access-tokens)
1819

1920
## 1. Basic setup
2021

@@ -567,3 +568,28 @@ app.use(
567568
The request object's `aud` is set to the issuer identifier advertised in the discovery document (which may differ from `issuerBaseURL`, e.g. a trailing slash), as JAR requires. `requestObjectSigningKey` accepts the same key formats as `clientAssertionSigningKey` (PEM string, Buffer, KeyObject, JWK, CryptoKey).
568569

569570
Full example at [jar.js](./examples/jar.js), to run it: `npm run start:example -- jar`
571+
572+
## 16. Decrypt JWE-encrypted access tokens
573+
574+
When an Auth0 API is configured with [Token Encryption](https://auth0.com/docs/secure/tokens/access-tokens/json-web-encryption), the access token returned at the callback is a JWE. Set `accessTokenDecryptionKey` and the SDK decrypts it before writing it to the session, at both the callback and on token refresh, so `req.oidc.accessToken` and `afterCallback` always receive a plaintext JWT. No change is needed in consuming code.
575+
576+
```js
577+
app.use(
578+
auth({
579+
authorizationParams: {
580+
response_type: 'code', // requires a client secret; JWE requires a code flow
581+
audience: 'https://your-api/',
582+
scope: 'openid profile email offline_access',
583+
},
584+
// Private key matching the public key uploaded to the API's Token Encryption
585+
// settings. Accepts PEM string, Buffer, KeyObject, JWK, or CryptoKey.
586+
accessTokenDecryptionKey: fs.readFileSync('./api-decryption-key.pem'),
587+
// Optional pin. Omit to auto-detect the algorithm from the JWE header.
588+
// accessTokenDecryptionAlg: 'RSA-OAEP-512',
589+
}),
590+
);
591+
```
592+
593+
The key-management algorithm is read from the JWE protected header (validated against a strong-algorithm allowlist), so decryption works whether the tenant uses `RSA-OAEP-256`, `RSA-OAEP-512`, and so on. Set `accessTokenDecryptionAlg` only if you want to pin one. Decryption requires a code flow, since implicit flow does not return access tokens from the token endpoint.
594+
595+
Full example at [jwe.js](./examples/jwe.js).

examples/jwe.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const express = require('express');
2+
const { auth } = require('../');
3+
4+
const app = express();
5+
6+
// JWE access token decryption demo.
7+
//
8+
// When Auth0 is configured to encrypt access tokens for an API (Dashboard > APIs >
9+
// your API > Token Encryption), the token returned at the callback is a JWE (five
10+
// dot-separated parts). With `accessTokenDecryptionKey` set, the SDK decrypts it
11+
// before writing it to the session - at both the callback and on token refresh -
12+
// so `req.oidc.accessToken` and `afterCallback` always see a plaintext JWT.
13+
//
14+
// The key-management algorithm is read from the JWE protected header (validated
15+
// against a strong-alg allowlist), so decryption works whether the tenant uses
16+
// RSA-OAEP-256, RSA-OAEP-512, etc. Set `accessTokenDecryptionAlg` only to pin one.
17+
//
18+
// Requires a code flow and an API that encrypts tokens, so this example is not
19+
// runnable against the mock provider (it does not encrypt). It shows the config.
20+
21+
app.use(
22+
auth({
23+
authRequired: false,
24+
clientSecret: '__your_client_secret__',
25+
authorizationParams: {
26+
response_type: 'code',
27+
// Request the API whose access tokens are encrypted.
28+
audience: 'https://your-api/',
29+
scope: 'openid profile email offline_access',
30+
},
31+
32+
// Private key matching the public key uploaded to the API's Token Encryption
33+
// settings. Accepts PEM string, Buffer, KeyObject, JWK, or CryptoKey.
34+
accessTokenDecryptionKey: require('fs').readFileSync(
35+
'./api-decryption-key.pem',
36+
),
37+
// Optional pin. Omit to auto-detect the alg from the JWE header.
38+
// accessTokenDecryptionAlg: 'RSA-OAEP-512',
39+
}),
40+
);
41+
42+
app.get('/', (req, res) => {
43+
if (req.oidc.isAuthenticated()) {
44+
const token = req.oidc.accessToken?.access_token;
45+
// A decrypted token is a normal 3-part JWT; a 5-part value means it is still
46+
// encrypted (decryption key not active).
47+
const parts = token ? token.split('.').length : 0;
48+
res.send(
49+
`<p>Logged in as <strong>${req.oidc.user.sub}</strong></p>` +
50+
`<p>Access token parts: ${parts} (3 = decrypted JWT)</p>` +
51+
`<a href="/logout">logout</a>`,
52+
);
53+
} else {
54+
res.send('<a href="/login">login</a>');
55+
}
56+
});
57+
58+
module.exports = app;

index.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,22 @@ interface ConfigParams {
847847
*/
848848
requestObjectSigningKeyId?: string;
849849

850+
/**
851+
* Private key used to decrypt JWE-encrypted access tokens.
852+
* When set, the SDK decrypts the access token at the callback and on refresh
853+
* before writing it to the session, so `req.oidc.accessToken` and `afterCallback`
854+
* always receive a plaintext JWT. Requires a code flow (`response_type: 'code'`
855+
* or `'code id_token'`). Accepts PEM string, Buffer, KeyObject, JWK, or CryptoKey.
856+
*/
857+
accessTokenDecryptionKey?: KeyObject | JoseCryptoKey | JWK | string | Buffer;
858+
859+
/**
860+
* Optional pin for the JWE key-management algorithm. When omitted, the algorithm
861+
* is read from the JWE protected header (validated against a strong-algorithm
862+
* allowlist), so decryption works with whatever alg the tenant uses.
863+
*/
864+
accessTokenDecryptionAlg?: string;
865+
850866
/**
851867
* Additional request body properties to be sent to the `token_endpoint` during authorization code exchange or token refresh.
852868
*/

index.test-d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,11 @@ expectType<RequestHandler>(
1515
requestObjectSigningKeyId: 'kid-1',
1616
}),
1717
);
18+
19+
// JWE access token decryption
20+
expectType<RequestHandler>(
21+
auth({
22+
accessTokenDecryptionKey: '-----BEGIN PRIVATE KEY-----',
23+
accessTokenDecryptionAlg: 'RSA-OAEP-512',
24+
}),
25+
);

lib/client.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
const crypto = require('crypto');
22
const client = require('openid-client');
3-
const { importPKCS8, importJWK, exportPKCS8, SignJWT } = require('jose');
3+
const {
4+
importPKCS8,
5+
importJWK,
6+
exportPKCS8,
7+
SignJWT,
8+
compactDecrypt,
9+
} = require('jose');
410
const pkg = require('../package.json');
511
const debug = require('./debug')('client');
612

@@ -308,7 +314,45 @@ async function buildRequestObject(authParams, config, audience) {
308314
.sign(key);
309315
}
310316

317+
// Key-management ("alg") algorithms accepted for access-token decryption when the
318+
// developer has not pinned one. Deliberately excludes RSA1_5 (PKCS#1 v1.5), which
319+
// is vulnerable to Bleichenbacher padding-oracle attacks. jose enforces this list
320+
// against the JWE header before the key is imported, so a token cannot downgrade
321+
// us to a weak or unexpected algorithm.
322+
const ALLOWED_ACCESS_TOKEN_JWE_ALGS = [
323+
'RSA-OAEP',
324+
'RSA-OAEP-256',
325+
'RSA-OAEP-384',
326+
'RSA-OAEP-512',
327+
'ECDH-ES',
328+
'ECDH-ES+A128KW',
329+
'ECDH-ES+A192KW',
330+
'ECDH-ES+A256KW',
331+
];
332+
333+
/**
334+
* Decrypts a JWE-encrypted access token.
335+
* Co-located here so it can call the internal importPrivateKey helper directly.
336+
*
337+
* The key-management algorithm is taken from the JWE protected header (so the tenant
338+
* can encrypt with e.g. RSA-OAEP-256 or RSA-OAEP-512 without the client pre-declaring
339+
* it) but ONLY after jose validates it against an allowlist of strong algorithms.
340+
* This prevents algorithm-confusion / downgrade attacks (notably RSA1_5). When the
341+
* developer sets `accessTokenDecryptionAlg`, the allowlist is narrowed to that one
342+
* value, turning it into a hard pin.
343+
*/
344+
async function decryptAccessToken(token, keyData, alg) {
345+
const keyManagementAlgorithms = alg ? [alg] : ALLOWED_ACCESS_TOKEN_JWE_ALGS;
346+
const { plaintext } = await compactDecrypt(
347+
token,
348+
(header) => importPrivateKey(keyData, header.alg),
349+
{ keyManagementAlgorithms },
350+
);
351+
return Buffer.from(plaintext).toString('utf8');
352+
}
353+
311354
exports.buildRequestObject = buildRequestObject;
355+
exports.decryptAccessToken = decryptAccessToken;
312356

313357
exports.get = (config) => {
314358
const { discoveryCacheMaxAge: cacheMaxAge } = config;

lib/config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,24 @@ const paramsSchema = Joi.object({
371371
otherwise: Joi.string().optional(),
372372
}),
373373
requestObjectSigningKeyId: Joi.string().optional(),
374+
accessTokenDecryptionKey: Joi.any()
375+
.optional()
376+
.when(
377+
Joi.ref('authorizationParams.response_type', {
378+
adjust: (v) => v && !v.includes('code'),
379+
}),
380+
{
381+
is: true,
382+
then: Joi.any().forbidden().messages({
383+
'any.unknown':
384+
'"accessTokenDecryptionKey" requires a code flow. Set authorizationParams.response_type to "code" or "code id_token".',
385+
}),
386+
},
387+
),
388+
// Optional pin. When omitted, the key-management alg is read from the JWE
389+
// protected header, so decryption works with whatever alg the tenant uses
390+
// (e.g. RSA-OAEP-256 or RSA-OAEP-512). When set, the token's alg must match.
391+
accessTokenDecryptionAlg: Joi.string().optional(),
374392
discoveryCacheMaxAge: Joi.number()
375393
.optional()
376394
.min(0)

lib/context.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
get: getClient,
1919
buildEndSessionUrl,
2020
buildRequestObject,
21+
decryptAccessToken,
2122
client: oidcClient,
2223
} = require('./client');
2324
const { encodeState, decodeState } = require('../lib/hooks/getLoginState');
@@ -224,11 +225,34 @@ async function refresh({ tokenEndpointParams } = {}) {
224225
parameters,
225226
);
226227

228+
// Decrypt the refreshed access token when JWE decryption is configured, so the
229+
// stored token and req.oidc.accessToken remain plaintext JWTs after a refresh —
230+
// consistent with the callback. Without this, encrypted tokens would revert to
231+
// ciphertext on the first refresh.
232+
let refreshedAccessToken = newTokenSet.access_token;
233+
if (config.accessTokenDecryptionKey && refreshedAccessToken) {
234+
try {
235+
refreshedAccessToken = await decryptAccessToken(
236+
refreshedAccessToken,
237+
config.accessTokenDecryptionKey,
238+
config.accessTokenDecryptionAlg,
239+
);
240+
} catch (error) {
241+
throw createError(
242+
500,
243+
`Failed to decrypt the refreshed access token: ${error.message}. ` +
244+
'Check that accessTokenDecryptionKey matches the key configured on the ' +
245+
'authorization server, and that accessTokenDecryptionAlg (if set) matches ' +
246+
'the JWE "alg".',
247+
);
248+
}
249+
}
250+
227251
// Update the session, preserving sessionExpiresAt — the refresh-token grant
228252
// does not return a session_expiry claim, so we must not overwrite the ceiling
229253
// that was set at login.
230254
Object.assign(session, {
231-
access_token: newTokenSet.access_token,
255+
access_token: refreshedAccessToken,
232256
// If no new ID token assume the current ID token is valid.
233257
id_token: newTokenSet.id_token || oldTokenSet.id_token,
234258
// If no new refresh token assume the current refresh token is valid.
@@ -852,6 +876,28 @@ class ResponseContext {
852876
: undefined,
853877
};
854878

879+
if (config.accessTokenDecryptionKey && session.access_token) {
880+
try {
881+
session.access_token = await decryptAccessToken(
882+
session.access_token,
883+
config.accessTokenDecryptionKey,
884+
config.accessTokenDecryptionAlg,
885+
);
886+
} catch (error) {
887+
// Surface a clear, actionable error instead of a raw crypto stack trace.
888+
// The most common causes are a key that does not match the tenant's
889+
// encryption key, or an accessTokenDecryptionAlg that does not match the
890+
// alg the token was encrypted with.
891+
throw createError(
892+
500,
893+
`Failed to decrypt the access token: ${error.message}. ` +
894+
'Check that accessTokenDecryptionKey matches the key configured on the ' +
895+
'authorization server, and that accessTokenDecryptionAlg (if set) matches ' +
896+
'the JWE "alg".',
897+
);
898+
}
899+
}
900+
855901
// Must store the `sid` separately as the ID Token gets overridden by
856902
// ID Token from the Refresh Grant which may not contain a sid (In Auth0 currently).
857903
session.sid = claims?.sid;

0 commit comments

Comments
 (0)