Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add demonstrating proof of possession #1461

Merged
merged 34 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d2f1440
setup test env to handle TextEncoder + IndexedDb
Apr 3, 2024
1008c98
add dev deps for testing
Apr 3, 2024
2bd6ad0
Add IndexedDb store to handle private crypto keys
Apr 3, 2024
489fbdc
Add DPoPService and associated cypto + jwt helper functions
Apr 3, 2024
749fa5c
Add DPoP settings to UserManagerSettings
Apr 3, 2024
dec3112
Wire up userManager to DPoP functionality for code exchange and refre…
Apr 3, 2024
b0973a0
Add tests for DPoPService exception handling
Apr 4, 2024
3c64e6a
Add test to gover retreiving a proof from the User class
Apr 4, 2024
ec37bf9
Remove unneccesary nonce code
Apr 4, 2024
c996199
Add test to cover dpopJkt in SignInRequest settings
Apr 5, 2024
3e32c76
Move UserManager dpop test into describe block
Apr 5, 2024
e571f7f
Add test coverage to CryptoUtils.customCalculateJwkThumbprint
Apr 5, 2024
2a8e78b
Refactor DPoPService to non static class
Apr 10, 2024
101871a
Remove dpopProof method
Apr 10, 2024
b998075
Add dpopProof method for external consumers
Apr 10, 2024
a152328
Create DPoPStorageStateStore that extends WebStorageStateStore and im…
Apr 14, 2024
0a73078
Re-organise dpop code into CyrptoUtils, DPoPStore and OidcClient
Jun 13, 2024
5212489
Add tests to cover reorganisation of code
Jun 13, 2024
77a0827
Make DPoPStore implementation optional
Jun 13, 2024
4cc3d75
Undo changes to StateStore
Jun 13, 2024
f145f32
Simplify DPoPStore interface
Jun 13, 2024
e2e07ed
Rename IndexDbDPoPStore to IndexedDbDPoPStore
Jun 13, 2024
fcb17fd
Re-implement dpop key auth code binding
Jun 13, 2024
fe2eeff
Remove redundant import and dpopstore prop on oidcClient
Jun 13, 2024
31a55c0
Add tests for dpop settings
Jun 14, 2024
478bbb2
Remove redundant dpopsettings on UserManagerSettings
Jun 14, 2024
155e9f3
refactor dpopJkt into private method, add test for signinRedirect
Jun 17, 2024
ea62cf5
Undo unneccessary changes to UserManager
Jun 17, 2024
c0a66ef
Tidy up
Jun 18, 2024
a92f2db
Fix styling and make dpop.store non optional.
Jun 24, 2024
e686ad5
Throw exception if dpop configured without a store
Jun 25, 2024
1e86e22
Resolve conflicts with main
Jun 25, 2024
12ac53f
Move removal of dpop Keys into UserManager.storeUser when user is null
Jun 25, 2024
7764078
Remove if statements from tests
Jun 25, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading