From 6eb2170b1b0df176e2fa608ba850da0e3c4fa361 Mon Sep 17 00:00:00 2001 From: Jared Perreault Date: Fri, 23 May 2025 15:33:59 -0400 Subject: [PATCH 1/6] feat: coordinator clock offset with auth server --- packages/auth-foundation/src/oauth2/client.ts | 13 +++++++++++++ .../src/utils/TimeCoordinator.ts | 18 ++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/auth-foundation/src/oauth2/client.ts b/packages/auth-foundation/src/oauth2/client.ts index 564b9c3..9122851 100644 --- a/packages/auth-foundation/src/oauth2/client.ts +++ b/packages/auth-foundation/src/oauth2/client.ts @@ -28,6 +28,7 @@ import { UserInfo } from './requests/UserInfo.ts'; import { PromiseQueue } from '../utils/PromiseQueue.ts'; import { EventEmitter } from '../utils/EventEmitter.ts'; import { hasSameValues } from '../utils/index.ts'; +import TimeCoordinator, { Timestamp } from '../utils/TimeCoordinator.ts'; // ref: https://developer.okta.com/docs/reference/api/oidc/ @@ -105,6 +106,18 @@ export class OAuth2Client e await this.dpopSigningAuthority.sign(request, { keyPairId: dpopPairId, nonce }); } + protected async processResponse(response: Response, request: APIRequest): Promise { + await super.processResponse(response, request); + + // NOTE: this logic will not work on CORS requests, the Date header needs to be allowlisted via access-control-expose-headers + const dateHeader = response.headers.get('date'); + if (dateHeader) { + const serverTime = Timestamp.from(new Date(dateHeader)); + const skew = Math.round(serverTime.timeSince(Date.now() / 1000)); + TimeCoordinator.clockSkew = skew; + } + } + /** @internal */ protected async getJson (url: URL, options: OAuth2Client.GetJsonOptions = {}): Promise { const { skipCache } = { ...OAuth2Client.DefaultGetJsonOptions, ...options }; diff --git a/packages/auth-foundation/src/utils/TimeCoordinator.ts b/packages/auth-foundation/src/utils/TimeCoordinator.ts index c0b9ccd..3fd8878 100644 --- a/packages/auth-foundation/src/utils/TimeCoordinator.ts +++ b/packages/auth-foundation/src/utils/TimeCoordinator.ts @@ -83,16 +83,26 @@ export class Timestamp { */ // TODO: implement (post beta) class TimeCoordinator { + #skew = 0; + static #tolerance = 0; // TODO: adjust from http time headers // (backend change needed to allow Date header in CORS requests) - get clockSkew () { - return 0; + get clockSkew (): number { + return this.#skew; + } + + set clockSkew (skew: number) { + this.#skew = skew; } // TODO: accept via config option - static get clockTolerance () { - return 0; + static get clockTolerance (): number { + return TimeCoordinator.#tolerance; + } + + static set clockTolerance (tolerance: number) { + TimeCoordinator.#tolerance = tolerance; } now (): Timestamp { From fabc10ddef8512146ec247b28cb728a2a0f236ce Mon Sep 17 00:00:00 2001 From: Jared Perreault Date: Sat, 28 Feb 2026 14:43:13 -0500 Subject: [PATCH 2/6] adds unit tests --- packages/auth-foundation/src/oauth2/client.ts | 17 +-- .../src/oauth2/configuration.ts | 27 ++++- .../test/spec/oauth2/client.spec.ts | 102 ++++++++++++++++++ .../test/spec/oauth2/configuration.spec.ts | 7 +- 4 files changed, 142 insertions(+), 11 deletions(-) diff --git a/packages/auth-foundation/src/oauth2/client.ts b/packages/auth-foundation/src/oauth2/client.ts index 9122851..2c24906 100644 --- a/packages/auth-foundation/src/oauth2/client.ts +++ b/packages/auth-foundation/src/oauth2/client.ts @@ -109,12 +109,17 @@ export class OAuth2Client e protected async processResponse(response: Response, request: APIRequest): Promise { await super.processResponse(response, request); - // NOTE: this logic will not work on CORS requests, the Date header needs to be allowlisted via access-control-expose-headers - const dateHeader = response.headers.get('date'); - if (dateHeader) { - const serverTime = Timestamp.from(new Date(dateHeader)); - const skew = Math.round(serverTime.timeSince(Date.now() / 1000)); - TimeCoordinator.clockSkew = skew; + if (this.configuration.syncClockWithAuthorizationServer) { + // NOTE: this logic will not work on CORS requests, the Date header needs to be allowlisted via access-control-expose-headers + const dateHeader = response.headers.get('date'); + if (dateHeader) { + const parsedDate = new Date(dateHeader); + if (parsedDate.toString() !== 'Invalid Date') { + const serverTime = Timestamp.from(parsedDate); + const skew = Math.round(serverTime.timeSince(Date.now() / 1000)); + TimeCoordinator.clockSkew = skew; + } + } } } diff --git a/packages/auth-foundation/src/oauth2/configuration.ts b/packages/auth-foundation/src/oauth2/configuration.ts index 1338919..f07b0f0 100644 --- a/packages/auth-foundation/src/oauth2/configuration.ts +++ b/packages/auth-foundation/src/oauth2/configuration.ts @@ -24,7 +24,8 @@ export type OAuth2ClientConfigurations = DiscrimUnion & typeof APIClient.Configuration.DefaultOptions = { ...APIClient.Configuration.DefaultOptions, allowHTTP: false, + syncClockWithAuthorizationServer: true, authentication: 'none' }; @@ -61,7 +77,8 @@ export class Configuration extends APIClient.Configuration implements APIClientC scopes, authentication, dpop, - allowHTTP + allowHTTP, + syncClockWithAuthorizationServer } = { ...Configuration.DefaultOptions, ...params }; const url = issuer ?? baseURL; // one of them must be defined via Discriminated Union if (!validateURL(url, allowHTTP)) { @@ -77,6 +94,7 @@ export class Configuration extends APIClient.Configuration implements APIClientC // default values are set in `static DefaultOptions` this.authentication = authentication; this.allowHTTP = allowHTTP; + this.syncClockWithAuthorizationServer = syncClockWithAuthorizationServer; } /** @@ -112,7 +130,7 @@ export class Configuration extends APIClient.Configuration implements APIClientC } toJSON (): JsonRecord { - const { issuer, discoveryURL, clientId, scopes, authentication, allowHTTP } = this; + const { issuer, discoveryURL, clientId, scopes, authentication, allowHTTP, syncClockWithAuthorizationServer } = this; return { ...super.toJSON(), issuer: issuer.href, @@ -120,7 +138,8 @@ export class Configuration extends APIClient.Configuration implements APIClientC clientId, scopes, authentication, - allowHTTP + allowHTTP, + syncClockWithAuthorizationServer }; } } diff --git a/packages/auth-foundation/test/spec/oauth2/client.spec.ts b/packages/auth-foundation/test/spec/oauth2/client.spec.ts index 0c79970..18ae5fe 100644 --- a/packages/auth-foundation/test/spec/oauth2/client.spec.ts +++ b/packages/auth-foundation/test/spec/oauth2/client.spec.ts @@ -830,4 +830,106 @@ describe('OAuth2Client', () => { }); }); }); + + describe('features', () => { + describe('Clock Synchronization', () => { + let testContext: any = {}; + + beforeEach(async () => { + fetchSpy.mockReset(); + const client = new OAuth2Client(params); + const original = new Token(mockTokenResponse()); + const tokenResponse = mockTokenResponse(); + + jest.spyOn(client, 'openIdConfiguration').mockResolvedValue({ + issuer: 'https://fake.okta.com', + token_endpoint: 'https://fake.okta.com/token' + }); + jest.spyOn((client as any), 'jwks').mockResolvedValue({ keys: [{ kid: 'foo', alg: 'bar'}]}); + + const TimeCoordinator = (await import('src/utils/TimeCoordinator')).default; + testContext = { client, original, tokenResponse, TimeCoordinator }; + }); + + afterEach(() => { + // needed to reset the dynamically imported `TimeCoordinator` singleton instance + jest.resetModules(); + }); + + it('should calculate clock skew when Date header is available', async () => { + const { TimeCoordinator, client, original, tokenResponse } = testContext; + const skewSetterSpy = jest.spyOn(TimeCoordinator, 'clockSkew', 'set'); + + const date15MinsBefore = new Date(Date.now() - (1000 * 60 * 15)); // 15 minutes before now + fetchSpy.mockResolvedValue(Response.json(tokenResponse, { + headers: { date: date15MinsBefore.toUTCString() } + })); + + expect(TimeCoordinator.clockSkew).toBe(0); + await client.refresh(original); + // NOTE: real time math is done, so value will be rounded and can be between -899 and -901 + expect(TimeCoordinator.clockSkew).toBeLessThan(-898); + expect(TimeCoordinator.clockSkew).toBeGreaterThan(-902); + + TimeCoordinator.clockSkew = 0; // reset + + const date15MinsAfter = new Date(Date.now() + (1000 * 60 * 15)); // 15 minutes after now + fetchSpy.mockResolvedValue(Response.json(tokenResponse, { + headers: { date: date15MinsAfter.toUTCString() } + })); + + expect(TimeCoordinator.clockSkew).toBe(0); + await client.refresh(original); + // NOTE: real time math is done, so value will be rounded and can be between 899 or 901 + expect(TimeCoordinator.clockSkew).toBeLessThan(902); + expect(TimeCoordinator.clockSkew).toBeGreaterThan(898); + + // NOTE: one call is done manually to reset (twice via .refresh(), once for manual reset) + expect(skewSetterSpy).toHaveBeenCalledTimes(3); + }); + + it('should ignore Date header when value isn\'t a valid date string', async () => { + const { TimeCoordinator, client, original, tokenResponse } = testContext; + expect(TimeCoordinator.clockSkew).toBe(0); + const skewSetterSpy = jest.spyOn(TimeCoordinator, 'clockSkew', 'set'); + + fetchSpy.mockResolvedValue(Response.json(tokenResponse, { + headers: { date: 'some random string' } + })); + + await client.refresh(original); + expect(TimeCoordinator.clockSkew).toBe(0); + expect(skewSetterSpy).not.toHaveBeenCalled(); + }); + + it('should gracefully skip when Date header is not available', async () => { + const { TimeCoordinator, client, original, tokenResponse } = testContext; + expect(TimeCoordinator.clockSkew).toBe(0); + const skewSetterSpy = jest.spyOn(TimeCoordinator, 'clockSkew', 'set'); + + fetchSpy.mockResolvedValue(Response.json(tokenResponse)); + + await client.refresh(original); + expect(TimeCoordinator.clockSkew).toBe(0); + expect(skewSetterSpy).not.toHaveBeenCalled(); + }); + + it('should skip when configured off', async () => { + const { TimeCoordinator, client, original, tokenResponse } = testContext; + expect(TimeCoordinator.clockSkew).toBe(0); + + const skewSetterSpy = jest.spyOn(TimeCoordinator, 'clockSkew', 'set'); + client.configuration.syncClockWithAuthorizationServer = false; + + const date15MinsAfter = new Date(Date.now() + (1000 * 60 * 15)); // 15 minutes after now + fetchSpy.mockResolvedValue(Response.json(tokenResponse, { + headers: { date: date15MinsAfter.toUTCString() } + })); + + await client.refresh(original); + expect(TimeCoordinator.clockSkew).toBe(0); + expect(skewSetterSpy).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/auth-foundation/test/spec/oauth2/configuration.spec.ts b/packages/auth-foundation/test/spec/oauth2/configuration.spec.ts index 93c2a78..f709947 100644 --- a/packages/auth-foundation/test/spec/oauth2/configuration.spec.ts +++ b/packages/auth-foundation/test/spec/oauth2/configuration.spec.ts @@ -69,6 +69,7 @@ describe('OAuth2Client.Configuration', () => { }); expect(c1.issuer.href).toEqual('https://foo.com/'); expect(c1.authentication).toEqual('none'); + expect(c1.syncClockWithAuthorizationServer).toEqual(true); expect(c1.allowHTTP).toEqual(false); expect(c1.dpop).toEqual(false); expect(c1.fetchImpl).toEqual(undefined); @@ -76,6 +77,7 @@ describe('OAuth2Client.Configuration', () => { // override default configurations OAuth2Client.Configuration.DefaultOptions.allowHTTP = true; OAuth2Client.Configuration.DefaultOptions.dpop = true; + OAuth2Client.Configuration.DefaultOptions.syncClockWithAuthorizationServer = false; const c2 = new OAuth2Client.Configuration({ baseURL: 'https://foo.com', @@ -84,6 +86,7 @@ describe('OAuth2Client.Configuration', () => { }); expect(c2.issuer.href).toEqual('https://foo.com/'); expect(c2.authentication).toEqual('none'); + expect(c2.syncClockWithAuthorizationServer).toEqual(false); expect(c2.allowHTTP).toEqual(true); expect(c2.dpop).toEqual(true); expect(c2.fetchImpl).toEqual(undefined); @@ -94,10 +97,12 @@ describe('OAuth2Client.Configuration', () => { clientId: 'fakeclientid', scopes: 'openid email profile', dpop: false, - allowHTTP: false + allowHTTP: false, + syncClockWithAuthorizationServer: true }); expect(c4.issuer.href).toEqual('https://foo.com/'); expect(c4.authentication).toEqual('none'); + expect(c4.syncClockWithAuthorizationServer).toEqual(true); expect(c4.allowHTTP).toEqual(false); expect(c4.dpop).toEqual(false); expect(c4.fetchImpl).toEqual(undefined); From c47163b442fc325c6fe31a4d9693f9e6dcf73b81 Mon Sep 17 00:00:00 2001 From: Jared Perreault Date: Mon, 2 Mar 2026 07:30:56 -0500 Subject: [PATCH 3/6] version bump --- package.json | 2 +- packages/auth-foundation/package.json | 2 +- packages/oauth2-flows/package.json | 2 +- packages/spa-platform/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 1ac5594..5971cb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@okta/okta-client-js", - "version": "0.5.4", + "version": "0.5.5", "private": true, "packageManager": "yarn@1.22.19", "engines": { diff --git a/packages/auth-foundation/package.json b/packages/auth-foundation/package.json index b05155c..3e01842 100644 --- a/packages/auth-foundation/package.json +++ b/packages/auth-foundation/package.json @@ -1,6 +1,6 @@ { "name": "@okta/auth-foundation", - "version": "0.5.4", + "version": "0.5.5", "type": "module", "main": "dist/esm/index.js", "module": "dist/esm/index.js", diff --git a/packages/oauth2-flows/package.json b/packages/oauth2-flows/package.json index 5f77b77..2401c5d 100644 --- a/packages/oauth2-flows/package.json +++ b/packages/oauth2-flows/package.json @@ -1,6 +1,6 @@ { "name": "@okta/oauth2-flows", - "version": "0.5.4", + "version": "0.5.5", "type": "module", "main": "dist/esm/index.js", "module": "dist/esm/index.js", diff --git a/packages/spa-platform/package.json b/packages/spa-platform/package.json index 5a9eb8d..c403d2d 100644 --- a/packages/spa-platform/package.json +++ b/packages/spa-platform/package.json @@ -1,6 +1,6 @@ { "name": "@okta/spa-platform", - "version": "0.5.4", + "version": "0.5.5", "type": "module", "main": "dist/esm/index.js", "module": "dist/esm/index.js", From 991f5a5cc7ace017878ea2b6bff37068e0449921 Mon Sep 17 00:00:00 2001 From: Jared Perreault Date: Mon, 2 Mar 2026 12:58:22 -0500 Subject: [PATCH 4/6] fix: dpop when system clock set ahead --- packages/auth-foundation/src/oauth2/client.ts | 31 +++++++++--- .../src/utils/TimeCoordinator.ts | 4 -- .../test/spec/oauth2/client.spec.ts | 48 +++++++++++++++++++ 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/auth-foundation/src/oauth2/client.ts b/packages/auth-foundation/src/oauth2/client.ts index 2c24906..3304f49 100644 --- a/packages/auth-foundation/src/oauth2/client.ts +++ b/packages/auth-foundation/src/oauth2/client.ts @@ -102,8 +102,14 @@ export class OAuth2Client e /** @internal */ protected async prepareDPoPNonceRetry (request: APIRequest, nonce: string): Promise { + return this.signTokenRequestWithDPoP(request); + } + + protected async signTokenRequestWithDPoP (request: APIRequest, nonce?: string): Promise { const { dpopPairId } = request.context; - await this.dpopSigningAuthority.sign(request, { keyPairId: dpopPairId, nonce }); + // dpop nonce may not be available for this request (undefined), this is expected + const dpopNonce = nonce ?? await this.getDPoPNonceFromCache(request); + await this.dpopSigningAuthority.sign(request, { keyPairId: dpopPairId, nonce: dpopNonce }); } protected async processResponse(response: Response, request: APIRequest): Promise { @@ -188,16 +194,24 @@ export class OAuth2Client e const { acrValues, maxAge } = tokenRequest; if (this.configuration.dpop) { - // dpop nonce may not be available for this request (undefined), this is expected - const nonce = await this.getDPoPNonceFromCache(request); - await this.dpopSigningAuthority.sign(request, { keyPairId: dpopPairId, nonce }); + request.context.dpopPairId = dpopPairId; + await this.signTokenRequestWithDPoP(request); } const response = await this.send(request); - const json = await response.json(); + let json = await response.json(); if (isOAuth2ErrorResponse(json)) { - return json; + if (!(OAuth2Client.isDPoPProofClockSkewError(json) && request.canRetry())) { + return json; + } + + // If a JWT (DPoP Proof) clock skew error is returned we can retry the request. + // The `Date` header of the /token response will be have been processed, hopefully + // this will align the client's clock with the Authorization Server's + await this.signTokenRequestWithDPoP(request); // re-sign request (with new `TimeCoordinator.now()`) + const retryReponse = await this.retry(request); // trigger retry + json = await retryReponse.json(); } const tokenContext: Token.Context = { @@ -567,4 +581,9 @@ export namespace OAuth2Client { export type GetJsonOptions = { skipCache?: boolean; }; + + export function isDPoPProofClockSkewError (error: OAuth2ErrorResponse) { + return error.error === 'invalid_dpop_proof' && + error.error_description === 'The DPoP proof JWT is issued in the future.'; + } } diff --git a/packages/auth-foundation/src/utils/TimeCoordinator.ts b/packages/auth-foundation/src/utils/TimeCoordinator.ts index 3fd8878..d19f32f 100644 --- a/packages/auth-foundation/src/utils/TimeCoordinator.ts +++ b/packages/auth-foundation/src/utils/TimeCoordinator.ts @@ -81,13 +81,10 @@ export class Timestamp { /** * @group TimeCoordinator */ -// TODO: implement (post beta) class TimeCoordinator { #skew = 0; static #tolerance = 0; - // TODO: adjust from http time headers - // (backend change needed to allow Date header in CORS requests) get clockSkew (): number { return this.#skew; } @@ -96,7 +93,6 @@ class TimeCoordinator { this.#skew = skew; } - // TODO: accept via config option static get clockTolerance (): number { return TimeCoordinator.#tolerance; } diff --git a/packages/auth-foundation/test/spec/oauth2/client.spec.ts b/packages/auth-foundation/test/spec/oauth2/client.spec.ts index 18ae5fe..365733f 100644 --- a/packages/auth-foundation/test/spec/oauth2/client.spec.ts +++ b/packages/auth-foundation/test/spec/oauth2/client.spec.ts @@ -545,6 +545,54 @@ describe('OAuth2Client', () => { expect(newToken.refreshToken).not.toEqual(token.refreshToken); expect(newToken.refreshToken).toEqual(undefined); }); + + describe('DPoP proof clock skew recovery', () => { + beforeEach(() => { + client.configuration.dpop = true; + jest.spyOn(client.dpopSigningAuthority, 'sign').mockImplementation((request) => request); + }); + + test('isDPoPProofClockSkewError', () => { + expect(OAuth2Client.isDPoPProofClockSkewError({ + error: 'invalid_dpop_proof', + error_description: 'The DPoP proof JWT is issued in the future.' + })).toBe(true); + expect(OAuth2Client.isDPoPProofClockSkewError({ + error: 'invalid_dpop_proof' + })).toBe(false); + expect(OAuth2Client.isDPoPProofClockSkewError({ + error: 'invalid_dpop_proof', + error_description: 'foobar' + })).toBe(false); + expect(OAuth2Client.isDPoPProofClockSkewError({ + error: 'foobar', + error_description: 'The DPoP proof JWT is issued in the future.' + })).toBe(false); + expect(OAuth2Client.isDPoPProofClockSkewError({ + error: 'foobar', + })).toBe(false); + }); + + it('gracefully recovers from a bad system clock when using DPoP', async () => { + const dpopProofInFutureErrorResponse = Response.json({ + error: 'invalid_dpop_proof', + error_description: 'The DPoP proof JWT is issued in the future.' + }); + const dpopTokenResponse = Response.json(mockTokenResponse(null, { token_type: 'DPoP' })); + + fetchSpy + .mockResolvedValueOnce(dpopProofInFutureErrorResponse) + .mockResolvedValueOnce(dpopTokenResponse); + const retrySpy = jest.spyOn(client, 'retry'); + + const token = new Token(mockTokenResponse()); + + const response = await client.performRefresh(token); + expect(response).toBeInstanceOf(Token); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(retrySpy).toHaveBeenCalledTimes(1); + }); + }); }); describe('revoke', () => { From 42b4c8321818de28b43f2b42288dfc874bded830 Mon Sep 17 00:00:00 2001 From: Jared Perreault Date: Mon, 2 Mar 2026 13:44:42 -0500 Subject: [PATCH 5/6] clock is set in past --- packages/auth-foundation/src/oauth2/client.ts | 12 +++++-- .../auth-foundation/src/oauth2/dpop/index.ts | 2 ++ .../src/utils/TimeCoordinator.ts | 8 ++--- .../test/spec/oauth2/client.spec.ts | 36 ++++++++++++++++++- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/auth-foundation/src/oauth2/client.ts b/packages/auth-foundation/src/oauth2/client.ts index 3304f49..66ba589 100644 --- a/packages/auth-foundation/src/oauth2/client.ts +++ b/packages/auth-foundation/src/oauth2/client.ts @@ -202,7 +202,11 @@ export class OAuth2Client e let json = await response.json(); if (isOAuth2ErrorResponse(json)) { - if (!(OAuth2Client.isDPoPProofClockSkewError(json) && request.canRetry())) { + if (!( + OAuth2Client.isDPoPProofClockSkewError(json) && // proper error is returned from AS + request.canRetry() && // request hasn't been retried too many times previously + Math.abs(Date.now() - TimeCoordinator.clockSkew) >= 150 // the TimeCoordinator updated with a meaningful time difference (~2.5 mintues) + )) { return json; } @@ -583,7 +587,9 @@ export namespace OAuth2Client { }; export function isDPoPProofClockSkewError (error: OAuth2ErrorResponse) { - return error.error === 'invalid_dpop_proof' && - error.error_description === 'The DPoP proof JWT is issued in the future.'; + return error.error === 'invalid_dpop_proof' && ( + error.error_description === 'The DPoP proof JWT is issued in the future.' || + error.error_description === 'The DPoP proof JWT is issued more than five minutes in the past.' + ); } } diff --git a/packages/auth-foundation/src/oauth2/dpop/index.ts b/packages/auth-foundation/src/oauth2/dpop/index.ts index e1b745e..7bfa9b4 100644 --- a/packages/auth-foundation/src/oauth2/dpop/index.ts +++ b/packages/auth-foundation/src/oauth2/dpop/index.ts @@ -123,6 +123,8 @@ export class DPoPSigningAuthorityImpl implements DPoPSigningAuthority { nonce }; + console.log('claims', claims); + // encode access token if (accessToken) { claims.ath = await hash(accessToken); diff --git a/packages/auth-foundation/src/utils/TimeCoordinator.ts b/packages/auth-foundation/src/utils/TimeCoordinator.ts index d19f32f..431e7b0 100644 --- a/packages/auth-foundation/src/utils/TimeCoordinator.ts +++ b/packages/auth-foundation/src/utils/TimeCoordinator.ts @@ -85,19 +85,19 @@ class TimeCoordinator { #skew = 0; static #tolerance = 0; - get clockSkew (): number { + get clockSkew (): Seconds { return this.#skew; } - set clockSkew (skew: number) { + set clockSkew (skew: Seconds) { this.#skew = skew; } - static get clockTolerance (): number { + static get clockTolerance (): Seconds { return TimeCoordinator.#tolerance; } - static set clockTolerance (tolerance: number) { + static set clockTolerance (tolerance: Seconds) { TimeCoordinator.#tolerance = tolerance; } diff --git a/packages/auth-foundation/test/spec/oauth2/client.spec.ts b/packages/auth-foundation/test/spec/oauth2/client.spec.ts index 365733f..24d1b8b 100644 --- a/packages/auth-foundation/test/spec/oauth2/client.spec.ts +++ b/packages/auth-foundation/test/spec/oauth2/client.spec.ts @@ -557,23 +557,37 @@ describe('OAuth2Client', () => { error: 'invalid_dpop_proof', error_description: 'The DPoP proof JWT is issued in the future.' })).toBe(true); + + expect(OAuth2Client.isDPoPProofClockSkewError({ + error: 'invalid_dpop_proof', + error_description: 'The DPoP proof JWT is issued more than five minutes in the past.' + })).toBe(true); + expect(OAuth2Client.isDPoPProofClockSkewError({ error: 'invalid_dpop_proof' })).toBe(false); + expect(OAuth2Client.isDPoPProofClockSkewError({ error: 'invalid_dpop_proof', error_description: 'foobar' })).toBe(false); + expect(OAuth2Client.isDPoPProofClockSkewError({ error: 'foobar', error_description: 'The DPoP proof JWT is issued in the future.' })).toBe(false); + + expect(OAuth2Client.isDPoPProofClockSkewError({ + error: 'foobar', + error_description: 'The DPoP proof JWT is issued more than five minutes in the past.' + })).toBe(false); + expect(OAuth2Client.isDPoPProofClockSkewError({ error: 'foobar', })).toBe(false); }); - it('gracefully recovers from a bad system clock when using DPoP', async () => { + it('gracefully recovers from a bad system clock when using DPoP (clock set ahead)', async () => { const dpopProofInFutureErrorResponse = Response.json({ error: 'invalid_dpop_proof', error_description: 'The DPoP proof JWT is issued in the future.' @@ -592,6 +606,26 @@ describe('OAuth2Client', () => { expect(fetchSpy).toHaveBeenCalledTimes(2); expect(retrySpy).toHaveBeenCalledTimes(1); }); + + it('gracefully recovers from a bad system clock when using DPoP (clock set behind)', async () => { + const dpopProofInPastErrorResponse = Response.json({ + error: 'invalid_dpop_proof', + error_description: 'The DPoP proof JWT is issued more than five minutes in the past.' + }); + const dpopTokenResponse = Response.json(mockTokenResponse(null, { token_type: 'DPoP' })); + + fetchSpy + .mockResolvedValueOnce(dpopProofInPastErrorResponse) + .mockResolvedValueOnce(dpopTokenResponse); + const retrySpy = jest.spyOn(client, 'retry'); + + const token = new Token(mockTokenResponse()); + + const response = await client.performRefresh(token); + expect(response).toBeInstanceOf(Token); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(retrySpy).toHaveBeenCalledTimes(1); + }); }); }); From 75f90adb19a8fa4d91e88f5ed67257423ec653fa Mon Sep 17 00:00:00 2001 From: Jared Perreault Date: Tue, 3 Mar 2026 10:28:11 -0500 Subject: [PATCH 6/6] feedback --- packages/auth-foundation/src/oauth2/client.ts | 32 +++++++++------- .../auth-foundation/src/oauth2/dpop/index.ts | 2 - .../test/spec/oauth2/client.spec.ts | 38 +++++++++++++++++++ 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/packages/auth-foundation/src/oauth2/client.ts b/packages/auth-foundation/src/oauth2/client.ts index 66ba589..f73f6bc 100644 --- a/packages/auth-foundation/src/oauth2/client.ts +++ b/packages/auth-foundation/src/oauth2/client.ts @@ -101,7 +101,7 @@ export class OAuth2Client e } /** @internal */ - protected async prepareDPoPNonceRetry (request: APIRequest, nonce: string): Promise { + protected async prepareDPoPNonceRetry (request: APIRequest): Promise { return this.signTokenRequestWithDPoP(request); } @@ -202,20 +202,26 @@ export class OAuth2Client e let json = await response.json(); if (isOAuth2ErrorResponse(json)) { - if (!( - OAuth2Client.isDPoPProofClockSkewError(json) && // proper error is returned from AS - request.canRetry() && // request hasn't been retried too many times previously - Math.abs(Date.now() - TimeCoordinator.clockSkew) >= 150 // the TimeCoordinator updated with a meaningful time difference (~2.5 mintues) - )) { - return json; + if ( + // proper error is returned from AS + OAuth2Client.isDPoPProofClockSkewError(json) && + // request hasn't been retried too many times previously + request.canRetry() && + // (heuristic) the TimeCoordinator updated with a meaningful time difference (~2.5 mintues) + Math.abs(Date.now() - TimeCoordinator.clockSkew) >= 150 + ) { + // If a JWT (DPoP Proof) clock skew error is returned we can retry the request. + // The `Date` header of the /token response will be have been processed, hopefully + // this will align the client's clock with the Authorization Server's + await this.signTokenRequestWithDPoP(request); // re-sign request (with new `TimeCoordinator.now()`) + const retryReponse = await this.retry(request); // trigger retry + json = await retryReponse.json(); } - // If a JWT (DPoP Proof) clock skew error is returned we can retry the request. - // The `Date` header of the /token response will be have been processed, hopefully - // this will align the client's clock with the Authorization Server's - await this.signTokenRequestWithDPoP(request); // re-sign request (with new `TimeCoordinator.now()`) - const retryReponse = await this.retry(request); // trigger retry - json = await retryReponse.json(); + // redundant, but handles scenario where retry returns error + if (isOAuth2ErrorResponse(json)) { + return json; + } } const tokenContext: Token.Context = { diff --git a/packages/auth-foundation/src/oauth2/dpop/index.ts b/packages/auth-foundation/src/oauth2/dpop/index.ts index 7bfa9b4..e1b745e 100644 --- a/packages/auth-foundation/src/oauth2/dpop/index.ts +++ b/packages/auth-foundation/src/oauth2/dpop/index.ts @@ -123,8 +123,6 @@ export class DPoPSigningAuthorityImpl implements DPoPSigningAuthority { nonce }; - console.log('claims', claims); - // encode access token if (accessToken) { claims.ath = await hash(accessToken); diff --git a/packages/auth-foundation/test/spec/oauth2/client.spec.ts b/packages/auth-foundation/test/spec/oauth2/client.spec.ts index 24d1b8b..0ec8a7c 100644 --- a/packages/auth-foundation/test/spec/oauth2/client.spec.ts +++ b/packages/auth-foundation/test/spec/oauth2/client.spec.ts @@ -626,6 +626,44 @@ describe('OAuth2Client', () => { expect(fetchSpy).toHaveBeenCalledTimes(2); expect(retrySpy).toHaveBeenCalledTimes(1); }); + + it('returns error after retry when same system clock error is returned (clock set ahead)', async () => { + const dpopProofInFutureErrorResponse = { + error: 'invalid_dpop_proof', + error_description: 'The DPoP proof JWT is issued in the future.' + }; + + fetchSpy + .mockResolvedValueOnce(Response.json(dpopProofInFutureErrorResponse)) + .mockResolvedValueOnce(Response.json(dpopProofInFutureErrorResponse)); + const retrySpy = jest.spyOn(client, 'retry'); + + const token = new Token(mockTokenResponse()); + + const response = await client.performRefresh(token); + expect(response).toEqual(dpopProofInFutureErrorResponse); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(retrySpy).toHaveBeenCalledTimes(1); + }); + + it('returns error after retry when same system clock error is returned (clock set behind)', async () => { + const dpopProofInPastErrorResponse = { + error: 'invalid_dpop_proof', + error_description: 'The DPoP proof JWT is issued more than five minutes in the past.' + }; + + fetchSpy + .mockResolvedValueOnce(Response.json(dpopProofInPastErrorResponse)) + .mockResolvedValueOnce(Response.json(dpopProofInPastErrorResponse)); + const retrySpy = jest.spyOn(client, 'retry'); + + const token = new Token(mockTokenResponse()); + + const response = await client.performRefresh(token); + expect(response).toEqual(dpopProofInPastErrorResponse); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(retrySpy).toHaveBeenCalledTimes(1); + }); }); });