diff --git a/.gitignore b/.gitignore index 54d944eda..2e9c43367 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ node_modules/ .vscode/ temp/ .history/ +.idea/ diff --git a/docs/oidc-client-ts.api.md b/docs/oidc-client-ts.api.md index fc28ae363..f071395e3 100644 --- a/docs/oidc-client-ts.api.md +++ b/docs/oidc-client-ts.api.md @@ -47,6 +47,8 @@ export class CheckSessionIFrame { // @public (undocumented) export interface CreateSigninRequestArgs extends Omit { + // (undocumented) + dpopJkt?: string; // (undocumented) redirect_uri?: string; // (undocumented) @@ -139,6 +141,26 @@ export interface INavigator { prepare(params: unknown): Promise; } +// @public (undocumented) +export class IndexedDbCryptoKeyPairStore { + // (undocumented) + static createStore(dbName: string, storeName: string): Promise<(txMode: IDBTransactionMode, callback: (store: IDBObjectStore) => T | PromiseLike) => Promise>; + // (undocumented) + static dbName: string; + // (undocumented) + static get(key: string): Promise; + // (undocumented) + static getAllKeys(): Promise; + // (undocumented) + static promisifyRequest(request: IDBRequest | IDBTransaction): Promise; + // (undocumented) + static remove(key: string): Promise; + // (undocumented) + static set(key: string, value: CryptoKeyPair): Promise; + // (undocumented) + static storeName: string; +} + // @public (undocumented) export class InMemoryWebStorage implements Storage { // (undocumented) @@ -301,7 +323,7 @@ export class OidcClient { // (undocumented) clearStaleState(): Promise; // (undocumented) - createSigninRequest({ state, request, request_uri, request_type, id_token_hint, login_hint, skipUserInfo, nonce, url_state, response_type, scope, redirect_uri, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, extraQueryParams, extraTokenParams, }: CreateSigninRequestArgs): Promise; + createSigninRequest({ state, request, request_uri, request_type, id_token_hint, login_hint, skipUserInfo, nonce, dpopJkt, url_state, response_type, scope, redirect_uri, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, extraQueryParams, extraTokenParams, }: CreateSigninRequestArgs): Promise; // (undocumented) createSignoutRequest({ state, id_token_hint, client_id, request_type, post_logout_redirect_uri, extraQueryParams, }?: CreateSignoutRequestArgs): Promise; // (undocumented) @@ -311,7 +333,7 @@ export class OidcClient { // (undocumented) processResourceOwnerPasswordCredentials({ username, password, skipUserInfo, extraTokenParams, }: ProcessResourceOwnerPasswordCredentialsArgs): Promise; // (undocumented) - processSigninResponse(url: string): Promise; + processSigninResponse(url: string, extraHeaders?: Record): Promise; // (undocumented) processSignoutResponse(url: string): Promise; // (undocumented) @@ -333,7 +355,7 @@ export class OidcClient { // (undocumented) protected readonly _tokenClient: TokenClient; // (undocumented) - useRefreshToken({ state, redirect_uri, resource, timeoutInSeconds, extraTokenParams, }: UseRefreshTokenArgs): Promise; + useRefreshToken({ state, redirect_uri, resource, timeoutInSeconds, extraHeaders, extraTokenParams, }: UseRefreshTokenArgs): Promise; // Warning: (ae-forgotten-export) The symbol "ResponseValidator" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -623,7 +645,7 @@ export type SigninRedirectArgs = RedirectParams & ExtraSigninRequestArgs; // @public (undocumented) export class SigninRequest { // (undocumented) - static create({ url, authority, client_id, redirect_uri, response_type, scope, state_data, response_mode, request_type, client_secret, nonce, url_state, resource, skipUserInfo, extraQueryParams, extraTokenParams, disablePKCE, ...optionalParams }: SigninRequestCreateArgs): Promise; + static create({ url, authority, client_id, redirect_uri, response_type, scope, state_data, response_mode, request_type, client_secret, nonce, url_state, resource, skipUserInfo, extraQueryParams, extraTokenParams, disablePKCE, dpopJkt, ...optionalParams }: SigninRequestCreateArgs): Promise; // (undocumented) readonly state: SigninState; // (undocumented) @@ -645,6 +667,8 @@ export interface SigninRequestCreateArgs { // (undocumented) display?: string; // (undocumented) + dpopJkt?: string; + // (undocumented) extraQueryParams?: Record; // (undocumented) extraTokenParams?: Record; @@ -902,6 +926,8 @@ export class User { url_state?: string; }); access_token: string; + // (undocumented) + dpopProof(url: string, httpMethod?: string): Promise; get expired(): boolean | undefined; expires_at?: number; get expires_in(): number | undefined; @@ -924,6 +950,8 @@ export class User { // @public (undocumented) export interface UseRefreshTokenArgs { + // (undocumented) + extraHeaders?: Record; // (undocumented) extraTokenParams?: Record; // (undocumented) @@ -947,6 +975,8 @@ export class UserManager { clearStaleState(): Promise; // (undocumented) protected readonly _client: OidcClient; + // (undocumented) + protected readonly _dpopNonceStore: WebStorageStateStore | null; get events(): UserManagerEvents; // (undocumented) protected readonly _events: UserManagerEvents; @@ -1048,6 +1078,8 @@ export interface UserManagerSettings extends OidcClientSettings { accessTokenExpiringNotificationTimeInSeconds?: number; automaticSilentRenew?: boolean; checkSessionIntervalInSeconds?: number; + // Warning: (ae-forgotten-export) The symbol "DPoPSettings" needs to be exported by the entry point index.d.ts + dpopSettings?: DPoPSettings; iframeNotifyParentOrigin?: string; iframeScriptOrigin?: string; includeIdTokenInSilentRenew?: boolean; @@ -1084,6 +1116,8 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore { // (undocumented) readonly checkSessionIntervalInSeconds: number; // (undocumented) + readonly dpopSettings: DPoPSettings; + // (undocumented) readonly iframeNotifyParentOrigin: string | undefined; // (undocumented) readonly iframeScriptOrigin: string | undefined; diff --git a/jest-environment-jsdom.cjs b/jest-environment-jsdom.cjs new file mode 100644 index 000000000..e3f1d4e40 --- /dev/null +++ b/jest-environment-jsdom.cjs @@ -0,0 +1,28 @@ +'use strict'; + +const { TextEncoder, TextDecoder } = require('util'); +const { default: $JSDOMEnvironment, TestEnvironment } = require('jest-environment-jsdom'); +const crypto = require("crypto"); + +Object.defineProperty(exports, '__esModule', { + value: true +}); + +class JSDOMEnvironment extends $JSDOMEnvironment { + constructor(...args) { + const { global } = super(...args); + // see https://github.com/jsdom/jsdom/issues/2524 + global.TextEncoder = TextEncoder; + global.TextDecoder = TextDecoder; + // see https://github.com/jestjs/jest/issues/9983 + global.Uint8Array = Uint8Array; + global.crypto.subtle = crypto.subtle; + global.crypto.randomUUID = crypto.randomUUID; + // see https://github.com/dumbmatter/fakeIndexedDB#jsdom-often-used-with-jest + global.structuredClone = structuredClone; + } +} + +exports.default = JSDOMEnvironment; +exports.TestEnvironment = TestEnvironment === $JSDOMEnvironment ? + JSDOMEnvironment : TestEnvironment; diff --git a/jest.config.mjs b/jest.config.mjs index 1041416ec..4e5b05c54 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -7,9 +7,12 @@ export default { clearMocks: true, setupFilesAfterEnv: ["./test/setup.ts"], testMatch: ["**/{src,test}/**/*.test.ts"], - testEnvironment: "jsdom", + testEnvironment: "./jest-environment-jsdom.cjs", collectCoverage, coverageReporters: collectCoverage ? ["lcov"] : ["lcov", "text"], + moduleNameMapper: { + "^jose": "jose", // map to jose cjs module otherwise jest breaks + }, transform: { "^.+\\.tsx?$": [ "ts-jest", diff --git a/package-lock.json b/package-lock.json index 64e6c0551..687104c08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.0.1", "license": "Apache-2.0", "dependencies": { + "crypto-js": "^4.1.1", "jwt-decode": "^4.0.0" }, "devDependencies": { @@ -21,11 +22,13 @@ "esbuild": "^0.20.0", "eslint": "^8.5.0", "eslint-plugin-testing-library": "^6.0.0", + "fake-indexeddb": "^5.0.1", "http-proxy-middleware": "^2.0.1", "husky": "^9.0.6", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", "jest-mock": "^29.3.1", + "jose": "^5.1.2", "lint-staged": "^15.0.1", "ts-jest": "^29.0.3", "typedoc": "^0.25.0", @@ -3147,6 +3150,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/css.escape": { "version": "1.5.1", "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", @@ -3861,6 +3869,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-5.0.1.tgz", + "integrity": "sha512-vxybH29Owtc6khV/Usy47B1g+eKwyhFiX8nwpCC4td320jvwrKQDH6vNtcJZgUzVxmfsSIlHzLKQzT76JMCO7A==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", @@ -5174,6 +5191,15 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "node_modules/jose": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.1.2.tgz", + "integrity": "sha512-X7TOC/d8KPvx4wPUuLHVgTSdoWw0UW5TQOUwhvCvj+ZPfsf9vUPhhksYPjNBWVGPQ/6yd/JrL1gQxBnIDwYdFg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", diff --git a/package.json b/package.json index 529c19c59..68221d1ba 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "prepare": "husky install" }, "dependencies": { + "crypto-js": "^4.1.1", "jwt-decode": "^4.0.0" }, "devDependencies": { @@ -51,11 +52,13 @@ "esbuild": "^0.20.0", "eslint": "^8.5.0", "eslint-plugin-testing-library": "^6.0.0", + "fake-indexeddb": "^5.0.1", "http-proxy-middleware": "^2.0.1", "husky": "^9.0.6", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", "jest-mock": "^29.3.1", + "jose": "^5.1.2", "lint-staged": "^15.0.1", "ts-jest": "^29.0.3", "typedoc": "^0.25.0", diff --git a/src/DPoPService.test.ts b/src/DPoPService.test.ts new file mode 100644 index 000000000..b1a336f87 --- /dev/null +++ b/src/DPoPService.test.ts @@ -0,0 +1,44 @@ +import { DPoPService } from "./DPoPService"; +import { jwtVerify, decodeProtectedHeader, importJWK, type JWK } from "jose"; +import { IndexedDbCryptoKeyPairStore as idb } from "./IndexedDbCryptoKeyPairStore"; + +describe("DPoPService", () => { + + beforeEach(async () => { + await idb.remove("oidc.dpop"); + }); + + describe("generateDPoPProof", () => { + it("should generate a valid proof without an access token", async () => { + const proof = await DPoPService.generateDPoPProof({ url: "http://example.com" }); + const protectedHeader = decodeProtectedHeader(proof); + const publicKey = await importJWK(protectedHeader.jwk); + const verifiedResult = await jwtVerify(proof, publicKey); + + expect(verifiedResult.payload).toHaveProperty("htu"); + expect(verifiedResult.payload).toHaveProperty("htm"); + }); + + it("should generate a valid proof with an access token", async () => { + await DPoPService.generateDPoPProof({ url: "http://example.com" }); + const proof = await DPoPService.generateDPoPProof({ url: "http://example.com", accessToken: "some_access_token" }); + + const protectedHeader = decodeProtectedHeader(proof); + const publicKey = await importJWK(protectedHeader.jwk); + const verifiedResult = await jwtVerify(proof, publicKey); + + expect(verifiedResult.payload).toHaveProperty("htu"); + expect(verifiedResult.payload).toHaveProperty("htm"); + expect(verifiedResult.payload).toHaveProperty("ath"); + expect(verifiedResult.payload["htu"]).toEqual("http://example.com"); + }); + }); + + describe("dpopJkt", () => { + it("should generate crypto keys when generating a dpop thumbprint if no keys exists in the store", async () => { + const setMock = jest.spyOn(idb, "set"); + await DPoPService.generateDPoPJkt(); + expect(setMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/DPoPService.ts b/src/DPoPService.ts new file mode 100644 index 000000000..b01e427fa --- /dev/null +++ b/src/DPoPService.ts @@ -0,0 +1,104 @@ +import { CryptoUtils, JwtUtils } from "./utils"; +import { IndexedDbCryptoKeyPairStore } from "./IndexedDbCryptoKeyPairStore"; + +/** + * Provides an implementation of Demonstrating Proof of Possession (DPoP) as defined in the + * OAuth2 spec https://datatracker.ietf.org/doc/html/rfc9449. + */ + +export interface GenerateDPoPProofOpts { + url: string; + accessToken?: string; + httpMethod?: string; +} +export class DPoPService { + public static async generateDPoPProof({ + url, + accessToken, + httpMethod, + }: GenerateDPoPProofOpts): Promise { + let hashedToken: Uint8Array; + let encodedHash: string; + + const payload: Record = { + "jti": window.crypto.randomUUID(), + "htm": httpMethod ?? "GET", + "htu": url, + "iat": Math.floor(Date.now() / 1000), + }; + + const keyPair = await this.loadKeyPair(); + + if (accessToken) { + hashedToken = await CryptoUtils.hash("SHA-256", accessToken); + encodedHash = CryptoUtils.encodeBase64Url(hashedToken); + payload.ath = encodedHash; + } + + try { + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + const header = { + "alg": "ES256", + "typ": "dpop+jwt", + "jwk": { + "crv": publicJwk.crv, + "kty": publicJwk.kty, + "x": publicJwk.x, + "y": publicJwk.y, + }, + }; + return await JwtUtils.generateSignedJwt(header, payload, keyPair.privateKey); + } catch (err) { + if (err instanceof TypeError) { + throw new Error(`Error exporting dpop public key: ${err.message}`); + } else { + throw err; + } + } + } + + public static async generateDPoPJkt() : Promise { + try { + const keyPair = await this.loadKeyPair(); + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + return await CryptoUtils.customCalculateJwkThumbprint(publicJwk); + } catch (err) { + if (err instanceof TypeError) { + throw new Error(`Could not retrieve dpop keys from storage: ${err.message}`); + } else { + throw err; + } + } + } + + protected static async loadKeyPair() : Promise { + try { + const allKeys = await IndexedDbCryptoKeyPairStore.getAllKeys(); + let keyPair: CryptoKeyPair; + if (!allKeys.includes("oidc.dpop")) { + keyPair = await this.generateKeys(); + await IndexedDbCryptoKeyPairStore.set("oidc.dpop", keyPair); + } else { + keyPair = await IndexedDbCryptoKeyPairStore.get("oidc.dpop") as CryptoKeyPair; + } + return keyPair; + } catch (err) { + if (err instanceof TypeError) { + throw new Error(`Could not retrieve dpop keys from storage: ${err.message}`); + } else { + throw err; + } + } + } + + protected static async generateKeys() : Promise { + return await window.crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + false, + ["sign", "verify"], + ); + } +} diff --git a/src/IndexedDbCryptoKeyPairStore.test.ts b/src/IndexedDbCryptoKeyPairStore.test.ts new file mode 100644 index 000000000..5dc6dde5f --- /dev/null +++ b/src/IndexedDbCryptoKeyPairStore.test.ts @@ -0,0 +1,91 @@ +import { IndexedDbCryptoKeyPairStore as subject } from "./IndexedDbCryptoKeyPairStore"; + +describe("IndexedDBCryptoKeyPairStore", () => { + let data: CryptoKeyPair; + const createCryptoKeyPair = async () => { + return await window.crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + false, + ["sign", "verify"], + ); + }; + + beforeEach(async () => { + data = await createCryptoKeyPair(); + }); + + describe("set", () => { + it("should return a promise", async () => { + // act + const p = subject.set("key", data); + + // assert + expect(p).toBeInstanceOf(Promise); + // eslint-disable-next-line no-empty + try { await p; } catch {} + }); + + it("should store a key in IndexedDB", async () => { + await subject.set("foo", data); + const result = await subject.get("foo"); + + expect(result).toEqual(data); + }); + }); + + describe("remove", () => { + it("should return a promise", async () => { + // act + const p = subject.remove("key"); + + // assert + expect(p).toBeInstanceOf(Promise); + // eslint-disable-next-line no-empty + try { await p; } catch {} + }); + + it("should remove a key from IndexedDB", async () => { + await subject.set("foo", data); + let result = await subject.get("foo"); + + expect(result).toEqual(data); + + await subject.remove("foo"); + result = await subject.get("foo"); + expect(result).toBeUndefined(); + }); + + it("should return a value if key exists", async () => { + await subject.set("foo", data); + const result = await subject.remove("foo"); + + expect(result).toEqual(data); + }); + }); + + describe("getAllKeys", () => { + it("should return a promise", async () => { + // act + const p = subject.getAllKeys(); + + // assert + expect(p).toBeInstanceOf(Promise); + // eslint-disable-next-line no-empty + try { await p; } catch {} + }); + + it("should get all keys in IndexedDB", async () => { + await subject.set("foo", data); + const dataTwo = await createCryptoKeyPair(); + await subject.set("boo", dataTwo); + + const result = await subject.getAllKeys(); + expect(result.length).toEqual(2); + expect(result).toContain("foo"); + expect(result).toContain("boo"); + }); + }); +}); diff --git a/src/IndexedDbCryptoKeyPairStore.ts b/src/IndexedDbCryptoKeyPairStore.ts new file mode 100644 index 000000000..63c33e2dc --- /dev/null +++ b/src/IndexedDbCryptoKeyPairStore.ts @@ -0,0 +1,62 @@ +export class IndexedDbCryptoKeyPairStore { + static dbName = "oidc"; + static storeName = "dpop"; + + static async get(key: string): Promise { + const store = await this.createStore(this.dbName, this.storeName); + return await store("readonly", (str) => { + return this.promisifyRequest(str.get(key)); + }) as CryptoKeyPair; + } + + static async getAllKeys(): Promise { + const store = await this.createStore(this.dbName, this.storeName); + return await store("readonly", (str) => { + return this.promisifyRequest(str.getAllKeys()); + }) as string[]; + } + + static async remove(key: string): Promise { + const item = await this.get(key); + const store = await this.createStore(this.dbName, this.storeName); + await store("readwrite", (str) => { + return this.promisifyRequest(str.delete(key)); + }); + return item; + } + + static async set(key: string, value: CryptoKeyPair): Promise { + const store = await this.createStore(this.dbName, this.storeName); + await store("readwrite", (str: IDBObjectStore) => { + str.put(value, key); + return this.promisifyRequest(str.transaction); + }); + } + + static promisifyRequest( + request: IDBRequest | IDBTransaction): Promise { + return new Promise((resolve, reject) => { + (request as IDBTransaction).oncomplete = (request as IDBRequest).onsuccess = () => resolve((request as IDBRequest).result); + (request as IDBTransaction).onabort = (request as IDBRequest).onerror = () => reject((request as IDBRequest).error); + }); + } + + static async createStore( + dbName: string, + storeName: string, + ): Promise<(txMode: IDBTransactionMode, callback: (store: IDBObjectStore) => T | PromiseLike) => Promise> { + const request = indexedDB.open(dbName); + request.onupgradeneeded = () => request.result.createObjectStore(storeName); + const db = await this.promisifyRequest(request); + + return async ( + txMode: IDBTransactionMode, + callback: (store: IDBObjectStore) => T | PromiseLike, + ) => { + const tx = db.transaction(storeName, txMode); + const store = tx.objectStore(storeName); + return await callback(store); + }; + } + +} diff --git a/src/JsonService.test.ts b/src/JsonService.test.ts index 800fec10d..499509cd1 100644 --- a/src/JsonService.test.ts +++ b/src/JsonService.test.ts @@ -5,6 +5,7 @@ import { ErrorResponse } from "./errors"; import { JsonService } from "./JsonService"; import { mocked } from "jest-mock"; +import type { ExtraHeader } from "./OidcClientSettings"; describe("JsonService", () => { let subject: JsonService; @@ -339,6 +340,31 @@ describe("JsonService", () => { ); }); + it("should fetch with extraHeaders if supplied", async () => { + // act + const extraHeaders: Record = { + "DPoP": "some random dpop proof", + }; + await expect(subject.postForm("http://test", { body: new URLSearchParams("payload=dummy"), extraHeaders })).rejects.toThrow(); + await expect(subject.postForm("http://test", { body: new URLSearchParams("payload=dummy"), basicAuth: "basicAuth", extraHeaders })).rejects.toThrow(); + + // assert + expect(fetch).toBeCalledTimes(2); + expect(fetch).toHaveBeenLastCalledWith( + "http://test", + expect.objectContaining({ + headers: { + Accept: "application/json", + Authorization: "Basic basicAuth", + "Content-Type": "application/x-www-form-urlencoded", + DPoP: expect.any(String), + }, + method: "POST", + body: new URLSearchParams(), + }), + ); + }); + it("should set payload as body", async () => { // act await expect(subject.postForm("http://test", { body: new URLSearchParams("payload=dummy") })).rejects.toThrow(); diff --git a/src/JsonService.ts b/src/JsonService.ts index 5d0f0f916..2047444ca 100644 --- a/src/JsonService.ts +++ b/src/JsonService.ts @@ -26,6 +26,7 @@ export interface PostFormOpts { basicAuth?: string; timeoutInSeconds?: number; initCredentials?: "same-origin" | "include" | "omit"; + extraHeaders?: Record; } /** @@ -131,12 +132,16 @@ export class JsonService { basicAuth, timeoutInSeconds, initCredentials, + extraHeaders, }: PostFormOpts): Promise> { const logger = this._logger.create("postForm"); + const headers: HeadersInit = { "Accept": this._contentTypes.join(", "), "Content-Type": "application/x-www-form-urlencoded", + ...extraHeaders, }; + if (basicAuth !== undefined) { headers["Authorization"] = "Basic " + basicAuth; } diff --git a/src/OidcClient.test.ts b/src/OidcClient.test.ts index 76154e521..32fa5a536 100644 --- a/src/OidcClient.test.ts +++ b/src/OidcClient.test.ts @@ -335,7 +335,7 @@ describe("OidcClient", () => { const response = await subject.processSigninResponse("http://app/cb?state=1"); // assert - expect(validateSigninResponseMock).toHaveBeenCalledWith(response, item); + expect(validateSigninResponseMock).toHaveBeenCalledWith(response, item, undefined); }); }); diff --git a/src/OidcClient.ts b/src/OidcClient.ts index 3ab42db5b..5e5c79c69 100644 --- a/src/OidcClient.ts +++ b/src/OidcClient.ts @@ -3,7 +3,7 @@ import { Logger, UrlUtils } from "./utils"; import { ErrorResponse } from "./errors"; -import { type OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings"; +import { type ExtraHeader, type OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings"; import { ResponseValidator } from "./ResponseValidator"; import { MetadataService } from "./MetadataService"; import type { RefreshState } from "./RefreshState"; @@ -24,6 +24,7 @@ export interface CreateSigninRequestArgs redirect_uri?: string; response_type?: string; scope?: string; + dpopJkt? : string; /** custom "state", which can be used by a caller to have "data" round tripped */ state?: unknown; @@ -39,6 +40,8 @@ export interface UseRefreshTokenArgs { timeoutInSeconds?: number; state: RefreshState; + + extraHeaders?: Record; } /** @@ -96,6 +99,7 @@ export class OidcClient { login_hint, skipUserInfo, nonce, + dpopJkt, url_state, response_type = this.settings.response_type, scope = this.settings.scope, @@ -133,6 +137,7 @@ export class OidcClient { client_secret: this.settings.client_secret, skipUserInfo, nonce, + dpopJkt, disablePKCE: this.settings.disablePKCE, }); @@ -164,12 +169,12 @@ export class OidcClient { return { state, response }; } - public async processSigninResponse(url: string): Promise { + public async processSigninResponse(url: string, extraHeaders?: Record): Promise { const logger = this._logger.create("processSigninResponse"); const { state, response } = await this.readSigninResponseState(url, true); logger.debug("received state from storage; validating response"); - await this._validator.validateSigninResponse(response, state); + await this._validator.validateSigninResponse(response, state, extraHeaders); return response; } @@ -191,6 +196,7 @@ export class OidcClient { redirect_uri, resource, timeoutInSeconds, + extraHeaders, extraTokenParams, }: UseRefreshTokenArgs): Promise { const logger = this._logger.create("useRefreshToken"); @@ -215,6 +221,7 @@ export class OidcClient { redirect_uri, resource, timeoutInSeconds, + extraHeaders, ...extraTokenParams, }); const response = new SigninResponse(new URLSearchParams()); diff --git a/src/ResponseValidator.ts b/src/ResponseValidator.ts index 1c1f845ed..8fcf0fdf1 100644 --- a/src/ResponseValidator.ts +++ b/src/ResponseValidator.ts @@ -15,6 +15,7 @@ import type { UserProfile } from "./User"; import type { RefreshState } from "./RefreshState"; import type { IdTokenClaims } from "./Claims"; import type { ClaimsService } from "./ClaimsService"; +import type { ExtraHeader } from "./OidcClientSettings"; /** * @internal @@ -30,13 +31,13 @@ export class ResponseValidator { protected readonly _claimsService: ClaimsService, ) {} - public async validateSigninResponse(response: SigninResponse, state: SigninState): Promise { + public async validateSigninResponse(response: SigninResponse, state: SigninState, extraHeaders?: Record): Promise { const logger = this._logger.create("validateSigninResponse"); this._processSigninState(response, state); logger.debug("state processed"); - await this._processCode(response, state); + await this._processCode(response, state, extraHeaders); logger.debug("code processed"); if (response.isOpenId) { @@ -169,7 +170,7 @@ export class ResponseValidator { logger.debug("user info claims received, updated profile:", response.profile); } - protected async _processCode(response: SigninResponse, state: SigninState): Promise { + protected async _processCode(response: SigninResponse, state: SigninState, extraHeaders?: Record): Promise { const logger = this._logger.create("_processCode"); if (response.code) { logger.debug("Validating code"); @@ -179,6 +180,7 @@ export class ResponseValidator { code: response.code, redirect_uri: state.redirect_uri, code_verifier: state.code_verifier, + extraHeaders: extraHeaders, ...state.extraTokenParams, }); Object.assign(response, tokenResponse); diff --git a/src/SigninRequest.ts b/src/SigninRequest.ts index b44bfb534..22492336b 100644 --- a/src/SigninRequest.ts +++ b/src/SigninRequest.ts @@ -20,6 +20,7 @@ export interface SigninRequestCreateArgs { // optional response_mode?: "query" | "fragment"; nonce?: string; + dpopJkt?: string; display?: string; prompt?: string; max_age?: number; @@ -72,6 +73,7 @@ export class SigninRequest { extraQueryParams, extraTokenParams, disablePKCE, + dpopJkt, ...optionalParams }: SigninRequestCreateArgs): Promise { if (!url) { @@ -118,6 +120,9 @@ export class SigninRequest { if (nonce) { parsedUrl.searchParams.append("nonce", nonce); } + if (dpopJkt) { + parsedUrl.searchParams.append("dpop_jkt", dpopJkt); + } let stateParam = state.id; if (url_state) { diff --git a/src/TokenClient.ts b/src/TokenClient.ts index a56558a24..1bced65f7 100644 --- a/src/TokenClient.ts +++ b/src/TokenClient.ts @@ -5,6 +5,7 @@ import { CryptoUtils, Logger } from "./utils"; import { JsonService } from "./JsonService"; import type { MetadataService } from "./MetadataService"; import type { OidcClientSettingsStore } from "./OidcClientSettings"; +import type { ExtraHeader } from "./OidcClientSettings"; /** * @internal @@ -17,6 +18,8 @@ export interface ExchangeCodeArgs { grant_type?: string; code: string; code_verifier?: string; + + extraHeaders?: Record; } /** @@ -47,6 +50,7 @@ export interface ExchangeRefreshTokenArgs { resource?: string | string[]; timeoutInSeconds?: number; + extraHeaders?: Record; } /** @@ -85,6 +89,7 @@ export class TokenClient { redirect_uri = this._settings.redirect_uri, client_id = this._settings.client_id, client_secret = this._settings.client_secret, + extraHeaders, ...args }: ExchangeCodeArgs): Promise> { const logger = this._logger.create("exchangeCode"); @@ -124,7 +129,7 @@ export class TokenClient { const url = await this._metadataService.getTokenEndpoint(false); logger.debug("got token endpoint"); - const response = await this._jsonService.postForm(url, { body: params, basicAuth, initCredentials: this._settings.fetchRequestCredentials }); + const response = await this._jsonService.postForm(url, { body: params, basicAuth, initCredentials: this._settings.fetchRequestCredentials, extraHeaders }); logger.debug("got response"); return response; @@ -191,6 +196,7 @@ export class TokenClient { client_id = this._settings.client_id, client_secret = this._settings.client_secret, timeoutInSeconds, + extraHeaders, ...args }: ExchangeRefreshTokenArgs): Promise> { const logger = this._logger.create("exchangeRefreshToken"); @@ -230,7 +236,7 @@ export class TokenClient { const url = await this._metadataService.getTokenEndpoint(false); logger.debug("got token endpoint"); - const response = await this._jsonService.postForm(url, { body: params, basicAuth, timeoutInSeconds, initCredentials: this._settings.fetchRequestCredentials }); + const response = await this._jsonService.postForm(url, { body: params, basicAuth, timeoutInSeconds, initCredentials: this._settings.fetchRequestCredentials, extraHeaders }); logger.debug("got response"); return response; diff --git a/src/User.ts b/src/User.ts index 67a07c9cf..209420d74 100644 --- a/src/User.ts +++ b/src/User.ts @@ -3,6 +3,7 @@ import { Logger, Timer } from "./utils"; import type { IdTokenClaims } from "./Claims"; +import { DPoPService } from "./DPoPService"; /** * Holds claims represented by a combination of the `id_token` and the user info endpoint. @@ -124,4 +125,8 @@ export class User { Logger.createStatic("User", "fromStorageString"); return new User(JSON.parse(storageString)); } + + public async dpopProof(url: string, httpMethod?: string): Promise { + return await DPoPService.generateDPoPProof({ url, accessToken: this.access_token, httpMethod: httpMethod } ); + } } diff --git a/src/UserManager.test.ts b/src/UserManager.test.ts index 890aab104..aa7139606 100644 --- a/src/UserManager.test.ts +++ b/src/UserManager.test.ts @@ -18,6 +18,7 @@ import type { UserProfile } from "./User"; import { WebStorageStateStore } from "./WebStorageStateStore"; import type { SigninState } from "./SigninState"; import type { State } from "./State"; +import { IndexedDbCryptoKeyPairStore as idb } from "./IndexedDbCryptoKeyPairStore"; import { mocked } from "jest-mock"; @@ -169,6 +170,25 @@ describe("UserManager", () => { }); }); + describe("removeUser", () => { + it("should remove user from store and remove dpop keys from indexedDB if dpop setting is true", async () => { + // arrange + Object.assign(subject.settings.dpopSettings, { + enabled: true, + }); + const storeUserMock = jest.spyOn(subject, "storeUser"); + const unloadMock = jest.spyOn(subject["_events"], "unload"); + const delMock = jest.spyOn(idb, "remove"); + // act + await subject.removeUser(); + + // assert + expect(storeUserMock).toBeCalledWith(null); + expect(unloadMock).toBeCalled(); + expect(delMock).toBeCalled(); + }); + }); + describe("revokeTokens", () => { it("should revoke the token types specified", async () => { // arrange @@ -300,6 +320,21 @@ describe("UserManager", () => { expect(user).toBeInstanceOf(User); spy.mockRestore(); }); + + it("should return a user if DPoP enabled", async () => { + // arrange + subject.settings.dpopSettings.enabled = true; + const spy = jest.spyOn(subject["_client"], "processSigninResponse") + .mockResolvedValue({} as SigninResponse); + + // act + const user = await subject.signinRedirectCallback("http://app/cb?state=test&code=code"); + + // assert + expect(spy).toHaveBeenCalledWith("http://app/cb?state=test&code=code", { "DPoP": expect.any(String) }); + expect(user).toBeInstanceOf(User); + spy.mockRestore(); + }); }); describe("signinResourceOwnerCredentials", () => { @@ -401,6 +436,43 @@ describe("UserManager", () => { handle, ); }); + + it("should pass dpopJkt arg if dpop is enabled and bind_authorization_code is true", async () => { + // arrange + const user = new User({ + access_token: "access_token", + token_type: "token_type", + profile: {} as UserProfile, + }); + const handle = { } as PopupWindow; + jest.spyOn(subject["_popupNavigator"], "prepare") + .mockImplementation(() => Promise.resolve(handle)); + subject["_signin"] = jest.fn().mockResolvedValue(user); + const extraArgs: SigninPopupArgs = { + extraQueryParams: { q : "q" }, + extraTokenParams: { t: "t" }, + state: "state", + nonce: "random_nonce", + redirect_uri: "http://app/extra_callback", + prompt: "login", + }; + subject.settings.dpopSettings.enabled = true; + subject.settings.dpopSettings.bind_authorization_code = true; + + // act + await subject.signinPopup(extraArgs); + + // assert + expect(subject["_signin"]).toHaveBeenCalledWith( + { + request_type: "si:p", + display: "popup", + dpopJkt: expect.any(String), + ...extraArgs, + }, + handle, + ); + }); }); describe("signinPopupCallback", () => { @@ -493,6 +565,45 @@ describe("UserManager", () => { ); }); + it("should pass dpopJkt to _signIn if dpop enabled and bind_authorization_code is true", async () => { + // arrange + const user = new User({ + access_token: "access_token", + token_type: "token_type", + profile: {} as UserProfile, + }); + jest.spyOn(subject["_popupNavigator"], "prepare"); + subject["_signin"] = jest.fn().mockResolvedValue(user); + const extraArgs: SigninSilentArgs = { + extraQueryParams: { q : "q" }, + extraTokenParams: { t: "t" }, + state: "state", + nonce: "random_nonce", + redirect_uri: "http://app/extra_callback", + }; + subject.settings.dpopSettings.enabled = true; + subject.settings.dpopSettings.bind_authorization_code = true; + + // act + await subject.signinSilent(extraArgs); + + // assert + expect(subject["_signin"]).toHaveBeenCalledWith( + { + request_type: "si:s", + prompt: "none", + id_token_hint: undefined, + dpopJkt: expect.any(String), + ...extraArgs, + }, + expect.objectContaining({ + close: expect.any(Function), + navigate: expect.any(Function), + }), + undefined, + ); + }); + it("should work when having no user present", async () => { // arrange const user = new User({ @@ -545,6 +656,44 @@ describe("UserManager", () => { ); }); + it("should use DPoP when enabled when using a refresh token", async () => { + // arrange + const user = new User({ + access_token: "access_token", + token_type: "token_type", + refresh_token: "refresh_token", + profile: { + sub: "sub", + nickname: "Nick", + } as UserProfile, + }); + subject.settings.dpopSettings.enabled = true; + + const useRefreshTokenSpy = jest.spyOn(subject["_client"], "useRefreshToken").mockResolvedValue({ + access_token: "new_access_token", + profile: { + sub: "sub", + nickname: "Nicholas", + }, + } as unknown as SigninResponse); + subject["_loadUser"] = jest.fn().mockResolvedValue(user); + + // act + const refreshedUser = await subject.signinSilent(); + expect(refreshedUser).toHaveProperty("access_token", "new_access_token"); + expect(refreshedUser!.profile).toHaveProperty("nickname", "Nicholas"); + expect(useRefreshTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + state: { + refresh_token: user.refresh_token, + session_state: null, + "profile": { "nickname": "Nick", "sub": "sub" }, + }, + extraHeaders: { "DPoP": expect.any(String) }, + }), + ); + }); + it("should use the resource from settings when a refresh token is present", async () => { // arrange const user = new User({ diff --git a/src/UserManager.ts b/src/UserManager.ts index 81af6738c..df98648b3 100644 --- a/src/UserManager.ts +++ b/src/UserManager.ts @@ -15,6 +15,11 @@ import type { SignoutResponse } from "./SignoutResponse"; import type { MetadataService } from "./MetadataService"; import { RefreshState } from "./RefreshState"; import type { SigninResponse } from "./SigninResponse"; +import { IndexedDbCryptoKeyPairStore } from "./IndexedDbCryptoKeyPairStore"; +import { InMemoryWebStorage } from "./InMemoryWebStorage"; +import { WebStorageStateStore } from "./WebStorageStateStore"; +import type { ExtraHeader } from "./OidcClientSettings"; +import { DPoPService } from "./DPoPService"; /** * @public @@ -88,6 +93,7 @@ export class UserManager { protected readonly _events: UserManagerEvents; protected readonly _silentRenewService: SilentRenewService; protected readonly _sessionMonitor: SessionMonitor | null; + protected readonly _dpopNonceStore: WebStorageStateStore | null; public constructor(settings: UserManagerSettings, redirectNavigator?: INavigator, popupNavigator?: INavigator, iframeNavigator?: INavigator) { this.settings = new UserManagerSettingsStore(settings); @@ -111,6 +117,11 @@ export class UserManager { this._sessionMonitor = new SessionMonitor(this); } + this._dpopNonceStore = null; + if (this.settings.dpopSettings.enabled) { + const store = typeof window !== "undefined" ? window.sessionStorage : new InMemoryWebStorage(); + this._dpopNonceStore = new WebStorageStateStore( { store }); + } } /** @@ -154,6 +165,11 @@ export class UserManager { const logger = this._logger.create("removeUser"); await this.storeUser(null); logger.info("user removed from storage"); + if (this.settings.dpopSettings.enabled) { + await this._dpopNonceStore?.remove("dpop_nonce"); + await IndexedDbCryptoKeyPairStore.remove("oidc.dpop"); + logger.debug("removed dpop cyptokeys from storage"); + } await this._events.unload(); } @@ -179,7 +195,7 @@ export class UserManager { /** * Process the response (callback) from the authorization endpoint. - * It is recommend to use {@link UserManager.signinCallback} instead. + * It is recommended to use {@link UserManager.signinCallback} instead. * * @returns A promise containing the authenticated `User`. * @@ -231,6 +247,7 @@ export class UserManager { */ public async signinPopup(args: SigninPopupArgs = {}): Promise { const logger = this._logger.create("signinPopup"); + let dpopJkt: string | undefined; const { popupWindowFeatures, popupWindowTarget, @@ -241,11 +258,16 @@ export class UserManager { logger.throw(new Error("No popup_redirect_uri configured")); } + if (this.settings.dpopSettings.enabled && this.settings.dpopSettings.bind_authorization_code) { + dpopJkt = await DPoPService.generateDPoPJkt(); + } + const handle = await this._popupNavigator.prepare({ popupWindowFeatures, popupWindowTarget }); const user = await this._signin({ request_type: "si:p", redirect_uri: url, display: "popup", + dpopJkt, ...requestArgs, }, handle); if (user) { @@ -261,7 +283,7 @@ export class UserManager { } /** * Notify the opening window of response (callback) from the authorization endpoint. - * It is recommend to use {@link UserManager.signinCallback} instead. + * It is recommended to use {@link UserManager.signinCallback} instead. * * @returns A promise * @@ -297,6 +319,7 @@ export class UserManager { timeoutInSeconds: silentRequestTimeoutInSeconds, }); } + let dpopJkt: string | undefined; const url = this.settings.silent_redirect_uri; if (!url) { @@ -310,11 +333,15 @@ export class UserManager { } const handle = await this._iframeNavigator.prepare({ silentRequestTimeoutInSeconds }); + if (this.settings.dpopSettings.enabled && this.settings.dpopSettings.bind_authorization_code) { + dpopJkt = await DPoPService.generateDPoPJkt(); + } user = await this._signin({ request_type: "si:s", redirect_uri: url, prompt: "none", id_token_hint: this.settings.includeIdTokenInSilentRenew ? user?.id_token : undefined, + dpopJkt, ...requestArgs, }, handle, verifySub); if (user) { @@ -330,7 +357,13 @@ export class UserManager { } protected async _useRefreshToken(args: UseRefreshTokenArgs): Promise { + const extraHeaders: Record = {}; + if (this.settings.dpopSettings.enabled) { + const url = await this.metadataService.getTokenEndpoint(false); + extraHeaders["DPoP"] = await DPoPService.generateDPoPProof({ url, httpMethod: "POST" }); + } const response = await this._client.useRefreshToken({ + extraHeaders, ...args, timeoutInSeconds: this.settings.silentRequestTimeoutInSeconds, }); @@ -344,7 +377,7 @@ export class UserManager { /** * * Notify the parent window of response (callback) from the authorization endpoint. - * It is recommend to use {@link UserManager.signinCallback} instead. + * It is recommended to use {@link UserManager.signinCallback} instead. * * @returns A promise * @@ -438,7 +471,12 @@ export class UserManager { ...requestArgs, }, handle); try { - const signinResponse = await this._client.processSigninResponse(navResponse.url); + const extraHeaders: Record = {}; + if (this.settings.dpopSettings.enabled) { + const tokenUrl = await this.metadataService.getTokenEndpoint(false); + extraHeaders["DPoP"] = await DPoPService.generateDPoPProof({ url: tokenUrl, httpMethod: "POST" }); + } + const signinResponse = await this._client.processSigninResponse(navResponse.url, extraHeaders); logger.debug("got signin response"); if (signinResponse.session_state && signinResponse.profile.sub) { @@ -496,7 +534,12 @@ export class UserManager { } protected async _signinEnd(url: string, verifySub?: string): Promise { const logger = this._logger.create("_signinEnd"); - const signinResponse = await this._client.processSigninResponse(url); + const extraHeaders: Record = {}; + if (this.settings.dpopSettings.enabled) { + const tokenUrl = await this.metadataService.getTokenEndpoint(false); + extraHeaders["DPoP"] = await DPoPService.generateDPoPProof({ url: tokenUrl, httpMethod: "POST" }); + } + const signinResponse = await this._client.processSigninResponse(url, extraHeaders); logger.debug("got signin response"); const user = await this._buildUser(signinResponse, verifySub); @@ -543,7 +586,7 @@ export class UserManager { /** * Process response (callback) from the end session endpoint. - * It is recommend to use {@link UserManager.signoutCallback} instead. + * It is recommended to use {@link UserManager.signoutCallback} instead. * * @returns A promise containing signout response * diff --git a/src/UserManagerSettings.test.ts b/src/UserManagerSettings.test.ts index e1e3cb397..0c610876e 100644 --- a/src/UserManagerSettings.test.ts +++ b/src/UserManagerSettings.test.ts @@ -376,4 +376,20 @@ describe("UserManagerSettings", () => { expect(subject.stopCheckSessionOnError).toEqual(true); }); }); + + describe("dpop", () => { + it("should return value from initial settings", () => { + // act + const subject = new UserManagerSettingsStore({ + authority: "authority", + client_id: "client", + redirect_uri: "redirect", + stopCheckSessionOnError : false, + dpopSettings: { enabled: true }, + }); + + // assert + expect(subject.dpopSettings.enabled).toEqual(true); + }); + }); }); diff --git a/src/UserManagerSettings.ts b/src/UserManagerSettings.ts index cf6ae3662..4d1777114 100644 --- a/src/UserManagerSettings.ts +++ b/src/UserManagerSettings.ts @@ -17,6 +17,11 @@ const DefaultAccessTokenExpiringNotificationTimeInSeconds = 60; const DefaultCheckSessionIntervalInSeconds = 2; export const DefaultSilentRequestTimeoutInSeconds = 10; +export interface DPoPSettings { + enabled: boolean; + bind_authorization_code?: boolean; +} + /** * The settings used to configure the {@link UserManager}. * @@ -56,6 +61,11 @@ export interface UserManagerSettings extends OidcClientSettings { /** Flag to control if id_token is included as id_token_hint in silent renew calls (default: false) */ includeIdTokenInSilentRenew?: boolean; + /** Indicates whether to apply Dynamic Proof Of Possession when requesting an access token + * See https://datatracker.ietf.org/doc/html/rfc9449 + */ + dpopSettings?: DPoPSettings; + /** Will raise events for when user has performed a signout at the OP (default: false) */ monitorSession?: boolean; monitorAnonymousSession?: boolean; @@ -108,6 +118,8 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore { public readonly validateSubOnSilentRenew: boolean; public readonly includeIdTokenInSilentRenew: boolean; + public readonly dpopSettings: DPoPSettings; + public readonly monitorSession: boolean; public readonly monitorAnonymousSession: boolean; public readonly checkSessionIntervalInSeconds: number; @@ -140,6 +152,8 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore { validateSubOnSilentRenew = true, includeIdTokenInSilentRenew = false, + dpopSettings = { enabled: false, bind_authorization_code: false }, + monitorSession = false, monitorAnonymousSession = false, checkSessionIntervalInSeconds = DefaultCheckSessionIntervalInSeconds, @@ -173,6 +187,8 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore { this.validateSubOnSilentRenew = validateSubOnSilentRenew; this.includeIdTokenInSilentRenew = includeIdTokenInSilentRenew; + this.dpopSettings = dpopSettings; + this.monitorSession = monitorSession; this.monitorAnonymousSession = monitorAnonymousSession; this.checkSessionIntervalInSeconds = checkSessionIntervalInSeconds; diff --git a/src/index.ts b/src/index.ts index 387328c67..007e52560 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,3 +44,4 @@ export { UserManagerSettingsStore } from "./UserManagerSettings"; export type { UserManagerSettings } from "./UserManagerSettings"; export { Version } from "./Version"; export { WebStorageStateStore } from "./WebStorageStateStore"; +export { IndexedDbCryptoKeyPairStore } from "./IndexedDbCryptoKeyPairStore"; diff --git a/src/utils/CryptoUtils.test.ts b/src/utils/CryptoUtils.test.ts index d78f747d2..847abebef 100644 --- a/src/utils/CryptoUtils.test.ts +++ b/src/utils/CryptoUtils.test.ts @@ -12,4 +12,17 @@ describe("CryptoUtils", () => { expect(rnd).toMatch(pattern); }); }); + + describe("customCalculateJwkThumbprint", () => { + it("should return a valid rfc7638 jwk thumbprint", async () => { + const jwk = { + "kty": "EC", + "x": "zSau12OpG01OkWtiU8yG1ppv06v1uDrG66cNeqMWk_8", + "y": "Mjr6rkLy4chKd7f8m0ctUFEA2DtZuk_F09FU3h98xyo", + "crv": "P-256", + } as JsonWebKey; + const jwkThumbprint = await CryptoUtils.customCalculateJwkThumbprint(jwk); + expect(jwkThumbprint).toEqual("fvRy8PxXeUhrCgW4r0hAFroUAqSnmyiCncJmlCamt9g"); + }); + }); }); diff --git a/src/utils/CryptoUtils.ts b/src/utils/CryptoUtils.ts index 5f3fdd895..37ecbb9c6 100644 --- a/src/utils/CryptoUtils.ts +++ b/src/utils/CryptoUtils.ts @@ -62,4 +62,64 @@ export class CryptoUtils { const data = encoder.encode([client_id, client_secret].join(":")); return toBase64(data); } + + /** + * Generates a base64url encoded string + */ + public static encodeBase64Url = (input: Uint8Array) => { + return toBase64(input).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + }; + + /** + * Generates a hash of a string using a given algorithm + * @param alg + * @param message + */ + public static async hash(alg: string, message: string) : Promise { + const msgUint8 = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest(alg, msgUint8); + return new Uint8Array(hashBuffer); + } + + /** + * Generates a rfc7638 compliant jwk thumbprint + * @param jwk + */ + public static async customCalculateJwkThumbprint(jwk: JsonWebKey): Promise { + let jsonObject: object; + switch (jwk.kty) { + case "RSA": + jsonObject = { + "e": jwk.e, + "kty": jwk.kty, + "n": jwk.n, + }; + break; + case "EC": + jsonObject = { + "crv": jwk.crv, + "kty": jwk.kty, + "x": jwk.x, + "y": jwk.y, + }; + break; + case "OKP": + jsonObject = { + "crv": jwk.crv, + "kty": jwk.kty, + "x": jwk.x, + }; + break; + case "oct": + jsonObject = { + "crv": jwk.k, + "kty": jwk.kty, + }; + break; + default: + throw new Error("Unknown jwk type"); + } + const utf8encodedAndHashed = await CryptoUtils.hash("SHA-256", JSON.stringify(jsonObject)); + return CryptoUtils.encodeBase64Url(utf8encodedAndHashed); + } } diff --git a/src/utils/JwtUtils.test.ts b/src/utils/JwtUtils.test.ts index f7548cc5f..d9263c265 100644 --- a/src/utils/JwtUtils.test.ts +++ b/src/utils/JwtUtils.test.ts @@ -48,4 +48,43 @@ describe("JwtUtils", () => { } }); }); + + describe("createJwt", () => { + it("should be able to create identical jwts two different ways", async () => { + const keyPair = await window.crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + false, + ["sign", "verify"]); + + const jti = window.crypto.randomUUID(); + + const payload: Record = { + "jti": jti, + "htm": "GET", + "htu": "http://test.com", + }; + + const iat = Date.now(); + + const header = { + "alg": "ES256", + "typ": "dpop+jwt", + }; + payload.iat = iat; + const jwt = await JwtUtils.generateSignedJwt(header, payload, keyPair.privateKey); + + const result = JwtUtils.decode(jwt); + + expect(result).toEqual( + { + "jti": jti, + "htm": "GET", + "htu": "http://test.com", + "iat": iat, + }); + }); + }); }); diff --git a/src/utils/JwtUtils.ts b/src/utils/JwtUtils.ts index 9546b79c8..ef75adc3b 100644 --- a/src/utils/JwtUtils.ts +++ b/src/utils/JwtUtils.ts @@ -2,6 +2,7 @@ import { jwtDecode } from "jwt-decode"; import { Logger } from "./Logger"; import type { JwtClaims } from "../Claims"; +import { CryptoUtils } from "./CryptoUtils"; /** * @internal @@ -17,4 +18,22 @@ export class JwtUtils { throw err; } } + + public static async generateSignedJwt(header: object, payload: object, privateKey: CryptoKey) : Promise { + const encodedHeader = CryptoUtils.encodeBase64Url(new TextEncoder().encode(JSON.stringify(header))); + const encodedPayload = CryptoUtils.encodeBase64Url(new TextEncoder().encode(JSON.stringify(payload))); + const encodedToken = `${encodedHeader}.${encodedPayload}`; + + const signature = await window.crypto.subtle.sign( + { + name: "ECDSA", + hash: { name: "SHA-256" }, + }, + privateKey, + new TextEncoder().encode(encodedToken), + ); + + const encodedSignature = CryptoUtils.encodeBase64Url(new Uint8Array(signature)); + return `${encodedToken}.${encodedSignature}`; + } } diff --git a/test/setup.ts b/test/setup.ts index a38681ba8..26c27ef13 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,4 +1,5 @@ import { Log } from "../src"; +import "fake-indexeddb/auto"; import { TextEncoder } from "util"; import { webcrypto } from "node:crypto";