Skip to content

Commit 08e041a

Browse files
martinmlWidcket
andauthored
Add support for DPoP (#1388)
### Changes > [!IMPORTANT] > This PR is only the first part of the whole DPoP feature. The second PR is #1400. > > Because of this, **please do not make a new release until both PRs are merged**. This PR adds support for DPoP. It includes: - Key generation and secure storage. - Authorization code binding (i.e. `dpop_jkt` parameter). - Access token request binding (i.e. `DPoP` header). - Automatic nonce management and retries for Auth0 flows. It does NOT include: - Docs for the DPoP feature. - A way of using DPoP with external, custom APIs. Both of these ☝️ will be added in #1400, for an easier review. ℹ️ Notes to understand some changes: - Since some changes to core code were made (i.e. header parsing and `token_type` property in cache), some apparently unrelated tests had to be adapted. - Since the `dpop` package has some syntax that is too new for the current Jest configuration, transpilation had to be explicitely enabled for it: - The `ts-jest` preset was changed to `ts-jest/presets/js-with-ts`. - `allowJs` was enabled. - `dpop` was added to `transformIgnorePatterns`. - I added `fake-indexeddb` for testing, which needs a `structuredClone` implementation in `jsdom` but current version doesn't include it, so we use the Node implementation instead. - `ES2019.Object`, `DOM` and `DOM.Iterable` were added to the TS `lib`s so Fetch API's `Headers` interface can be used. This will not change the resulting code, just the types available when type-checking. ### Testing - [x] This change adds unit test coverage - [ ] This change adds integration test coverage - [x] This change has been tested on the latest version of the platform/language ### Checklist - [x] I have read the [Auth0 general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md) - [x] I have read the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md) - [x] All code quality tools/guidelines have been run/followed --------- Co-authored-by: Rita Zerrizuela <zeta@widcket.com>
1 parent 067c028 commit 08e041a

33 files changed

+1540
-81
lines changed

__tests__/Auth0Client/constructor.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { assertUrlEquals, loginWithRedirectFn, setupFn } from './helpers';
1010

1111
import { TEST_CLIENT_ID, TEST_CODE_CHALLENGE, TEST_DOMAIN } from '../constants';
1212
import { ICache } from '../../src/cache';
13+
import * as DpopModule from '../../src/dpop/dpop';
1314

1415
jest.mock('es-cookie');
1516
jest.mock('../../src/jwt');
@@ -65,6 +66,7 @@ describe('Auth0Client', () => {
6566
mockWindow.Worker = {};
6667
jest.spyOn(scope, 'getUniqueScopes');
6768
sessionStorage.clear();
69+
jest.spyOn(DpopModule, 'Dpop').mockReturnThis();
6870
});
6971

7072
afterEach(() => {
@@ -167,5 +169,18 @@ describe('Auth0Client', () => {
167169

168170
expect(mockCache.set).toHaveBeenCalled();
169171
});
172+
173+
it('does not create DPoP handler when is disabled', () => {
174+
const auth0 = setup({ useDpop: false });
175+
176+
expect(auth0['dpop']).toBeUndefined();
177+
});
178+
179+
it('creates a DPoP handler when enabled', () => {
180+
const auth0 = setup({ useDpop: true });
181+
182+
expect(auth0['dpop']).not.toBeUndefined();
183+
expect(DpopModule.Dpop).toHaveBeenCalledWith(TEST_CLIENT_ID);
184+
});
170185
});
171186
});

__tests__/Auth0Client/dpop.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* We don't need the DOM for this specific test suite.
3+
*
4+
* @jest-environment node
5+
*/
6+
7+
import { Auth0Client, Auth0ClientOptions } from '../../src';
8+
import {
9+
TEST_CLIENT_ID,
10+
TEST_DOMAIN,
11+
TEST_DPOP_NONCE,
12+
TEST_DPOP_PROOF
13+
} from '../constants';
14+
15+
import { beforeEach, describe, expect } from '@jest/globals';
16+
import { Dpop } from '../../src/dpop/dpop';
17+
18+
function newTestAuth0Client(
19+
extraOpts?: Partial<Auth0ClientOptions>
20+
): Auth0Client {
21+
return new Auth0Client({
22+
...extraOpts,
23+
domain: TEST_DOMAIN,
24+
clientId: TEST_CLIENT_ID
25+
});
26+
}
27+
28+
describe('Auth0Client', () => {
29+
beforeEach(() => {
30+
jest.resetAllMocks();
31+
jest.restoreAllMocks();
32+
});
33+
34+
describe('_assertDpop()', () => {
35+
const auth0 = newTestAuth0Client();
36+
37+
describe('DPoP disabled', () => {
38+
it('throws an error', () =>
39+
expect(() => auth0['_assertDpop'](undefined)).toThrow(
40+
'`useDpop` option must be enabled before using DPoP.'
41+
));
42+
});
43+
44+
describe('DPoP enabled', () => {
45+
const dpop = new Dpop(TEST_CLIENT_ID);
46+
47+
it('does not throw', () =>
48+
expect(() => auth0['_assertDpop'](dpop)).not.toThrow());
49+
});
50+
});
51+
52+
describe('getDpopNonce()', () => {
53+
const id = 'my_custom_api';
54+
const auth0 = newTestAuth0Client({ useDpop: true });
55+
const dpop = auth0['dpop']!;
56+
57+
beforeEach(() => {
58+
auth0['_assertDpop'] = jest.fn();
59+
jest.spyOn(dpop, 'getNonce').mockResolvedValue(TEST_DPOP_NONCE);
60+
});
61+
62+
let output: unknown;
63+
64+
beforeEach(async () => {
65+
output = await auth0.getDpopNonce(id);
66+
});
67+
68+
it('asserts DPoP is enabled', () =>
69+
expect(auth0['_assertDpop']).toHaveBeenCalled());
70+
71+
it('delegates into Dpop.getNonce()', () => {
72+
expect(dpop.getNonce).toHaveBeenCalledWith(id);
73+
expect(output).toBe(TEST_DPOP_NONCE);
74+
});
75+
});
76+
77+
describe('setDpopNonce()', () => {
78+
const id = 'my_custom_api';
79+
const auth0 = newTestAuth0Client({ useDpop: true });
80+
const dpop = auth0['dpop']!;
81+
82+
beforeEach(() => {
83+
auth0['_assertDpop'] = jest.fn();
84+
jest.spyOn(dpop, 'setNonce').mockResolvedValue();
85+
});
86+
87+
beforeEach(() => auth0.setDpopNonce(TEST_DPOP_NONCE, id));
88+
89+
it('asserts DPoP is enabled', () =>
90+
expect(auth0['_assertDpop']).toHaveBeenCalled());
91+
92+
it('delegates into Dpop.setNonce()', () =>
93+
expect(dpop.setNonce).toHaveBeenCalledWith(TEST_DPOP_NONCE, id));
94+
});
95+
96+
describe('generateDpopProof()', () => {
97+
const auth0 = newTestAuth0Client({ useDpop: true });
98+
const dpop = auth0['dpop']!;
99+
100+
const params: Parameters<Auth0Client['generateDpopProof']>[0] = {
101+
accessToken: 'test-access-token',
102+
method: 'test-method',
103+
url: 'test-url',
104+
nonce: 'test-nonce'
105+
};
106+
107+
beforeEach(() => {
108+
auth0['_assertDpop'] = jest.fn();
109+
jest.spyOn(dpop, 'generateProof').mockResolvedValue(TEST_DPOP_PROOF);
110+
});
111+
112+
let output: string;
113+
114+
beforeEach(async () => {
115+
output = await auth0.generateDpopProof(params);
116+
});
117+
118+
it('asserts DPoP is enabled', () =>
119+
expect(auth0['_assertDpop']).toHaveBeenCalled());
120+
121+
it('delegates into Dpop.generateProof()', () =>
122+
expect(dpop.generateProof).toHaveBeenCalledWith(params));
123+
124+
it('returns the proof', () => expect(output).toBe(TEST_DPOP_PROOF));
125+
});
126+
});

__tests__/Auth0Client/exchangeToken.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ describe('Auth0Client', () => {
9090
},
9191
id_token: 'fake_id_token',
9292
access_token: 'fake_access_token',
93+
token_type: 'Bearer',
9394
expires_in: 3600,
9495
scope: requestOptions.scope
9596
};
@@ -140,6 +141,7 @@ describe('Auth0Client', () => {
140141
},
141142
id_token: 'fake_id_token',
142143
access_token: 'fake_access_token',
144+
token_type: 'Bearer',
143145
expires_in: 3600,
144146
scope: requestOptions.scope
145147
};
@@ -190,6 +192,7 @@ describe('Auth0Client', () => {
190192
},
191193
id_token: 'fake_id_token',
192194
access_token: 'fake_access_token',
195+
token_type: 'Bearer',
193196
expires_in: 3600,
194197
scope: requestOptions.scope
195198
};
@@ -235,6 +238,7 @@ describe('Auth0Client', () => {
235238
},
236239
id_token: 'fake_id_token',
237240
access_token: 'fake_access_token',
241+
token_type: 'Bearer',
238242
expires_in: 3600,
239243
scope: requestOptions.scope
240244
};
@@ -297,6 +301,7 @@ describe('Auth0Client', () => {
297301
},
298302
id_token: 'fake_id_token',
299303
access_token: 'fake_access_token',
304+
token_type: 'Bearer',
300305
expires_in: 3600,
301306
scope: requestOptions.scope
302307
};

__tests__/Auth0Client/getTokenSilently.test.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1+
import { expect } from '@jest/globals';
12
import * as esCookie from 'es-cookie';
2-
import { verify } from '../../src/jwt';
33
import { MessageChannel } from 'worker_threads';
4-
import * as utils from '../../src/utils';
5-
import * as promiseUtils from '../../src/promise-utils';
6-
import * as scope from '../../src/scope';
74
import * as api from '../../src/api';
85
import * as http from '../../src/http';
9-
import { expect } from '@jest/globals';
6+
import { verify } from '../../src/jwt';
7+
import * as promiseUtils from '../../src/promise-utils';
8+
import * as scope from '../../src/scope';
9+
import * as utils from '../../src/utils';
1010

1111
import { expectToHaveBeenCalledWithAuth0ClientParam } from '../helpers';
1212

13-
import { GET_TOKEN_SILENTLY_LOCK_KEY, TEST_ORG_ID } from '../constants';
13+
import {
14+
GET_TOKEN_SILENTLY_LOCK_KEY,
15+
TEST_ORG_ID,
16+
TEST_TOKEN_TYPE
17+
} from '../constants';
1418

1519
// @ts-ignore
1620
import { acquireLockSpy } from 'browser-tabs-lock';
@@ -2148,7 +2152,8 @@ describe('Auth0Client', () => {
21482152
refresh_token: TEST_REFRESH_TOKEN,
21492153
access_token: TEST_ACCESS_TOKEN,
21502154
expires_in: 86400
2151-
})
2155+
}),
2156+
headers: new Headers()
21522157
})
21532158
);
21542159
// Fail only the first occurring /token request by providing it as mockImplementationOnce.
@@ -2160,7 +2165,8 @@ describe('Auth0Client', () => {
21602165
json: () => ({
21612166
error: 'invalid_grant',
21622167
error_description: INVALID_REFRESH_TOKEN_ERROR_MESSAGE
2163-
})
2168+
}),
2169+
headers: new Headers()
21642170
})
21652171
);
21662172

@@ -2189,7 +2195,8 @@ describe('Auth0Client', () => {
21892195
json: () => ({
21902196
error: 'invalid_grant',
21912197
error_description: INVALID_REFRESH_TOKEN_ERROR_MESSAGE
2192-
})
2198+
}),
2199+
headers: new Headers()
21932200
})
21942201
);
21952202

@@ -2267,6 +2274,7 @@ describe('Auth0Client', () => {
22672274
id_token: TEST_ID_TOKEN,
22682275
refresh_token: TEST_REFRESH_TOKEN,
22692276
access_token: TEST_ACCESS_TOKEN,
2277+
token_type: TEST_TOKEN_TYPE,
22702278
expires_in: 86400
22712279
})
22722280
);
@@ -2280,6 +2288,7 @@ describe('Auth0Client', () => {
22802288
expect(response).toStrictEqual({
22812289
id_token: TEST_ID_TOKEN,
22822290
access_token: TEST_ACCESS_TOKEN,
2291+
token_type: TEST_TOKEN_TYPE,
22832292
expires_in: 86400
22842293
});
22852294
});
@@ -2294,6 +2303,7 @@ describe('Auth0Client', () => {
22942303
id_token: TEST_ID_TOKEN,
22952304
refresh_token: TEST_REFRESH_TOKEN,
22962305
access_token: TEST_ACCESS_TOKEN,
2306+
token_type: TEST_TOKEN_TYPE,
22972307
expires_in: 86400,
22982308
scope: 'read:messages'
22992309
})
@@ -2308,6 +2318,7 @@ describe('Auth0Client', () => {
23082318
expect(response).toStrictEqual({
23092319
id_token: TEST_ID_TOKEN,
23102320
access_token: TEST_ACCESS_TOKEN,
2321+
token_type: TEST_TOKEN_TYPE,
23112322
expires_in: 86400,
23122323
scope: 'read:messages'
23132324
});
@@ -2332,6 +2343,7 @@ describe('Auth0Client', () => {
23322343
expect(response).toStrictEqual({
23332344
id_token: TEST_ID_TOKEN,
23342345
access_token: TEST_ACCESS_TOKEN,
2346+
token_type: TEST_TOKEN_TYPE,
23352347
expires_in: 86400
23362348
});
23372349

@@ -2397,6 +2409,7 @@ describe('Auth0Client', () => {
23972409
expect(response).toStrictEqual({
23982410
id_token: TEST_ID_TOKEN,
23992411
access_token: TEST_ACCESS_TOKEN,
2412+
token_type: TEST_TOKEN_TYPE,
24002413
expires_in: 86400,
24012414
scope: 'read:messages'
24022415
});

__tests__/Auth0Client/helpers.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import {
1919
TEST_ID_TOKEN,
2020
TEST_REDIRECT_URI,
2121
TEST_REFRESH_TOKEN,
22-
TEST_STATE
22+
TEST_STATE,
23+
TEST_TOKEN_TYPE
2324
} from '../constants';
2425
import { expect } from '@jest/globals';
2526
import { patchOpenUrlWithOnRedirect } from '../../src/Auth0Client.utils';
@@ -104,10 +105,15 @@ export const assertUrlEquals = (
104105
}
105106
};
106107

107-
export const fetchResponse = (ok, json) =>
108+
export const fetchResponse = (
109+
ok: boolean,
110+
json: unknown,
111+
headers: Record<string, string> = {}
112+
) =>
108113
Promise.resolve({
109114
ok,
110-
json: () => Promise.resolve(json)
115+
json: () => Promise.resolve(json),
116+
headers: new Headers(headers)
111117
});
112118

113119
export const setupFn = (mockVerify: jest.Mock) => {
@@ -238,6 +244,7 @@ export const loginWithRedirectFn = (mockWindow, mockFetch) => {
238244
id_token: TEST_ID_TOKEN,
239245
refresh_token: TEST_REFRESH_TOKEN,
240246
access_token: TEST_ACCESS_TOKEN,
247+
token_type: TEST_TOKEN_TYPE,
241248
expires_in: 86400
242249
},
243250
token.response
@@ -340,6 +347,7 @@ export const loginWithPopupFn = (mockWindow, mockFetch) => {
340347
id_token: TEST_ID_TOKEN,
341348
refresh_token: TEST_REFRESH_TOKEN,
342349
access_token: TEST_ACCESS_TOKEN,
350+
token_type: TEST_TOKEN_TYPE,
343351
expires_in: 86400
344352
},
345353
token.response
@@ -357,6 +365,7 @@ export const checkSessionFn = mockFetch => {
357365
id_token: TEST_ID_TOKEN,
358366
refresh_token: TEST_REFRESH_TOKEN,
359367
access_token: TEST_ACCESS_TOKEN,
368+
token_type: TEST_TOKEN_TYPE,
360369
expires_in: 86400
361370
})
362371
);
@@ -402,6 +411,7 @@ export const getTokenSilentlyFn = (mockWindow, mockFetch) => {
402411
id_token: TEST_ID_TOKEN,
403412
refresh_token: TEST_REFRESH_TOKEN,
404413
access_token: TEST_ACCESS_TOKEN,
414+
token_type: TEST_TOKEN_TYPE,
405415
expires_in: 86400
406416
},
407417
token.response

__tests__/Auth0Client/logout.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,19 @@ describe('Auth0Client', () => {
209209
);
210210
});
211211

212+
it('clears DPoP handler when present', async () => {
213+
const auth0 = setup({ useDpop: true });
214+
const dpop = auth0['dpop']!;
215+
216+
jest
217+
.spyOn(dpop, 'clear')
218+
.mockResolvedValue();
219+
220+
await auth0.logout();
221+
222+
expect(dpop.clear).toHaveBeenCalled();
223+
});
224+
212225
it('skips `window.location.assign` when `options.onRedirect` is provided', async () => {
213226
const auth0 = setup();
214227
const onRedirect = jest.fn();

0 commit comments

Comments
 (0)