Skip to content

Commit

Permalink
Add demonstrating proof of possession (#1461)
Browse files Browse the repository at this point in the history
* setup test env to handle TextEncoder + IndexedDb

* add dev deps for testing

* Add IndexedDb store to handle private crypto keys

* Add DPoPService and associated cypto + jwt helper functions

* Add DPoP settings to UserManagerSettings

* Wire up userManager to DPoP functionality for code exchange and refresh token

* Add tests for DPoPService exception handling


---------

Co-authored-by: Chris Keogh <chris.keogh@xero.com>
  • Loading branch information
dbfr3qs and Chris Keogh committed Jun 25, 2024
1 parent e36315d commit 307a4b3
Show file tree
Hide file tree
Showing 23 changed files with 1,102 additions and 34 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ node_modules/
.vscode/
temp/
.history/

# Jetbrains IDEs
.idea/
41 changes: 38 additions & 3 deletions docs/oidc-client-ts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export class CheckSessionIFrame {

// @public (undocumented)
export interface CreateSigninRequestArgs extends Omit<SigninRequestCreateArgs, "url" | "authority" | "client_id" | "redirect_uri" | "response_type" | "scope" | "state_data"> {
// (undocumented)
dpopJkt?: string;
// (undocumented)
redirect_uri?: string;
// (undocumented)
Expand Down Expand Up @@ -139,6 +141,28 @@ export interface INavigator {
prepare(params: unknown): Promise<IWindow>;
}

// Warning: (ae-forgotten-export) The symbol "DPoPStore" needs to be exported by the entry point index.d.ts
//
// @public
export class IndexedDbDPoPStore implements DPoPStore {
// (undocumented)
createStore<T>(dbName: string, storeName: string): Promise<(txMode: IDBTransactionMode, callback: (store: IDBObjectStore) => T | PromiseLike<T>) => Promise<T>>;
// (undocumented)
readonly _dbName: string;
// (undocumented)
get(key: string): Promise<CryptoKeyPair>;
// (undocumented)
getAllKeys(): Promise<string[]>;
// (undocumented)
promisifyRequest<T = undefined>(request: IDBRequest<T> | IDBTransaction): Promise<T>;
// (undocumented)
remove(key: string): Promise<CryptoKeyPair>;
// (undocumented)
set(key: string, value: CryptoKeyPair): Promise<void>;
// (undocumented)
readonly _storeName: string;
}

// @public (undocumented)
export class InMemoryWebStorage implements Storage {
// (undocumented)
Expand Down Expand Up @@ -301,10 +325,12 @@ export class OidcClient {
// (undocumented)
clearStaleState(): Promise<void>;
// (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, omitScopeWhenRequesting, }: CreateSigninRequestArgs): Promise<SigninRequest>;
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, dpopJkt, omitScopeWhenRequesting, }: CreateSigninRequestArgs): Promise<SigninRequest>;
// (undocumented)
createSignoutRequest({ state, id_token_hint, client_id, request_type, post_logout_redirect_uri, extraQueryParams, }?: CreateSignoutRequestArgs): Promise<SignoutRequest>;
// (undocumented)
getDpopProof(dpopStore: DPoPStore): Promise<string>;
// (undocumented)
protected readonly _logger: Logger;
// (undocumented)
readonly metadataService: MetadataService;
Expand Down Expand Up @@ -350,6 +376,8 @@ export interface OidcClientSettings {
client_secret?: string;
disablePKCE?: boolean;
display?: string;
// Warning: (ae-forgotten-export) The symbol "DPoPSettings" needs to be exported by the entry point index.d.ts
dpop?: DPoPSettings | undefined;
extraHeaders?: Record<string, ExtraHeader>;
extraQueryParams?: Record<string, string | number | boolean>;
// (undocumented)
Expand Down Expand Up @@ -384,7 +412,7 @@ export interface OidcClientSettings {

// @public
export class OidcClientSettingsStore {
constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, requestTimeoutInSeconds, staleStateAgeInSeconds, mergeClaimsStrategy, disablePKCE, stateStore, revokeTokenAdditionalContentTypes, fetchRequestCredentials, refreshTokenAllowedScope, extraQueryParams, extraTokenParams, extraHeaders, omitScopeWhenRequesting, }: OidcClientSettings);
constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, requestTimeoutInSeconds, staleStateAgeInSeconds, mergeClaimsStrategy, disablePKCE, stateStore, revokeTokenAdditionalContentTypes, fetchRequestCredentials, refreshTokenAllowedScope, extraQueryParams, extraTokenParams, extraHeaders, dpop, omitScopeWhenRequesting, }: OidcClientSettings);
// (undocumented)
readonly acr_values: string | undefined;
// (undocumented)
Expand All @@ -400,6 +428,8 @@ export class OidcClientSettingsStore {
// (undocumented)
readonly display: string | undefined;
// (undocumented)
readonly dpop: DPoPSettings | undefined;
// (undocumented)
readonly extraHeaders: Record<string, ExtraHeader>;
// (undocumented)
readonly extraQueryParams: Record<string, string | number | boolean>;
Expand Down Expand Up @@ -629,7 +659,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, omitScopeWhenRequesting, ...optionalParams }: SigninRequestCreateArgs): Promise<SigninRequest>;
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, omitScopeWhenRequesting, ...optionalParams }: SigninRequestCreateArgs): Promise<SigninRequest>;
// (undocumented)
readonly state: SigninState;
// (undocumented)
Expand All @@ -651,6 +681,8 @@ export interface SigninRequestCreateArgs {
// (undocumented)
display?: string;
// (undocumented)
dpopJkt?: string;
// (undocumented)
extraQueryParams?: Record<string, string | number | boolean>;
// (undocumented)
extraTokenParams?: Record<string, unknown>;
Expand Down Expand Up @@ -957,9 +989,12 @@ export class UserManager {
clearStaleState(): Promise<void>;
// (undocumented)
protected readonly _client: OidcClient;
dpopProof(url: string, user: User, httpMethod?: string): Promise<string | undefined>;
get events(): UserManagerEvents;
// (undocumented)
protected readonly _events: UserManagerEvents;
// (undocumented)
generateDPoPJkt(dpopSettings: DPoPSettings): Promise<string | undefined>;
getUser(): Promise<User | null>;
// (undocumented)
protected readonly _iframeNavigator: INavigator;
Expand Down
28 changes: 28 additions & 0 deletions jest-environment-jsdom.cjs
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 4 additions & 1 deletion jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@
"eslint": "^8.5.0",
"eslint-plugin-testing-library": "^6.0.0",
"http-proxy-middleware": "^3.0.0",
"fake-indexeddb": "^5.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",
Expand Down
9 changes: 9 additions & 0 deletions src/DPoPStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @public
*/
export interface DPoPStore {
set(key: string, value: CryptoKeyPair): Promise<void>;
get(key: string): Promise<CryptoKeyPair>;
remove(key: string): Promise<CryptoKeyPair>;
getAllKeys(): Promise<string[]>;
}
94 changes: 94 additions & 0 deletions src/IndexedDbDPoPStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { IndexedDbDPoPStore } from "./IndexedDbDPoPStore";

describe("DPoPStore", () => {
const subject = new IndexedDbDPoPStore();

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");
});
});
});
68 changes: 68 additions & 0 deletions src/IndexedDbDPoPStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { DPoPStore } from "./DPoPStore";

/**
* Provides a default implementation of the DPoP store using IndexedDB.
*
* @public
*/
export class IndexedDbDPoPStore implements DPoPStore {
readonly _dbName: string = "oidc";
readonly _storeName: string = "dpop";

public async set(key: string, value: CryptoKeyPair): Promise<void> {
const store = await this.createStore(this._dbName, this._storeName);
await store("readwrite", (str: IDBObjectStore) => {
str.put(value, key);
return this.promisifyRequest(str.transaction);
});
}

public async get(key: string): Promise<CryptoKeyPair> {
const store = await this.createStore(this._dbName, this._storeName);
return await store("readonly", (str) => {
return this.promisifyRequest(str.get(key));
}) as CryptoKeyPair;
}

public async remove(key: string): Promise<CryptoKeyPair> {
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;
}

public async getAllKeys(): Promise<string[]> {
const store = await this.createStore(this._dbName, this._storeName);
return await store("readonly", (str) => {
return this.promisifyRequest(str.getAllKeys());
}) as string[];
}

promisifyRequest<T = undefined>(
request: IDBRequest<T> | IDBTransaction): Promise<T> {
return new Promise<T>((resolve, reject) => {
(request as IDBTransaction).oncomplete = (request as IDBRequest<T>).onsuccess = () => resolve((request as IDBRequest<T>).result);
(request as IDBTransaction).onabort = (request as IDBRequest<T>).onerror = () => reject((request as IDBRequest<T>).error);
});
}

async createStore<T>(
dbName: string,
storeName: string,
): Promise<(txMode: IDBTransactionMode, callback: (store: IDBObjectStore) => T | PromiseLike<T>) => Promise<T>> {
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
const db = await this.promisifyRequest<IDBDatabase>(request);

return async (
txMode: IDBTransactionMode,
callback: (store: IDBObjectStore) => T | PromiseLike<T>,
) => {
const tx = db.transaction(storeName, txMode);
const store = tx.objectStore(storeName);
return await callback(store);
};
}
}
Loading

0 comments on commit 307a4b3

Please sign in to comment.