Skip to content

migration: openid-client and jose migration#785

Merged
cschetan77 merged 33 commits into
masterfrom
migration/openid-clientv6
May 12, 2026
Merged

migration: openid-client and jose migration#785
cschetan77 merged 33 commits into
masterfrom
migration/openid-clientv6

Conversation

@aks96

@aks96 aks96 commented Feb 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Upgrades openid-client from v4 to v6 and jose from v2 to v6. These are now ESM-only packages, which drives the Node.js version requirement change.
The core middleware API, session handling, and all authentication methods remain unchanged.

Breaking Changes

See V3_MIGRATION_GUIDE.md for before/after examples and migration steps for each change.

  1. Node.js version — requires ^20.19.0 || ^22.12.0 || >= 23.0.0
  2. httpAgent config removed — replaced by customFetch. Passing httpAgent throws at startup.
  3. clientAssertionSigningAlg now required — the implicit RS256 default has been removed. Required when clientAssertionSigningKey is a PEM, Buffer, KeyObject, or JWK without an alg property.
  4. ES256K and EdDSA removed from valid clientAssertionSigningAlg values. Use Ed25519 instead of EdDSA. ES256K has no replacement.
  5. afterCallback behavior changereq.oidc now reflects the incoming user's new tokens during the callback, not the previous session state.
  6. Session cookie dropped on streaming responses — in v2, the session cookie was injected just before headers were flushed, so it worked even on streaming responses. In v3, the cookie is written at res.end() time instead. If your route calls res.write(), res.flushHeaders(), or res.writeHead() before the response ends (e.g. SSE, chunked responses), the session cookie will be silently skipped. If you rely on token rotation, avoid using it on streaming routes.
  7. clientAssertionSigningKey type updated — if you use TypeScript, the accepted type for clientAssertionSigningKey has changed to align with jose v6 and the Web Crypto API. In practice, all previously supported key formats (PEM string, Buffer, KeyObject, JWK) still work at runtime — only the TypeScript type definition has changed

Testing

Manual testing against a real Auth0 tenant

Scenario Flow
Basic login flow Discovery, token exchange, callback URL handling, session cookie written correctly
customFetch wiring Called for both discovery and token endpoints, confirmed via logging
httpAgent rejection Throws TypeError: "httpAgent" is not allowed at startup before the app boots
Private key JWT with PEM + clientAssertionSigningAlg: 'RS256' Full login succeeds using signed client_assertion JWT
Private key JWT without clientAssertionSigningAlg Throws at startup
afterCallback new behavior req.oidc.user reflects the incoming user's tokens, confirmed sub matches the new ID token
Streaming response (res.writeHead before res.end) No crash, session cookie silently skipped.

Unit tests

Unit tests have been updated to reflect the new API and behaviour.

End-to-end tests

The existing end-to-end test suite covers the full login/logout flow, token refresh, backchannel logout (basic, re-login after logout, custom genid, custom query store), and custom session store scenarios using a local OIDC provider.

@aks96 aks96 requested a review from a team as a code owner February 9, 2026 12:39
Comment thread test/client.tests.js Fixed
Comment thread test/client.tests.js Fixed
Comment thread V3_MIGRATION_GUIDE.md
Comment thread lib/context.js Outdated
Comment thread lib/context.js Outdated
Comment thread lib/client.js
Comment thread lib/client.js Outdated
Comment thread lib/client.js Outdated
Comment thread lib/client.js Outdated
Comment thread lib/client.js Outdated
Comment thread lib/crypto.js Outdated
Comment thread lib/appSession.js
@aks96 aks96 force-pushed the migration/openid-clientv6 branch from 32b378e to 56b8799 Compare April 7, 2026 18:23
@aks96 aks96 requested a review from cschetan77 April 8, 2026 06:05
Comment thread lib/appSession.js Outdated
Comment thread lib/appSession.js Outdated
Comment thread lib/appSession.js
Comment thread lib/context.js Outdated
Comment thread lib/context.js Outdated
Comment thread lib/context.js Outdated
Comment thread lib/context.js Outdated
Comment thread lib/context.js Outdated
Comment thread lib/context.js Outdated
const { client, issuer } = await getClient(config);
const redirectUri = options.redirectUri || this.getRedirectUri();
const { configuration } = await getClient(config);
// Note: In openid-client v6, redirect_uri is automatically extracted from the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redirect_uri is still part of the public API and the typedocs still show res.oidc.callback({ redirectUri: ... }).

If we now always derive the redirect URI from the current request URL, apps with custom callback routes or multiple domains may send a different redirect_uri to the token endpoint than the one used during login.

