diff --git a/jest.config.ts b/jest.config.ts index 98298471af..cac914f4c2 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -25,6 +25,7 @@ import { createJsWithTsPreset, type JestConfigWithTsJest, } from "ts-jest"; +import { join } from "node:path"; // Jest 30 loads .ts config files as ESM via Node's native TypeScript support, // so `require` is not available. Use createRequire for require.resolve calls. @@ -40,11 +41,20 @@ const baseConfig: ArrayElement> = { ...defaultPreset, roots: [""], testMatch: ["**/*.spec.ts"], - modulePathIgnorePatterns: ["dist/", "/examples/"], + modulePathIgnorePatterns: ["dist", join("", "examples")], coveragePathIgnorePatterns: [".*.spec.ts", "dist/"], clearMocks: true, injectGlobals: false, - setupFilesAfterEnv: ["/jest.setup.ts"], + setupFilesAfterEnv: [join("", "jest.setup.ts")], + transform: { + ...defaultPreset.transform, + // [\\\\/] expands to [\\/], which makes the regex Windows-compatible. + "node_modules[\\\\/]jose.+\\.js$": [ + "ts-jest", + { tsconfig: { allowJs: true } }, + ], + }, + transformIgnorePatterns: ["node_modules[\\\\/](?!jose)"], moduleNameMapper: { "^jose": esmRequire.resolve("jose"), }, diff --git a/package-lock.json b/package-lock.json index a6ede1158e..87b592a156 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14994,7 +14994,9 @@ } }, "node_modules/jose": { - "version": "4.15.9", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -18266,6 +18268,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/openid-client/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/openid-client/node_modules/lru-cache": { "version": "6.0.0", "license": "ISC", @@ -22732,7 +22743,7 @@ "@inrupt/oidc-client-ext": "^4.0.0", "@inrupt/solid-client-authn-core": "^4.0.0", "events": "^3.3.0", - "jose": "^5.1.3", + "jose": "^6.2.3", "uuid": "^11.1.0" }, "devDependencies": { @@ -22771,20 +22782,13 @@ "express": "^5.1.0" } }, - "packages/browser/node_modules/jose": { - "version": "5.1.3", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "packages/core": { "name": "@inrupt/solid-client-authn-core", "version": "4.0.0", "license": "MIT", "dependencies": { "events": "^3.3.0", - "jose": "^5.1.3", + "jose": "^6.2.3", "uuid": "^11.1.0" }, "devDependencies": { @@ -22794,20 +22798,13 @@ "node": "^22.0.0 || ^24.0.0" } }, - "packages/core/node_modules/jose": { - "version": "5.1.3", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "packages/node": { "name": "@inrupt/solid-client-authn-node", "version": "4.0.0", "license": "MIT", "dependencies": { "@inrupt/solid-client-authn-core": "^4.0.0", - "jose": "^5.1.3", + "jose": "^6.2.3", "openid-client": "^5.7.1", "uuid": "^11.1.0" }, @@ -22844,20 +22841,13 @@ "@types/express": "^5.0.5" } }, - "packages/node/node_modules/jose": { - "version": "5.1.3", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "packages/oidc-browser": { "name": "@inrupt/oidc-client-ext", "version": "4.0.0", "license": "MIT", "dependencies": { "@inrupt/solid-client-authn-core": "^4.0.0", - "jose": "^5.1.3", + "jose": "^6.2.3", "oidc-client-ts": "^3.5.0", "uuid": "^11.1.0" }, @@ -23080,13 +23070,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "packages/oidc-browser/node_modules/jose": { - "version": "5.1.3", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "packages/oidc-browser/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", diff --git a/packages/browser/package.json b/packages/browser/package.json index d9cf1570f5..5a00b5aeac 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -33,7 +33,7 @@ "@inrupt/oidc-client-ext": "^4.0.0", "@inrupt/solid-client-authn-core": "^4.0.0", "events": "^3.3.0", - "jose": "^5.1.3", + "jose": "^6.2.3", "uuid": "^11.1.0" }, "publishConfig": { diff --git a/packages/browser/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.spec.ts b/packages/browser/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.spec.ts index a84312a233..3c2a6c0b46 100644 --- a/packages/browser/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.spec.ts +++ b/packages/browser/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.spec.ts @@ -34,9 +34,8 @@ import { } from "@inrupt/solid-client-authn-core/mocks"; import { jest, it, describe, expect } from "@jest/globals"; import type * as OidcClientExt from "@inrupt/oidc-client-ext"; -import type { JWK } from "jose"; +import type { JWK, CryptoKey } from "jose"; import { importJWK } from "jose"; -import type { KeyObject } from "crypto"; import { AuthCodeRedirectHandler } from "./AuthCodeRedirectHandler"; import { SessionInfoManagerMock } from "../../../sessionInfo/__mocks__/SessionInfoManager"; import { LocalStorageMock } from "../../../storage/__mocks__/LocalStorage"; @@ -126,7 +125,7 @@ const mockTokenEndpointDpopResponse = webId: mockWebId(), clientId: "some client", dpopKey: { - privateKey: (await importJWK(mockJwk())) as KeyObject, + privateKey: (await importJWK(mockJwk())) as CryptoKey, // Note that here for convenience the private key is also used as public key. // Obviously, this should never be done in non-test code. publicKey: mockJwk(), diff --git a/packages/browser/src/login/oidc/refresh/TokenRefresher.spec.ts b/packages/browser/src/login/oidc/refresh/TokenRefresher.spec.ts index 688098ab58..e4e240f04c 100644 --- a/packages/browser/src/login/oidc/refresh/TokenRefresher.spec.ts +++ b/packages/browser/src/login/oidc/refresh/TokenRefresher.spec.ts @@ -29,11 +29,10 @@ import { mockStorageUtility, // eslint-disable-next-line import/no-unresolved } from "@inrupt/solid-client-authn-core/mocks"; -import type { JWK } from "jose"; +import type { JWK, CryptoKey } from "jose"; import { importJWK } from "jose"; import type { refresh } from "@inrupt/oidc-client-ext"; import { EventEmitter } from "events"; -import type { KeyObject } from "crypto"; import TokenRefresher from "./TokenRefresher"; import { mockDefaultIssuerConfigFetcher, @@ -70,7 +69,7 @@ const mockJwk = (): JWK => { const mockKeyPair = async (): Promise => { return { - privateKey: (await importJWK(mockJwk())) as KeyObject, + privateKey: (await importJWK(mockJwk())) as CryptoKey, // Use the same JWK for public and private key out of convenience, don't do // this in real life. publicKey: mockJwk(), diff --git a/packages/core/package.json b/packages/core/package.json index e8dc1e733c..f25a6e8d48 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "events": "^3.3.0", - "jose": "^5.1.3", + "jose": "^6.2.3", "uuid": "^11.1.0" }, "publishConfig": { diff --git a/packages/core/src/authenticatedFetch/dpopUtils.spec.ts b/packages/core/src/authenticatedFetch/dpopUtils.spec.ts index a6d657757f..0691aeb53d 100644 --- a/packages/core/src/authenticatedFetch/dpopUtils.spec.ts +++ b/packages/core/src/authenticatedFetch/dpopUtils.spec.ts @@ -19,16 +19,16 @@ // import { it, describe, expect } from "@jest/globals"; -import type { KeyLike } from "jose"; +import type { CryptoKey } from "jose"; import { generateKeyPair, exportJWK, jwtVerify } from "jose"; import { createDpopHeader, generateDpopKeyPair } from "./dpopUtils"; -let publicKey: KeyLike | undefined; -let privateKey: KeyLike | undefined; +let publicKey: CryptoKey | undefined; +let privateKey: CryptoKey | undefined; const mockJwk = async (): Promise<{ - publicKey: KeyLike; - privateKey: KeyLike; + publicKey: CryptoKey; + privateKey: CryptoKey; }> => { if (typeof publicKey === "undefined" || typeof privateKey === "undefined") { const generatedPair = await generateKeyPair("ES256"); diff --git a/packages/core/src/authenticatedFetch/dpopUtils.ts b/packages/core/src/authenticatedFetch/dpopUtils.ts index 0ac454c56a..f90a7dd45c 100644 --- a/packages/core/src/authenticatedFetch/dpopUtils.ts +++ b/packages/core/src/authenticatedFetch/dpopUtils.ts @@ -18,7 +18,7 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import type { JWK, KeyLike } from "jose"; +import type { JWK, CryptoKey } from "jose"; import { SignJWT, generateKeyPair, exportJWK } from "jose"; import { v4 } from "uuid"; import { PREFERRED_SIGNING_ALG } from "../constant"; @@ -36,7 +36,7 @@ function normalizeHTU(audience: string): string { } export type KeyPair = { - privateKey: KeyLike; + privateKey: CryptoKey; publicKey: JWK; }; diff --git a/packages/core/src/authenticatedFetch/fetchFactory.spec.ts b/packages/core/src/authenticatedFetch/fetchFactory.spec.ts index 2ca4e0484d..1469242c7f 100644 --- a/packages/core/src/authenticatedFetch/fetchFactory.spec.ts +++ b/packages/core/src/authenticatedFetch/fetchFactory.spec.ts @@ -22,7 +22,7 @@ /* eslint-disable no-shadow */ import { jest, it, describe, expect, afterEach } from "@jest/globals"; -import type { KeyLike } from "jose"; +import type { CryptoKey } from "jose"; import { jwtVerify, generateKeyPair, exportJWK } from "jose"; import { EventEmitter } from "events"; import { @@ -55,15 +55,17 @@ const mockNotRedirectedResponse = () => { return mockedResponse; }; -let publicKey: KeyLike | undefined; -let privateKey: KeyLike | undefined; +let publicKey: CryptoKey | undefined; +let privateKey: CryptoKey | undefined; const mockJwk = async (): Promise<{ - publicKey: KeyLike; - privateKey: KeyLike; + publicKey: CryptoKey; + privateKey: CryptoKey; }> => { if (typeof publicKey === "undefined" || typeof privateKey === "undefined") { - const generatedPair = await generateKeyPair("ES256"); + const generatedPair = await generateKeyPair("ES256", { + extractable: true, + }); publicKey = generatedPair.publicKey; privateKey = generatedPair.privateKey; } @@ -159,7 +161,7 @@ describe("buildAuthenticatedFetch", () => { const headers = new Headers(mockedFetch.mock.calls[0][1]?.headers); const { payload } = await jwtVerify( headers.get("DPoP") as string, - (await mockKeyPair()).privateKey, + (await mockJwk()).publicKey, ); expect(payload.htu).toBe("http://some.url/"); expect(payload.htm).toBe("POST"); @@ -198,7 +200,7 @@ describe("buildAuthenticatedFetch", () => { const headers = new Headers(mockedFetch.mock.calls[1][1]?.headers); const { payload } = await jwtVerify( headers.get("DPoP") as string, - (await mockKeyPair()).privateKey, + (await mockJwk()).publicKey, ); expect(payload.htu).toBe("https://my.pod/container/"); }); diff --git a/packages/core/src/storage/StorageUtility.spec.ts b/packages/core/src/storage/StorageUtility.spec.ts index 39e83e2446..b6f05d9d6c 100644 --- a/packages/core/src/storage/StorageUtility.spec.ts +++ b/packages/core/src/storage/StorageUtility.spec.ts @@ -18,7 +18,7 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import type { KeyLike } from "jose"; +import type { CryptoKey } from "jose"; import { exportJWK, generateKeyPair } from "jose"; import { jest, describe, it, expect } from "@jest/globals"; import { mockIssuerConfig } from "../login/oidc/__mocks__/IssuerConfig"; @@ -531,15 +531,17 @@ describe("saveSessionInfoToStorage", () => { ).resolves.toBe("true"); }); - let publicKey: KeyLike | undefined; - let privateKey: KeyLike | undefined; + let publicKey: CryptoKey | undefined; + let privateKey: CryptoKey | undefined; const mockJwk = async (): Promise<{ - publicKey: KeyLike; - privateKey: KeyLike; + publicKey: CryptoKey; + privateKey: CryptoKey; }> => { if (typeof publicKey === "undefined" || typeof privateKey === "undefined") { - const generatedPair = await generateKeyPair("ES256"); + const generatedPair = await generateKeyPair("ES256", { + extractable: true, + }); publicKey = generatedPair.publicKey; privateKey = generatedPair.privateKey; } diff --git a/packages/core/src/util/token.spec.ts b/packages/core/src/util/token.spec.ts index 08c62cd775..04c10181f1 100644 --- a/packages/core/src/util/token.spec.ts +++ b/packages/core/src/util/token.spec.ts @@ -33,12 +33,12 @@ jest.mock("jose", () => { describe("getWebidFromTokenPayload", () => { // Singleton keys generated on the first call to mockJwk - let publicKey: Jose.KeyLike | undefined; - let privateKey: Jose.KeyLike | undefined; + let publicKey: Jose.CryptoKey | undefined; + let privateKey: Jose.CryptoKey | undefined; const mockJwk = async (): Promise<{ - publicKey: Jose.KeyLike; - privateKey: Jose.KeyLike; + publicKey: Jose.CryptoKey; + privateKey: Jose.CryptoKey; }> => { if (typeof publicKey === "undefined" || typeof privateKey === "undefined") { const generatedPair = await generateKeyPair("ES256"); @@ -55,7 +55,7 @@ describe("getWebidFromTokenPayload", () => { claims: Jose.JWTPayload, issuer: string, audience: string, - signingKey?: Jose.KeyLike, + signingKey?: Jose.CryptoKey, ): Promise => { return new SignJWT(claims) .setProtectedHeader({ alg: "ES256" }) @@ -68,11 +68,9 @@ describe("getWebidFromTokenPayload", () => { it("throws if the JWKS retrieval fails", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; - mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockRejectedValue("Maformed JWKS"), - ); + mockJose.createRemoteJWKSet.mockReturnValue((async () => { + throw new Error("Maformed JWKS"); + }) as unknown as ReturnType<(typeof Jose)["createRemoteJWKSet"]>); const jwt = await mockJwt( { someClaim: true }, "https://some.issuer", @@ -91,9 +89,9 @@ describe("getWebidFromTokenPayload", () => { it("throws if the ID token signature verification fails", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue((await mockJwk()).publicKey), + (async () => (await mockJwk()).publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); const { privateKey: anotherKey } = await generateKeyPair("ES256"); // Sign the returned JWT with a private key unrelated to the public key in the JWKS @@ -118,9 +116,9 @@ describe("getWebidFromTokenPayload", () => { it("throws if the ID token issuer verification fails", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue((await mockJwk()).publicKey), + (async () => (await mockJwk()).publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); const jwt = await mockJwt( { someClaim: true }, @@ -142,9 +140,9 @@ describe("getWebidFromTokenPayload", () => { it("throws if the ID token audience verification fails", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue((await mockJwk()).publicKey), + (async () => (await mockJwk()).publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); const jwt = await mockJwt( { someClaim: true }, @@ -166,9 +164,9 @@ describe("getWebidFromTokenPayload", () => { it("throws if the 'webid' and the 'sub' claims are missing", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue((await mockJwk()).publicKey), + (async () => (await mockJwk()).publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); const jwt = await mockJwt( { someClaim: true }, @@ -188,9 +186,9 @@ describe("getWebidFromTokenPayload", () => { it("throws if the 'webid' claims is missing and the 'sub' claim is not an IRI", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue((await mockJwk()).publicKey), + (async () => (await mockJwk()).publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); const jwt = await mockJwt( { sub: "some user ID" }, @@ -213,9 +211,9 @@ describe("getWebidFromTokenPayload", () => { it("returns the WebID if the 'webid' and 'azp' claims exist", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue((await mockJwk()).publicKey), + (async () => (await mockJwk()).publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); const jwt = await mockJwt( { webid: "https://some.webid#me", azp: "some client" }, @@ -234,9 +232,9 @@ describe("getWebidFromTokenPayload", () => { it("returns the clientID if the 'webid' and 'azp' claims exist", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue((await mockJwk()).publicKey), + (async () => (await mockJwk()).publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); const jwt = await mockJwt( { webid: "https://some.webid#me", azp: "some client" }, @@ -255,9 +253,9 @@ describe("getWebidFromTokenPayload", () => { it("returns the WebID if the 'sub' and 'azp' claims exist and 'sub' is IRI-like", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue((await mockJwk()).publicKey), + (async () => (await mockJwk()).publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); const jwt = await mockJwt( { sub: "https://some.webid#me", azp: "some client" }, @@ -276,9 +274,9 @@ describe("getWebidFromTokenPayload", () => { it("returns the webID if the 'sub' claim exists and it is IRI-like but 'azp' does not exist", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue((await mockJwk()).publicKey), + (async () => (await mockJwk()).publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); const jwt = await mockJwt( { sub: "https://some.webid#me" }, @@ -297,9 +295,9 @@ describe("getWebidFromTokenPayload", () => { it("clientID is undefined if the 'azp' claim does not exist", async () => { const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue((await mockJwk()).publicKey), + (async () => (await mockJwk()).publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); const jwt = await mockJwt( { sub: "https://some.webid#me" }, diff --git a/packages/node/package.json b/packages/node/package.json index 4414259c44..dde1d66b0e 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "@inrupt/solid-client-authn-core": "^4.0.0", - "jose": "^5.1.3", + "jose": "^6.2.3", "openid-client": "^5.7.1", "uuid": "^11.1.0" }, diff --git a/packages/node/src/Session.static.spec.ts b/packages/node/src/Session.static.spec.ts index ec415e9f93..06cbc291f2 100644 --- a/packages/node/src/Session.static.spec.ts +++ b/packages/node/src/Session.static.spec.ts @@ -102,9 +102,9 @@ const mockIdToken = async (payload: Jose.JWTPayload) => { // Mock the issuer JWKS. const mockJose = jest.requireMock("jose") as jest.Mocked; mockJose.createRemoteJWKSet.mockReturnValue( - jest - .fn>() - .mockResolvedValue(issuerKeyPair.publicKey), + (async () => issuerKeyPair.publicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, ); return new SignJWT({ sub: "user123", @@ -200,7 +200,9 @@ describe("Session static functions", () => { }); it("creates a session with DPoP token if dpopKey is provided", async () => { - const dpopKeyPair = await generateKeyPair("ES256"); + const dpopKeyPair = await generateKeyPair("ES256", { + extractable: true, + }); const dpopKey = { privateKey: dpopKeyPair.privateKey, diff --git a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts index 6f1b41662f..7d271c8518 100644 --- a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts +++ b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts @@ -49,9 +49,9 @@ import { import { URL } from "url"; import { Issuer } from "openid-client"; -import type { KeyObject } from "crypto"; import type { EventEmitter } from "events"; import { configToIssuerMetadata } from "../IssuerConfigFetcher"; +import { asDPoPInput } from "../../../util/dpopInput"; // Camelcase identifiers are required in the OIDC specification. /* eslint-disable camelcase*/ @@ -151,10 +151,7 @@ export class AuthCodeRedirectHandler implements IIncomingRedirectHandler { removeOpenIdParams(inputRedirectUrl).href, params, { code_verifier: oidcContext.codeVerifier, state: oauthState }, - // The KeyLike type is dynamically bound to either KeyObject or CryptoKey - // at runtime depending on the environment. Here, we know we are in a NodeJS - // context. - { DPoP: dpopKey?.privateKey as KeyObject }, + { DPoP: dpopKey ? asDPoPInput(dpopKey.privateKey) : undefined }, ); if ( diff --git a/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts b/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts index 8af292eef7..466f9343aa 100644 --- a/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts +++ b/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts @@ -41,9 +41,9 @@ import { buildAuthenticatedFetch, EVENTS, } from "@inrupt/solid-client-authn-core"; -import type { KeyObject } from "crypto"; import { Issuer } from "openid-client"; import { configToIssuerMetadata } from "../IssuerConfigFetcher"; +import { asDPoPInput } from "../../../util/dpopInput"; // Camelcase identifiers are required in the OIDC specification. /* eslint-disable camelcase*/ @@ -93,7 +93,7 @@ export default class ClientCredentialsOidcHandler implements IOidcHandler { { DPoP: oidcLoginOptions.dpop && dpopKey !== undefined - ? (dpopKey.privateKey as KeyObject) + ? asDPoPInput(dpopKey.privateKey) : undefined, }, ); diff --git a/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.spec.ts b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.spec.ts index b0a0827fe1..1e62dad737 100644 --- a/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.spec.ts +++ b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.spec.ts @@ -33,7 +33,7 @@ import { } from "@inrupt/solid-client-authn-core"; import type * as SolidClientAuthnCore from "@inrupt/solid-client-authn-core"; import { randomUUID } from "crypto"; -import { jwtVerify, exportJWK } from "jose"; +import { jwtVerify, exportJWK, importJWK } from "jose"; import { EventEmitter } from "events"; import { mockDefaultOidcOptions, @@ -215,7 +215,7 @@ describe("RefreshTokenOidcHandler", () => { const dpopProof = headers.get("DPoP"); // This checks that the refreshed access token is bound to the initial DPoP key. await expect( - jwtVerify(dpopProof!, dpopKeyPair.privateKey), + jwtVerify(dpopProof!, await importJWK(dpopKeyPair.publicKey)), ).resolves.not.toThrow(); }); diff --git a/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts index 3a243be2d8..c63d5d55c3 100644 --- a/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts +++ b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts @@ -45,10 +45,9 @@ import { buildAuthenticatedFetch, maybeBuildRpInitiatedLogout, } from "@inrupt/solid-client-authn-core"; -import type { JWK } from "jose"; +import type { JWK, CryptoKey as JoseCryptoKey } from "jose"; import { importJWK } from "jose"; import type { EventEmitter } from "events"; -import type { KeyObject } from "crypto"; function validateOptions( oidcLoginOptions: IOidcOptions, @@ -178,7 +177,8 @@ export default class RefreshTokenOidcHandler implements IOidcHandler { privateKey: (await importJWK( JSON.parse(privateKey), PREFERRED_SIGNING_ALG[0], - )) as KeyObject, + { extractable: true }, + )) as JoseCryptoKey, }; } diff --git a/packages/node/src/login/oidc/refresh/TokenRefresher.spec.ts b/packages/node/src/login/oidc/refresh/TokenRefresher.spec.ts index 026b74d449..7d41d5a266 100644 --- a/packages/node/src/login/oidc/refresh/TokenRefresher.spec.ts +++ b/packages/node/src/login/oidc/refresh/TokenRefresher.spec.ts @@ -25,11 +25,10 @@ import { StorageUtilityMock, EVENTS, } from "@inrupt/solid-client-authn-core"; -import type { JWK } from "jose"; +import type { JWK, CryptoKey } from "jose"; import { importJWK } from "jose"; import type { IdTokenClaims, TokenSet } from "openid-client"; import { EventEmitter } from "events"; -import type { KeyObject } from "crypto"; import TokenRefresher from "./TokenRefresher"; import { mockClientRegistrar, @@ -75,7 +74,7 @@ const mockJwk = (): JWK => { const mockKeyPair = async (): Promise => { return { - privateKey: (await importJWK(mockJwk())) as KeyObject, + privateKey: (await importJWK(mockJwk())) as CryptoKey, // Use the same JWK for public and private key out of convenience, don't do // this in real life. publicKey: mockJwk(), diff --git a/packages/node/src/login/oidc/refresh/TokenRefresher.ts b/packages/node/src/login/oidc/refresh/TokenRefresher.ts index f9afd139c6..7dc7482694 100644 --- a/packages/node/src/login/oidc/refresh/TokenRefresher.ts +++ b/packages/node/src/login/oidc/refresh/TokenRefresher.ts @@ -40,10 +40,10 @@ import { } from "@inrupt/solid-client-authn-core"; import type { IssuerMetadata, TokenSet } from "openid-client"; import { Issuer } from "openid-client"; -import type { KeyObject } from "crypto"; import type { EventEmitter } from "events"; import { configToIssuerMetadata } from "../IssuerConfigFetcher"; import { negotiateClientSigningAlg } from "../ClientRegistrar"; +import { asDPoPInput } from "../../../util/dpopInput"; // Camelcase identifiers are required in the OIDC specification. /* eslint-disable camelcase*/ @@ -151,11 +151,7 @@ export default class TokenRefresher implements ITokenRefresher { const tokenSet = await tokenSetToTokenEndpointResponse( await client.refresh(refreshToken, { - // openid-client does not support yet jose@3.x, and expects - // type definitions that are no longer present. However, the JWK - // type that we pass here is compatible with the API, hence the type - // assertion. - DPoP: dpopKey ? (dpopKey.privateKey as KeyObject) : undefined, + DPoP: dpopKey ? asDPoPInput(dpopKey.privateKey) : undefined, }), issuer.metadata, clientInfo, diff --git a/packages/node/src/multiSession.fromTokens.spec.ts b/packages/node/src/multiSession.fromTokens.spec.ts index 78b34dac59..0489614dfa 100644 --- a/packages/node/src/multiSession.fromTokens.spec.ts +++ b/packages/node/src/multiSession.fromTokens.spec.ts @@ -139,8 +139,8 @@ describe("logout", () => { }; // Singleton keys generated for tests - let mockPublicKey: Jose.KeyLike; - let mockPrivateKey: Jose.KeyLike; + let mockPublicKey: Jose.CryptoKey; + let mockPrivateKey: Jose.CryptoKey; // Generate a valid ID token const createMockIdToken = async ( @@ -174,7 +174,11 @@ describe("logout", () => { // Setup the mocked jwtVerify to succeed by default const mockJose = jest.requireMock("jose") as jest.Mocked; // Setup createRemoteJWKSet to return a key lookup function - mockJose.createRemoteJWKSet.mockReturnValue(async () => mockPublicKey); + mockJose.createRemoteJWKSet.mockReturnValue( + (async () => mockPublicKey) as unknown as ReturnType< + (typeof Jose)["createRemoteJWKSet"] + >, + ); }); it("throws an error if idToken is not provided", async () => { diff --git a/packages/node/src/util/dpopInput.ts b/packages/node/src/util/dpopInput.ts new file mode 100644 index 0000000000..0945ab8c00 --- /dev/null +++ b/packages/node/src/util/dpopInput.ts @@ -0,0 +1,32 @@ +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import type { CryptoKey } from "jose"; +import type { KeyObject } from "node:crypto"; + +// FIXME: Remove this helper when openid-client is upgraded to v6. +// openid-client v5's DPoPInput type is `KeyObject | Parameters[0]`, +// but at runtime it also accepts a Web Crypto `CryptoKey` (verified via the +// `Symbol.toStringTag === 'CryptoKey'` check in its `dpopProof` method). +// jose v6's `generateKeyPair` returns a `CryptoKey`, so we need to bridge the +// type mismatch. openid-client v6 uses jose v6 natively and accepts `CryptoKey` +// directly, so this helper becomes unnecessary after that upgrade. +export const asDPoPInput = (key: CryptoKey): KeyObject => + key as unknown as KeyObject; diff --git a/packages/oidc-browser/package.json b/packages/oidc-browser/package.json index 07e1444ef4..2621e794e6 100644 --- a/packages/oidc-browser/package.json +++ b/packages/oidc-browser/package.json @@ -26,7 +26,7 @@ }, "dependencies": { "@inrupt/solid-client-authn-core": "^4.0.0", - "jose": "^5.1.3", + "jose": "^6.2.3", "oidc-client-ts": "^3.5.0", "uuid": "^11.1.0" }, diff --git a/packages/oidc-browser/src/__mocks__/issuer.mocks.ts b/packages/oidc-browser/src/__mocks__/issuer.mocks.ts index e953ed7fcb..08567335c3 100644 --- a/packages/oidc-browser/src/__mocks__/issuer.mocks.ts +++ b/packages/oidc-browser/src/__mocks__/issuer.mocks.ts @@ -26,7 +26,7 @@ import type { IIssuerConfig, KeyPair, } from "@inrupt/solid-client-authn-core"; -import type { KeyObject } from "crypto"; +import type { CryptoKey } from "jose"; import type { TokenEndpointInput } from "../dpop/tokenExchange"; // Camelcase identifiers are required in the OIDC specification. @@ -48,7 +48,7 @@ export const mockKeyPair = async (): Promise => { const publicKey = mockJwk(); delete publicKey.d; return { - privateKey: (await importJWK(mockJwk())) as KeyObject, + privateKey: (await importJWK(mockJwk())) as CryptoKey, publicKey, }; }; diff --git a/tests/environment/customEnvironment.ts b/tests/environment/customEnvironment.ts index 1e077fda9a..865ff9c131 100644 --- a/tests/environment/customEnvironment.ts +++ b/tests/environment/customEnvironment.ts @@ -23,6 +23,9 @@ import Environment from "jest-environment-jsdom"; export default class CustomTestEnvironment extends Environment { async setup() { await super.setup(); + if (typeof this.global.structuredClone === "undefined") { + this.global.structuredClone = structuredClone; + } if (typeof this.global.TextEncoder === "undefined") { // The following doesn't work from jest-jsdom-polyfills. // TextEncoder (global or via 'util') references a Uint8Array constructor