Skip to content

Commit 95908d1

Browse files
committed
fix(jwe): pass non-JWE tokens through and constrain the decryption alg pin
Address review feedback on JWE: - decryptAccessToken now returns non-JWE tokens (not five compact parts) unchanged instead of throwing. Toggling Token Encryption off, a mid-rollout mismatch, or an opaque-token audience no longer turns every login and refresh into a hard 500. - Constrain accessTokenDecryptionAlg to the strong-algorithm allowlist at the config layer (defense in depth), mirroring the runtime allowlist. - Add tests: an off-allowlist alg (A128KW) is rejected, non-JWE and opaque tokens pass through, and the clear-error path now uses a genuine JWE with a wrong key.
1 parent 8bb1d42 commit 95908d1

4 files changed

Lines changed: 108 additions & 28 deletions

File tree

lib/client.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -341,14 +341,23 @@ const ALLOWED_ACCESS_TOKEN_JWE_ALGS = [
341341
* Decrypts a JWE-encrypted access token.
342342
* Co-located here so it can call the internal importPrivateKey helper directly.
343343
*
344-
* The key-management algorithm is taken from the JWE protected header (so the tenant
345-
* can encrypt with e.g. RSA-OAEP-256 or RSA-OAEP-512 without the client pre-declaring
346-
* it) but ONLY after jose validates it against an allowlist of strong algorithms.
347-
* This prevents algorithm-confusion / downgrade attacks (notably RSA1_5). When the
348-
* developer sets `accessTokenDecryptionAlg`, the allowlist is narrowed to that one
349-
* value, turning it into a hard pin.
344+
* A JWE in compact serialization has exactly five dot-separated parts. Anything
345+
* else (a plaintext 3-part JWT, an opaque token, etc.) is returned unchanged, so
346+
* that toggling Token Encryption off, a mid-rollout mismatch, or an audience that
347+
* yields an opaque token does not turn every login and refresh into a hard error.
348+
*
349+
* For genuine JWEs the key-management algorithm is taken from the protected header
350+
* (so the tenant can encrypt with e.g. RSA-OAEP-256 or RSA-OAEP-512 without the
351+
* client pre-declaring it) but ONLY after jose validates it against an allowlist of
352+
* strong algorithms. This prevents algorithm-confusion / downgrade attacks (notably
353+
* RSA1_5). When the developer sets `accessTokenDecryptionAlg`, the allowlist is
354+
* narrowed to that one value, turning it into a hard pin.
350355
*/
351356
async function decryptAccessToken(token, keyData, alg) {
357+
// Not a compact JWE (five parts) — pass through untouched.
358+
if (typeof token !== 'string' || token.split('.').length !== 5) {
359+
return token;
360+
}
352361
const keyManagementAlgorithms = alg ? [alg] : ALLOWED_ACCESS_TOKEN_JWE_ALGS;
353362
const { plaintext } = await compactDecrypt(
354363
token,

lib/config.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ const ASYMMETRIC_SIGNING_ALGS = [
5050
'Ed25519',
5151
];
5252

53+
// Strong JWE key-management algorithms accepted for access-token decryption.
54+
// Mirrors ALLOWED_ACCESS_TOKEN_JWE_ALGS in lib/client.js; kept here so an explicit
55+
// accessTokenDecryptionAlg pin is validated at config time (defense in depth).
56+
const ACCESS_TOKEN_JWE_ALGS = [
57+
'RSA-OAEP',
58+
'RSA-OAEP-256',
59+
'RSA-OAEP-384',
60+
'RSA-OAEP-512',
61+
'ECDH-ES',
62+
'ECDH-ES+A128KW',
63+
'ECDH-ES+A192KW',
64+
'ECDH-ES+A256KW',
65+
];
66+
5367
const paramsSchema = Joi.object({
5468
secret: Joi.alternatives([
5569
Joi.string().min(8),
@@ -390,8 +404,11 @@ const paramsSchema = Joi.object({
390404
),
391405
// Optional pin. When omitted, the key-management alg is read from the JWE
392406
// protected header, so decryption works with whatever alg the tenant uses
393-
// (e.g. RSA-OAEP-256 or RSA-OAEP-512). When set, the token's alg must match.
394-
accessTokenDecryptionAlg: Joi.string().optional(),
407+
// (e.g. RSA-OAEP-256 or RSA-OAEP-512). When set, the token's alg must match,
408+
// and the pin itself is constrained to the strong-algorithm allowlist.
409+
accessTokenDecryptionAlg: Joi.string()
410+
.valid(...ACCESS_TOKEN_JWE_ALGS)
411+
.optional(),
395412
discoveryCacheMaxAge: Joi.number()
396413
.optional()
397414
.min(0)

test/callback.tests.js

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1862,16 +1862,25 @@ describe('callback response_mode: form_post', () => {
18621862
assert.equal(tokens.accessToken.access_token, plainAccessToken);
18631863
});
18641864

1865-
it('should surface a clear error when decryption fails (wrong key/token)', async () => {
1866-
const notAJwe = '__test_access_token__';
1865+
it('should surface a clear error when decryption fails (wrong key)', async () => {
1866+
// A genuine JWE (5 parts) encrypted to the fixture public key, but the SDK is
1867+
// configured with a different, non-matching private key -> decryption fails.
1868+
const jwe = await encryptToken(
1869+
'eyJhbGciOiJSUzI1NiJ9.body.sig',
1870+
'RSA-OAEP-256',
1871+
);
1872+
const { generateKeyPairSync } = require('crypto');
1873+
const wrongKey = generateKeyPairSync('rsa', {
1874+
modulusLength: 2048,
1875+
}).privateKey.export({ type: 'pkcs8', format: 'pem' });
18671876
const validToken = makeIdToken({ c_hash: '77QmUPtjPfzWtF2AnpK9RQ' });
18681877

18691878
const { response } = await setup({
1870-
accessToken: notAJwe,
1879+
accessToken: jwe,
18711880
authOpts: {
18721881
...defaultConfig,
18731882
clientSecret: '__test_client_secret__',
1874-
accessTokenDecryptionKey: privatePEM,
1883+
accessTokenDecryptionKey: wrongKey,
18751884
authorizationParams: { response_type: 'code id_token' },
18761885
},
18771886
cookies: generateCookies({
@@ -1892,6 +1901,32 @@ describe('callback response_mode: form_post', () => {
18921901
);
18931902
});
18941903

1904+
it('should pass a plaintext (non-JWE) access token through unchanged', async () => {
1905+
const validToken = makeIdToken({ c_hash: '77QmUPtjPfzWtF2AnpK9RQ' });
1906+
1907+
const { tokens } = await setup({
1908+
accessToken: '__test_access_token__', // opaque, not a JWE
1909+
authOpts: {
1910+
...defaultConfig,
1911+
clientSecret: '__test_client_secret__',
1912+
accessTokenDecryptionKey: privatePEM,
1913+
authorizationParams: { response_type: 'code id_token' },
1914+
},
1915+
cookies: generateCookies({
1916+
state: expectedDefaultState,
1917+
nonce: '__test_nonce__',
1918+
}),
1919+
body: {
1920+
state: expectedDefaultState,
1921+
id_token: validToken,
1922+
code: 'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y',
1923+
},
1924+
});
1925+
1926+
// No hard 500; the opaque token is stored unchanged.
1927+
assert.equal(tokens.accessToken.access_token, '__test_access_token__');
1928+
});
1929+
18951930
it('should decrypt the refreshed access token (refresh grant, not just callback)', async () => {
18961931
const loginPlain = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.login.sig';
18971932
const refreshedPlain =

test/client.tests.js

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -907,29 +907,26 @@ describe('client initialization', function () {
907907
assert.equal(decrypted, plaintext);
908908
});
909909

910-
it('should throw when given a non-JWE string', async function () {
910+
it('should pass a 3-part JWT (non-JWE) through unchanged', async function () {
911911
const plainJwt = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test.signature';
912912

913-
await assert.isRejected(
914-
decryptAccessToken(
915-
plainJwt,
916-
require('../end-to-end/fixture/jwk').privatePEM,
917-
'RSA-OAEP-256',
918-
),
913+
const result = await decryptAccessToken(
914+
plainJwt,
915+
require('../end-to-end/fixture/jwk').privatePEM,
916+
'RSA-OAEP-256',
919917
);
918+
assert.equal(result, plainJwt);
920919
});
921920

922-
it('should throw when decryption fails with invalid JWE', async function () {
923-
const plainJwt = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9';
921+
it('should pass a non-JWE opaque string through unchanged', async function () {
922+
const opaque = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9';
924923

925-
// Try to decrypt something that's not a valid JWE
926-
await assert.isRejected(
927-
decryptAccessToken(
928-
plainJwt,
929-
require('../end-to-end/fixture/jwk').privatePEM,
930-
'RSA-OAEP-256',
931-
),
924+
const result = await decryptAccessToken(
925+
opaque,
926+
require('../end-to-end/fixture/jwk').privatePEM,
927+
'RSA-OAEP-256',
932928
);
929+
assert.equal(result, opaque);
933930
});
934931

935932
// The alg is read from the JWE header (within a strong-alg allowlist) when no
@@ -969,5 +966,27 @@ describe('client initialization', function () {
969966
decryptAccessToken(jwe, privatePEM, 'RSA-OAEP-256'),
970967
);
971968
});
969+
970+
it('should reject a JWE whose alg is not on the allowlist', async function () {
971+
// A128KW is a valid JWE alg but deliberately excluded from the allowlist.
972+
// The key resolver (importPrivateKey) must never run for a rejected alg.
973+
const symKey = new Uint8Array(16); // 128-bit key for A128KW
974+
const jwe = await new CompactEncrypt(new TextEncoder().encode(plaintext))
975+
.setProtectedHeader({ alg: 'A128KW', enc: 'A128CBC-HS256' })
976+
.encrypt(symKey);
977+
await assert.isRejected(decryptAccessToken(jwe, privatePEM, undefined));
978+
});
979+
980+
it('should pass a non-JWE (plaintext JWT) token through unchanged', async function () {
981+
const jwt = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.sig'; // 3 parts, not a JWE
982+
const result = await decryptAccessToken(jwt, privatePEM, undefined);
983+
assert.equal(result, jwt);
984+
});
985+
986+
it('should pass an opaque access token through unchanged', async function () {
987+
const opaque = 'opaque-access-token-value';
988+
const result = await decryptAccessToken(opaque, privatePEM, undefined);
989+
assert.equal(result, opaque);
990+
});
972991
});
973992
});

0 commit comments

Comments
 (0)