If this is intentional, can we call it out as a breaking change and update the types/docs ?

Comment thread lib/context.js Outdated
// Build host with port if needed
const host = needsPort ? `${hostname}:${port}` : hostname;

const currentUrl = new URL(`${protocol}://${host}${req.originalUrl}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, we should not drop options.redirectUri support here. Even though openid-client v6 can infer the redirect_uri from the URL passed to authorizationCodeGrant(), this SDK can still decide which URL to pass in.

To preserve the existing express-openid-connect API, we should use options.redirectUri when it is supplied, and only infer the callback URL from the request when it is not.

Otherwise, existing apps that rely on res.oidc.callback({ redirectUri }) may break silently, even though the option is still documented and typed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I completely agree, the support for options.redirectUri should not have been dropped. I already pointed this out earlier, I guess this got missed to address - #785 (comment)
pre-v6, the redirectUri was constructed from either options.redirectUri or using getRedirectUri method and explicitly passed to openid-client's callback method.
Have restored the same behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated:

  1. Removed the comment that said options.redirectUri is no longer used
  2. Added callbackUrl construction after currentUrl is built, restoring the options.redirectUri || this.getRedirectUri() fallback chain as earlier.
  3. The request obj passed to oidcClient has this callcackUrl instead of original request's url

Comment thread lib/appSession.js

if (isCustomStore) {
const id = existingSessionValue || (await generateId(req));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add coverage for responses that flush headers before res.end() ?. Since session cookies are now written from the res.end wrapper, setCookie() may skip writing the cookie when res.headersSent is already true.

This can happen for streaming responses or routes that call res.write() before res.end(). Previously, on-headers wrote cookies right before headers were sent, so this may be a behavior change where session updates are silently lost.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, this is a limitation introduced by removing on-headers. It can all be attributed to jose-v6 async encryption, which makes our setCookie async too. on-headers only supports synchronous callbacks, so it can no longer be used to write the cookie right before headers are flushed.

I've made the failure explicit with a debug log when headersSent is already true, covering not just streaming responses but any scenario where headers are flushed early (res.write(), res.writeHead(), res.flushHeaders(), res.sendFile()).

As per your suggestion I've also added a test that properly documents and asserts this behavior.

Comment thread lib/client.js Outdated
);
}

return fetch(fetchUrl, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

httpAgent is still listed as a public configuration option, but this new fetch implementation doesn’t seem to use it anymore. Some users might depend on it for things like proxies, custom TLS settings, or controlling network access.

Can we either make sure httpAgent is used in this new fetch logic, or clearly document this as a breaking change and update the types and docs?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, support for passing httpAgent has been removed.

The core issue is that httpAgent worked because got (the HTTP library openid-client v4 used internally) had a first-class concept of agents, we could hand it an http.Agent or https.Agent and it would use that for every connection it made.
openid-client v6 replaced got with native fetch, and fetch simply has no equivalent concept, there's no way to pass a Node.js http.Agent into a fetch() call. The two APIs model transport-level control completely differently, so the old option can't be mapped across.

Update:
Reference - PR#787

Instead, we've exposed customFetch as the escape hatch. It gives consumers full control over the underlying HTTP behaviour, whether that's routing through a proxy, pinning a custom TLS certificate, adding retry logic, or just logging outbound requests for debugging. The SDK wraps whatever function is provided to inject required headers (User-Agent, telemetry) before the call goes out, so SDK concerns stay intact regardless of what the consumer does underneath. For the proxy case specifically, undici ships with Node.js 18+ so no extra dependency is needed

On the type side, removed httpAgent from index.d.ts (along with the HttpAgent/HttpsAgent imports) and typed customFetch as typeof fetch, This is broad enough to be compatible with undici and other fetch-compatible implementations.

Also added Example-13 to EXAMPLES.md showing the undici ProxyAgent pattern end-to-end.

For test coverage, added two behavioural tests, one asserting the provided function is actually invoked during discovery, and another confirming the SDK's header decoration still runs before the call reaches the consumer's function.

Comment thread package.json Outdated
"joi": "^17.13.3",
"jose": "^2.0.7",
"jose": "^6.1.3",
"on-headers": "^1.1.0",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this PR no longer uses on-headers, can we remove it from dependencies ?

If we decide to restore on-headers to preserve the old session-cookie timing behavior, then it should stay. Otherwise this looks like a stale dependency after the session persistence refactor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed it from dep list.
I know it was perfect at setting the cookie at right moment but don't think we can bring it back, as it expects a synchronous callback and our setCookie method changed to async.

Comment thread test/client.tests.js Outdated
});

it('should timeout for delay > httpTimeout', async function () {
it.skip('should timeout for delay > httpTimeout', async function () {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid skipping this test ? httpTimeout is still a public config option, so we should keep coverage that requests actually fail when they exceed the configured timeout.

If timeout behavior changed with fetch/openid-client v6, we should update the assertion to match the new error shape rather than skipping the test entirely.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've re enabled and updated the test suite client respects httpTimeout configuration to better cover for all 3 httpTimeout scenarios.

Comment thread test/client.tests.js Outdated
});

describe('client respects clientAssertionSigningAlg configuration', function () {
describe.skip('client respects clientAssertionSigningAlg configuration', function () {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid skipping this test suite ? clientAssertionSigningAlg is still a public option, and this PR changes the private key JWT implementation during the openid-client v6 migration.

We should keep coverage that the configured signing algorithm is actually used, even if the test needs to be rewritten around the v6 grant APIs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've re enabled this test suite and updated the tests to cover -
Test 1: should use RS256 as the default signing algorithm in the client assertion
Test 2: should use the configured signing algorithm in the client assertion
Test 3: should fail when signing algorithm is incompatible with the key type

Comment thread test/client.tests.js Outdated
});

describe('client respects httpAgent configuration', function () {
describe.skip('client respects httpAgent configuration', function () {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid skipping this test suite ?. httpAgent is still a public config option, but this PR no longer appears to pass it into the HTTP layer.

If httpAgent support is intentionally removed because of the fetch migration, we should document it as a breaking change and update the public types/docs.

Otherwise, this test should be rewritten to verify the new implementation still honors httpAgent.

Comment thread package-lock.json Outdated
},
"engines": {
"node": "^10.19.0 || >=12.0.0 < 13 || >=13.7.0 < 14 || >= 14.2.0"
"node": ">=20.0.0"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we keep this in sync with package.json ?

The package declares Node support as ^20.19.0 || ^22.12.0 || >= 23.0.0, but the lockfile root package still says >=20.0.0. Take a look at this:

"node": "^20.19.0 || ^22.12.0 || >= 23.0.0"

Since the migration guide says the exact minimum versions matter for require(ESM) support, the lockfile should reflect the same engine range to avoid confusion.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed it for now,
But we will probably plan the removal of package lock file after after this in a separate PR.

Comment thread index.d.ts Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nandan-bhat

This comment was marked as off-topic.

nandan-bhat
nandan-bhat previously approved these changes May 12, 2026
@nandan-bhat nandan-bhat dismissed their stale review May 12, 2026 06:45

This PR needs further review. I am dismissing my approval.

Comment thread lib/client.js Outdated
const issuerRespTypes = Array.isArray(serverMetadata.response_types_supported)
? serverMetadata.response_types_supported
: [];
issuerRespTypes.map(sortSpaceDelimitedString);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.map() returns a new array and does not change issuerRespTypes. Doesn't this mean the comparison on line 186 always uses the original unsorted values ? For example, wouldn't "code id_token" fail to match "id_token code" ?

Should this be:

const sortedRespTypes = issuerRespTypes.map(sortSpaceDelimitedString);
if (!sortedRespTypes.includes(configRespType)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the comparison should have been made on sortedRespTypes result.
Updated.

Comment thread index.d.ts
* this property will be required.
*/
clientAssertionSigningAlg?:
| 'RS256'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TypeScript type still allows 'ES256K' and 'EdDSA', but the Joi schema in

clientAssertionSigningAlg: Joi.string()
.valid(
'RS256',
'RS384',
'RS512',
'PS256',
'PS384',
'PS512',
'ES256',
'ES384',
'ES512',
'Ed25519',
)
rejects them at runtime.

Should we update the type to match the new valid set ('Ed25519' instead of 'EdDSA', and remove 'ES256K') ?

@cschetan77 cschetan77 May 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, there was a mismatch between Joi schema for clientAssertionSigningAlg and type def file.
This was mistakenly left unchecked while determining breaking changes.
Updated the index.d.ts to reflect the same.

Comment thread lib/tokenset.js Outdated
Comment on lines +23 to +27
this.token_type =
tokenSet.token_type?.toLowerCase() === 'bearer'
? 'Bearer'
: tokenSet.token_type;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

token_type normalization already happened during session write time. This is redundant.

@cschetan77 cschetan77 merged commit 076fb82 into master May 12, 2026
23 of 25 checks passed
@cschetan77 cschetan77 deleted the migration/openid-clientv6 branch May 12, 2026 16:20
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.

5 participants