Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
13. [Use a proxy for OIDC requests](#13-use-a-proxy-for-oidc-requests)
14. [Session expiry from upstream IdP (IPSIE `session_expiry`)](#14-session-expiry-from-upstream-idp-ipsie-session_expiry)
15. [JWT-Secured Authorization Requests (JAR)](#15-jwt-secured-authorization-requests-jar)
16. [Decrypt JWE-encrypted access tokens](#16-decrypt-jwe-encrypted-access-tokens)

## 1. Basic setup

Expand Down Expand Up @@ -567,3 +568,28 @@ app.use(
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).

Full example at [jar.js](./examples/jar.js), to run it: `npm run start:example -- jar`

## 16. Decrypt JWE-encrypted access tokens

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.

```js
app.use(
auth({
authorizationParams: {
response_type: 'code', // requires a client secret; JWE requires a code flow
audience: 'https://your-api/',
scope: 'openid profile email offline_access',
},
// Private key matching the public key uploaded to the API's Token Encryption
// settings. Accepts PEM string, Buffer, KeyObject, JWK, or CryptoKey.
accessTokenDecryptionKey: fs.readFileSync('./api-decryption-key.pem'),
// Optional pin. Omit to auto-detect the algorithm from the JWE header.
// accessTokenDecryptionAlg: 'RSA-OAEP-512',
}),
);
```

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.

Full example at [jwe.js](./examples/jwe.js).
58 changes: 58 additions & 0 deletions examples/jwe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const express = require('express');
const { auth } = require('../');

const app = express();

// JWE access token decryption demo.
//
// When Auth0 is configured to encrypt access tokens for an API (Dashboard > APIs >
// your API > Token Encryption), the token returned at the callback is a JWE (five
// dot-separated parts). With `accessTokenDecryptionKey` set, 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 see a plaintext JWT.
//
// The key-management algorithm is read from the JWE protected header (validated
// against a strong-alg allowlist), so decryption works whether the tenant uses
// RSA-OAEP-256, RSA-OAEP-512, etc. Set `accessTokenDecryptionAlg` only to pin one.
//
// Requires a code flow and an API that encrypts tokens, so this example is not
// runnable against the mock provider (it does not encrypt). It shows the config.

app.use(
auth({
authRequired: false,
clientSecret: '__your_client_secret__',
authorizationParams: {
response_type: 'code',
// Request the API whose access tokens are encrypted.
audience: 'https://your-api/',
scope: 'openid profile email offline_access',
},

// Private key matching the public key uploaded to the API's Token Encryption
// settings. Accepts PEM string, Buffer, KeyObject, JWK, or CryptoKey.
accessTokenDecryptionKey: require('fs').readFileSync(
'./api-decryption-key.pem',
),
// Optional pin. Omit to auto-detect the alg from the JWE header.
// accessTokenDecryptionAlg: 'RSA-OAEP-512',
}),
);

app.get('/', (req, res) => {
if (req.oidc.isAuthenticated()) {
const token = req.oidc.accessToken?.access_token;
// A decrypted token is a normal 3-part JWT; a 5-part value means it is still
// encrypted (decryption key not active).
const parts = token ? token.split('.').length : 0;
res.send(
`<p>Logged in as <strong>${req.oidc.user.sub}</strong></p>` +
`<p>Access token parts: ${parts} (3 = decrypted JWT)</p>` +
`<a href="/logout">logout</a>`,
);
} else {
res.send('<a href="/login">login</a>');
}
});
Comment thread
jd3vi1 marked this conversation as resolved.
Dismissed

module.exports = app;
16 changes: 16 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,22 @@ interface ConfigParams {
*/
requestObjectSigningKeyId?: string;

/**
* Private key used to decrypt JWE-encrypted access tokens.
* When set, the SDK decrypts the access token at the callback and on refresh
* before writing it to the session, so `req.oidc.accessToken` and `afterCallback`
* always receive a plaintext JWT. Requires a code flow (`response_type: 'code'`
* or `'code id_token'`). Accepts PEM string, Buffer, KeyObject, JWK, or CryptoKey.
*/
accessTokenDecryptionKey?: KeyObject | JoseCryptoKey | JWK | string | Buffer;

/**
* Optional pin for the JWE key-management algorithm. When omitted, the algorithm
* is read from the JWE protected header (validated against a strong-algorithm
* allowlist), so decryption works with whatever alg the tenant uses.
*/
accessTokenDecryptionAlg?: string;

/**
* Additional request body properties to be sent to the `token_endpoint` during authorization code exchange or token refresh.
*/
Expand Down
8 changes: 8 additions & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,11 @@ expectType<RequestHandler>(
requestObjectSigningKeyId: 'kid-1',
}),
);

// JWE access token decryption
expectType<RequestHandler>(
auth({
accessTokenDecryptionKey: '-----BEGIN PRIVATE KEY-----',
accessTokenDecryptionAlg: 'RSA-OAEP-512',
}),
);
55 changes: 54 additions & 1 deletion lib/client.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
const crypto = require('crypto');
const client = require('openid-client');
const { importPKCS8, importJWK, exportPKCS8, SignJWT } = require('jose');
const {
importPKCS8,
importJWK,
exportPKCS8,
SignJWT,
compactDecrypt,
} = require('jose');
const pkg = require('../package.json');
const debug = require('./debug')('client');

Expand Down Expand Up @@ -315,7 +321,54 @@ async function buildRequestObject(authParams, config, audience) {
.sign(key);
}

// Key-management ("alg") algorithms accepted for access-token decryption when the
// developer has not pinned one. Deliberately excludes RSA1_5 (PKCS#1 v1.5), which
// is vulnerable to Bleichenbacher padding-oracle attacks. jose enforces this list
// against the JWE header before the key is imported, so a token cannot downgrade
// us to a weak or unexpected algorithm.
const ALLOWED_ACCESS_TOKEN_JWE_ALGS = [
'RSA-OAEP',
'RSA-OAEP-256',
'RSA-OAEP-384',
'RSA-OAEP-512',
'ECDH-ES',
'ECDH-ES+A128KW',
'ECDH-ES+A192KW',
'ECDH-ES+A256KW',
];

/**
* Decrypts a JWE-encrypted access token.
* Co-located here so it can call the internal importPrivateKey helper directly.
*
* A JWE in compact serialization has exactly five dot-separated parts. Anything
* else (a plaintext 3-part JWT, an opaque token, etc.) is returned unchanged, so
* that toggling Token Encryption off, a mid-rollout mismatch, or an audience that
* yields an opaque token does not turn every login and refresh into a hard error.
*
* For genuine JWEs the key-management algorithm is taken from the protected header
* (so the tenant can encrypt with e.g. RSA-OAEP-256 or RSA-OAEP-512 without the
* client pre-declaring it) but ONLY after jose validates it against an allowlist of
* strong algorithms. This prevents algorithm-confusion / downgrade attacks (notably
* RSA1_5). When the developer sets `accessTokenDecryptionAlg`, the allowlist is
* narrowed to that one value, turning it into a hard pin.
*/
async function decryptAccessToken(token, keyData, alg) {
// Not a compact JWE (five parts) — pass through untouched.
if (typeof token !== 'string' || token.split('.').length !== 5) {
return token;
}
const keyManagementAlgorithms = alg ? [alg] : ALLOWED_ACCESS_TOKEN_JWE_ALGS;
const { plaintext } = await compactDecrypt(
token,
(header) => importPrivateKey(keyData, header.alg),
{ keyManagementAlgorithms },
);
return Buffer.from(plaintext).toString('utf8');
}

exports.buildRequestObject = buildRequestObject;
exports.decryptAccessToken = decryptAccessToken;

exports.get = (config) => {
const { discoveryCacheMaxAge: cacheMaxAge } = config;
Expand Down
35 changes: 35 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ const ASYMMETRIC_SIGNING_ALGS = [
'Ed25519',
];

// Strong JWE key-management algorithms accepted for access-token decryption.
// Mirrors ALLOWED_ACCESS_TOKEN_JWE_ALGS in lib/client.js; kept here so an explicit
// accessTokenDecryptionAlg pin is validated at config time (defense in depth).
const ACCESS_TOKEN_JWE_ALGS = [
'RSA-OAEP',
'RSA-OAEP-256',
'RSA-OAEP-384',
'RSA-OAEP-512',
'ECDH-ES',
'ECDH-ES+A128KW',
'ECDH-ES+A192KW',
'ECDH-ES+A256KW',
];

const paramsSchema = Joi.object({
secret: Joi.alternatives([
Joi.string().min(8),
Expand Down Expand Up @@ -374,6 +388,27 @@ const paramsSchema = Joi.object({
otherwise: Joi.string().optional(),
}),
requestObjectSigningKeyId: Joi.string().optional(),
accessTokenDecryptionKey: Joi.any()
.optional()
.when(
Joi.ref('authorizationParams.response_type', {
adjust: (v) => v && !v.includes('code'),
}),
{
is: true,
then: Joi.any().forbidden().messages({
'any.unknown':
'"accessTokenDecryptionKey" requires a code flow. Set authorizationParams.response_type to "code" or "code id_token".',
}),
},
),
// Optional pin. When omitted, the key-management alg is read from the JWE
// protected header, so decryption works with whatever alg the tenant uses
// (e.g. RSA-OAEP-256 or RSA-OAEP-512). When set, the token's alg must match,
// and the pin itself is constrained to the strong-algorithm allowlist.
accessTokenDecryptionAlg: Joi.string()
.valid(...ACCESS_TOKEN_JWE_ALGS)
.optional(),
discoveryCacheMaxAge: Joi.number()
.optional()
.min(0)
Expand Down
48 changes: 47 additions & 1 deletion lib/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
get: getClient,
buildEndSessionUrl,
buildRequestObject,
decryptAccessToken,
client: oidcClient,
} = require('./client');
const { encodeState, decodeState } = require('../lib/hooks/getLoginState');
Expand Down Expand Up @@ -224,11 +225,34 @@ async function refresh({ tokenEndpointParams } = {}) {
parameters,
);

// Decrypt the refreshed access token when JWE decryption is configured, so the
// stored token and req.oidc.accessToken remain plaintext JWTs after a refresh —
// consistent with the callback. Without this, encrypted tokens would revert to
// ciphertext on the first refresh.
let refreshedAccessToken = newTokenSet.access_token;
if (config.accessTokenDecryptionKey && refreshedAccessToken) {
try {
refreshedAccessToken = await decryptAccessToken(
refreshedAccessToken,
config.accessTokenDecryptionKey,
config.accessTokenDecryptionAlg,
);
} catch (error) {
throw createError(
500,
`Failed to decrypt the refreshed access token: ${error.message}. ` +
'Check that accessTokenDecryptionKey matches the key configured on the ' +
'authorization server, and that accessTokenDecryptionAlg (if set) matches ' +
'the JWE "alg".',
);
}
}

// Update the session, preserving sessionExpiresAt — the refresh-token grant
// does not return a session_expiry claim, so we must not overwrite the ceiling
// that was set at login.
Object.assign(session, {
access_token: newTokenSet.access_token,
access_token: refreshedAccessToken,
// If no new ID token assume the current ID token is valid.
id_token: newTokenSet.id_token || oldTokenSet.id_token,
// If no new refresh token assume the current refresh token is valid.
Expand Down Expand Up @@ -852,6 +876,28 @@ class ResponseContext {
: undefined,
};

if (config.accessTokenDecryptionKey && session.access_token) {
try {
session.access_token = await decryptAccessToken(
session.access_token,
config.accessTokenDecryptionKey,
config.accessTokenDecryptionAlg,
);
} catch (error) {
// Surface a clear, actionable error instead of a raw crypto stack trace.
// The most common causes are a key that does not match the tenant's
// encryption key, or an accessTokenDecryptionAlg that does not match the
// alg the token was encrypted with.
throw createError(
500,
`Failed to decrypt the access token: ${error.message}. ` +
'Check that accessTokenDecryptionKey matches the key configured on the ' +
'authorization server, and that accessTokenDecryptionAlg (if set) matches ' +
'the JWE "alg".',
);
}
}

// Must store the `sid` separately as the ID Token gets overridden by
// ID Token from the Refresh Grant which may not contain a sid (In Auth0 currently).
session.sid = claims?.sid;
Expand Down
Loading
Loading