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

Demonstrating Proof of Possession #1361

Closed
wants to merge 38 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
dd5adc6
Add initial DPoPService to create proofs when exchanging a secret
Nov 30, 2023
4bc0be5
Expose method on User class to access dpop proof after dpop bound acc…
Dec 1, 2023
460da52
Persist dpop CryptoKeyPair to indexeddb
Dec 3, 2023
d8d9ad6
refactor dpop service
Dec 7, 2023
542faa0
add DPoPService tests and setup jsdom to handle TextEncoder, TextDeco…
Dec 7, 2023
5d832fb
add optional dpop boolean to OidcClientSettings
Dec 7, 2023
9c5dd61
add test for ClientSettingsStore.dpop
Dec 10, 2023
ba16e05
Correct DPoP header name
Dec 10, 2023
ae148e5
Throw error if dpop keys are not found in the store, add tests
Dec 11, 2023
162ce9f
Remove dpop keys from storage on signout if dpop enabled
Dec 11, 2023
34a6407
add test from removing dpop keys when removing user
Dec 12, 2023
1f4320a
rename dpop setting to dpopEnabled
Dec 12, 2023
8838369
add test for invalid CryptoKeyPair in indexeddb, add failing test for…
Dec 13, 2023
1dbb762
rename dpop setting to dpopEnabled in oidcClientSettings
Dec 14, 2023
323aa5c
Add first cut support for bind DPoP key to auth code
Dec 14, 2023
d8840fa
Refactor dpopservice
Dec 14, 2023
fbe0b32
make DPoPService static
Jan 16, 2024
c9c3ee1
add DPoPSettings interface
Jan 16, 2024
9e4f76e
Include dpop key thumbprint if dpop enabled and bind_authorization_co…
Jan 24, 2024
7d7c7e8
Minor clean up
Jan 24, 2024
13cb759
Fix merge conflicts with main
Jan 24, 2024
9b2d100
remove jose jwt creation and base64url encode
Jan 31, 2024
47d7d40
remove jose entirely for DPoPService
Feb 1, 2024
9036ad0
Move functions into relevant util module and fix jwk serialisation error
Feb 7, 2024
50900fd
Move jose to dev deps for now
Feb 7, 2024
7085388
Move jwkthumbprint code and add test
Feb 12, 2024
4c5d727
Create IndexedDbStore class
Feb 19, 2024
fe84f3c
remove idb-keyval dep entirely
Feb 20, 2024
78891f0
add more tests for IndexedDbCryptoKeyPairStore
Feb 22, 2024
45302fb
change indexeddb db and store names
Feb 22, 2024
d78885f
Move dpopEnabdle out of json service and extend postFormOpts with ext…
Feb 26, 2024
92f5458
Store dpop nonce in session storage when received from authz server
Mar 5, 2024
d5067a6
remove nonce support for now, will do this separately in another PR
Mar 6, 2024
e71ddc5
Merge branch 'main' into DPoP
Mar 6, 2024
88af397
Use Opts interface for generateDPoPProof parameters
Mar 20, 2024
04b1a97
pull up DPoP into UserManager
Mar 20, 2024
284b6ca
Pull up DPoP settings out of OidcClientSettings and into UserManagerS…
Mar 20, 2024
1e019ea
refactor JsonService extraHeaders test
Mar 20, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ node_modules/
.vscode/
temp/
.history/
.idea/
42 changes: 38 additions & 4 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,26 @@ export interface INavigator {
prepare(params: unknown): Promise<IWindow>;
}

// @public (undocumented)
export class IndexedDbCryptoKeyPairStore {
// (undocumented)
static createStore<T>(dbName: string, storeName: string): Promise<(txMode: IDBTransactionMode, callback: (store: IDBObjectStore) => T | PromiseLike<T>) => Promise<T>>;
// (undocumented)
static dbName: string;
// (undocumented)
static get(key: string): Promise<CryptoKeyPair | null>;
// (undocumented)
static getAllKeys(): Promise<string[]>;
// (undocumented)
static promisifyRequest<T = undefined>(request: IDBRequest<T> | IDBTransaction): Promise<T>;
// (undocumented)
static remove(key: string): Promise<CryptoKeyPair | null>;
// (undocumented)
static set(key: string, value: CryptoKeyPair): Promise<void>;
// (undocumented)
static storeName: string;
}

// @public (undocumented)
export class InMemoryWebStorage implements Storage {
// (undocumented)
Expand Down Expand Up @@ -301,7 +323,7 @@ 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, }: CreateSigninRequestArgs): Promise<SigninRequest>;
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<SigninRequest>;
// (undocumented)
createSignoutRequest({ state, id_token_hint, client_id, request_type, post_logout_redirect_uri, extraQueryParams, }?: CreateSignoutRequestArgs): Promise<SignoutRequest>;
// (undocumented)
Expand All @@ -311,7 +333,7 @@ export class OidcClient {
// (undocumented)
processResourceOwnerPasswordCredentials({ username, password, skipUserInfo, extraTokenParams, }: ProcessResourceOwnerPasswordCredentialsArgs): Promise<SigninResponse>;
// (undocumented)
processSigninResponse(url: string): Promise<SigninResponse>;
processSigninResponse(url: string, extraHeaders?: Record<string, ExtraHeader>): Promise<SigninResponse>;
// (undocumented)
processSignoutResponse(url: string): Promise<SignoutResponse>;
// (undocumented)
Expand All @@ -333,7 +355,7 @@ export class OidcClient {
// (undocumented)
protected readonly _tokenClient: TokenClient;
// (undocumented)
useRefreshToken({ state, redirect_uri, resource, timeoutInSeconds, extraTokenParams, }: UseRefreshTokenArgs): Promise<SigninResponse>;
useRefreshToken({ state, redirect_uri, resource, timeoutInSeconds, extraHeaders, extraTokenParams, }: UseRefreshTokenArgs): Promise<SigninResponse>;
// Warning: (ae-forgotten-export) The symbol "ResponseValidator" needs to be exported by the entry point index.d.ts
//
// (undocumented)
Expand Down Expand Up @@ -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<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, ...optionalParams }: SigninRequestCreateArgs): Promise<SigninRequest>;
// (undocumented)
readonly state: SigninState;
// (undocumented)
Expand All @@ -645,6 +667,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 @@ -902,6 +926,8 @@ export class User {
url_state?: string;
});
access_token: string;
// (undocumented)
dpopProof(url: string, httpMethod?: string): Promise<string>;
get expired(): boolean | undefined;
expires_at?: number;
get expires_in(): number | undefined;
Expand All @@ -924,6 +950,8 @@ export class User {

// @public (undocumented)
export interface UseRefreshTokenArgs {
// (undocumented)
extraHeaders?: Record<string, ExtraHeader>;
// (undocumented)
extraTokenParams?: Record<string, unknown>;
// (undocumented)
Expand All @@ -947,6 +975,8 @@ export class UserManager {
clearStaleState(): Promise<void>;
// (undocumented)
protected readonly _client: OidcClient;
// (undocumented)
protected readonly _dpopNonceStore: WebStorageStateStore | null;
get events(): UserManagerEvents;
// (undocumented)
protected readonly _events: UserManagerEvents;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

jsdom doesn't support some apis so I had to work around that.
jsdom/jsdom#2524

Also some issues with jest and esm modules...
https://stackoverflow.com/questions/76608600/jest-tests-are-failing-because-of-an-unknown-unexpected-token-export

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
26 changes: 26 additions & 0 deletions package-lock.json

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

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"prepare": "husky install"
},
"dependencies": {
"crypto-js": "^4.1.1",
"jwt-decode": "^4.0.0"
},
"devDependencies": {
Expand All @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions src/DPoPService.test.ts
Original file line number Diff line number Diff line change
@@ -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(<JWK>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(<JWK>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();
});
});
});
Loading