Skip to content

feat(jwe): decrypt JWE-encrypted access tokens#864

Open
jd3vi1 wants to merge 3 commits into
feature/jarfrom
feature/jwe
Open

feat(jwe): decrypt JWE-encrypted access tokens#864
jd3vi1 wants to merge 3 commits into
feature/jarfrom
feature/jwe

Conversation

@jd3vi1

@jd3vi1 jd3vi1 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds decryption of JWE-encrypted access tokens. When an API is configured to encrypt access tokens, the token returned at the callback is a JWE. With accessTokenDecryptionKey set, the SDK decrypts it before writing it to the session, so req.oidc.accessToken and afterCallback always receive a plaintext JWT with no change to consuming code.

Stacked on #863 (JAR). Review after that merges; the base will retarget to master.

What's included

  • accessTokenDecryptionKey — private key for decryption. Accepts PEM string, Buffer, KeyObject, JWK, or CryptoKey. Requires a code flow (implicit flow returns no access token from the token endpoint).
  • accessTokenDecryptionAlg — optional pin for the key-management algorithm.
  • Decryption runs at both the callback and on token refresh, so tokens stay plaintext across refreshes for apps using offline_access.
  • Decryption happens before afterCallback fires, so hooks receive a usable token.
  • The key-management algorithm is read from the JWE protected header (so RSA-OAEP-256, RSA-OAEP-512, etc. all work without pre-declaring), but only after jose validates it against an allowlist of strong algorithms. This avoids a hardcoded default that breaks other algorithms while preventing algorithm-confusion/downgrade (notably RSA1_5). accessTokenDecryptionAlg, when set, narrows the allowlist to that one value.
  • Decryption failures surface a clear, actionable error instead of a raw crypto stack trace.

Usage

app.use(
  auth({
    authorizationParams: {
      response_type: 'code',
      audience: 'https://your-api/',
      scope: 'openid profile email offline_access',
    },
    accessTokenDecryptionKey: fs.readFileSync('./api-decryption-key.pem'),
    // accessTokenDecryptionAlg: 'RSA-OAEP-512', // optional pin
  }),
);

Tests

  • Unit tests for decryptAccessToken (header auto-detect for RSA-OAEP-256/512, pin match/mismatch, non-JWE input), config validation (code-flow requirement), and end-to-end callback + refresh decryption with a clear-error case.
  • Type tests in index.test-d.ts.
  • Documentation in EXAMPLES.md and an example at examples/jwe.js.

Comment thread examples/jwe.js Dismissed
Comment thread test/callback.tests.js Fixed
Comment thread test/client.tests.js Fixed
Comment thread test/client.tests.js Fixed
Comment thread test/client.tests.js Fixed
jd3vi1 added 2 commits July 27, 2026 15:55
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.
…g 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.
@jd3vi1

jd3vi1 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both majors in 95908d1:

  1. Non-JWE tokens no longer 500. decryptAccessToken now returns the token unchanged when it is not a compact JWE (five dot-separated parts), so a plaintext JWT, an opaque token, or Token Encryption being toggled off / mid-rollout passes through instead of failing every login and refresh. Genuine JWEs still go through the allowlist-gated decrypt path.
  2. Weak-alg rejection test added. New test encrypts with an off-allowlist A128KW and asserts it is rejected before the key resolver runs. Also added pass-through tests for non-JWE and opaque tokens, and reworked the clear-error test to use a genuine JWE with a non-matching key (since non-JWEs no longer error).

Minor: accessTokenDecryptionAlg is now constrained to the allowlist at the config layer (Joi.string().valid(...)) for defense in depth.

Full suite and tsd green.

Drop unused exportSPKI, privateJWK, privateKeyPem, fs, path bindings in the
decryptAccessToken test block and an unused jar binding in the callback test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants