From 63ec7dabb64e67ea9b134e6f8a1723eb40e0a11a Mon Sep 17 00:00:00 2001 From: Jared Perreault <90656038+jaredperreault-okta@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:38:22 -0500 Subject: [PATCH 1/8] adds unit test coverage to TaskBridge (#15) OKTA-1053835 test: adds unit test coverage for TaskBridge --- .../auth-foundation/src/utils/TaskBridge.ts | 107 +++-- .../test/spec/utils/TaskBridge.spec.ts | 381 +++++++++++++++--- .../src/Credential/CredentialCoordinator.ts | 1 + .../orchestrators/HostOrchestrator/Host.ts | 4 +- .../test/helpers/makeTestResource.ts | 1 + .../test/spec/BrowserTokenStorage.spec.ts | 2 +- .../orchestrators/HostOrchestrator.spec.ts | 125 +++--- .../jest-helpers/browser/jest.environment.js | 1 + tooling/jest-helpers/browser/jest.setup.ts | 3 +- 9 files changed, 478 insertions(+), 147 deletions(-) diff --git a/packages/auth-foundation/src/utils/TaskBridge.ts b/packages/auth-foundation/src/utils/TaskBridge.ts index ae25432..7954101 100644 --- a/packages/auth-foundation/src/utils/TaskBridge.ts +++ b/packages/auth-foundation/src/utils/TaskBridge.ts @@ -1,10 +1,9 @@ import type { BroadcastChannelLike } from '../types/index.ts'; import { shortID } from '../crypto/index.ts'; +import { AuthSdkError } from '../errors/AuthSdkError.ts'; /** @useDeclaredType */ type TypeMap = Record; -// TODO: revisit this -// type TypeMap = Record>; /** * A bridge for passing messages between a `TaskHandler` and a `Requestor`. The `Requestor` is "asking" the `TaskHandler` @@ -86,8 +85,9 @@ export abstract class TaskBridge { }); this.#pending.set(request.id, request); + let abortHandler: () => void; const result = (new Promise((resolve, reject) => { - const setTimeoutTimer = () => { + const resetTimeoutTimer = () => { // `options.timeout` set to `null` disables the timeout mechanism if (options.timeout === null) { return; @@ -97,20 +97,21 @@ export abstract class TaskBridge { clearTimeout(timeoutId); } // TODO: error type - timeoutId = setTimeout(() => reject(new Error('timeout')), options.timeout ?? 5000); + timeoutId = setTimeout(() => reject( + new TaskBridge.TimeoutError('timeout') + ), options.timeout ?? 5000); }; // sets timeout timer - setTimeoutTimer(); + resetTimeoutTimer(); // forces the pending promise to reject, so resources clean up if the request is aborted - request.signal.addEventListener('abort', () => { - reject(new DOMException('Aborted', 'AbortError')); - }); + abortHandler = () => reject(new DOMException('Aborted', 'AbortError')); + request.signal.addEventListener('abort', abortHandler, { once: true }); // This channel is meant for the Receiver to send the results (aka `HandlerMessage` messages) // ignore all Requestor events received (aka `RequestorMessage`) responseChannel.onmessage = (event) => { - if ('action' in event.data) { + if (request.signal.aborted || 'action' in event.data) { return; // ignore message } @@ -126,8 +127,11 @@ export abstract class TaskBridge { case 'PENDING': // defer the timeout timer when a heartbeat is received (host is still working) - setTimeoutTimer(); + resetTimeoutTimer(); + break; + case 'ABORTED': + request.abort('Host Aborted'); break; } }; @@ -141,15 +145,16 @@ export abstract class TaskBridge { } requestChannel.close(); responseChannel.close(); + request.signal.removeEventListener('abort', abortHandler); this.#pending.delete(request.id); }); - // TODO: review - const cancel = () => { + const abort = () => { responseChannel.postMessage({ action: 'CANCEL', __v: TaskBridge.BridgeVersion }); + request.controller.abort('cancel'); }; - return { result, cancel }; + return { result, abort }; } subscribe(handler: TaskBridge.TaskHandler) { @@ -182,8 +187,6 @@ export abstract class TaskBridge { // event type is now `RequestorMessage` switch (event.data.action) { case 'CANCEL': - // TODO: probably don't need to reply, just cancel action, if possible - // responseChannel.postMessage({ status: 'CANCELED' }); message.abort('cancel'); break; } @@ -198,8 +201,19 @@ export abstract class TaskBridge { ); } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') { - return null; + if (err instanceof DOMException) { + if (err.name === 'AbortError') { + // task was aborted, do nothing + return null; + } + + if (err.name === 'InvalidStateError') { + // this is error is thrown if a `.postMessage` is attempted after the channel is closed + // this can happen when the `handler` function attempts to `reply()` after `.close()` + // is called. Ignore the error, the `AbortSignal` is provided to the `handler` for + // if needed + return null; + } } if (err instanceof Error) { @@ -213,11 +227,31 @@ export abstract class TaskBridge { }; } + /** + * Returns the number of pending tasks + */ + get pending (): number { + return this.#pending.size; + } + close () { this.#channel?.close(); for (const message of this.#pending.values()) { message.abort(); - message.channel.close(); + } + + // Give abort messages a chance to be sent before closing channels + queueMicrotask(() => { + for (const message of this.#pending.values()) { + message.channel.close(); + this.clearMessage(message.id); + } + }); + + this.#pending.clear(); + if (this.#heartbeatInt !== null) { + clearInterval(this.#heartbeatInt); + this.#heartbeatInt = null; } } } @@ -229,7 +263,7 @@ export namespace TaskBridge { /** * Possible `status` values indicating the process of an orchestrated request */ - export type TaskStatus = 'PENDING' | 'SUCCESS' | 'FAILED'; + export type TaskStatus = 'PENDING' | 'SUCCESS' | 'FAILED' | 'ABORTED'; export type BridgeVersions = 1 | 2; @@ -247,6 +281,9 @@ export namespace TaskBridge { } | { status: 'PENDING' __v: BridgeVersions; + } | { + status: 'ABORTED' + __v: BridgeVersions; } /** @@ -309,9 +346,9 @@ export namespace TaskBridge { } reply (data: S, status: TaskBridge.TaskStatus): void; - reply (status: 'PENDING'): void; - reply (data: S | 'PENDING', status: TaskBridge.TaskStatus = 'SUCCESS') { - const fn = this.replyFn ?? this.channel.postMessage; + reply (status: 'PENDING' | 'ABORTED'): void; + reply (data: S | 'PENDING' | 'ABORTED', status: TaskBridge.TaskStatus = 'SUCCESS') { + const fn = this.replyFn ?? this.channel.postMessage.bind(this.channel); if (data === 'PENDING' || status === 'PENDING') { // only send `PENDING` heartbeats when using <= v2 of the TaskBridge payload structure @@ -319,6 +356,12 @@ export namespace TaskBridge { fn({ status: 'PENDING', __v: this.__v } satisfies HandlerMessage); } } + else if (data === 'ABORTED' || status === 'ABORTED') { + // only send `PENDING` heartbeats when using <= v2 of the TaskBridge payload structure + if (this.__v === 2) { + fn({ status: 'ABORTED', __v: this.__v } satisfies HandlerMessage); + } + } else { // TODO: remove this condition - OKTA-1053515 if (this.__v < 2) { @@ -332,6 +375,7 @@ export namespace TaskBridge { } abort (...args: Parameters) { + this.reply('ABORTED'); return this.controller.abort(...args); } @@ -345,6 +389,7 @@ export namespace TaskBridge { */ export type TaskOptions = { timeout?: number | null; + signal?: AbortSignal; }; /** @@ -352,7 +397,7 @@ export namespace TaskBridge { */ export type TaskResponse = { result: Promise; - cancel: () => void; + abort: () => void; }; /** @@ -364,4 +409,20 @@ export namespace TaskBridge { options?: { signal: AbortSignal } ) => any; + /** + * @group Errors + */ + export class TimeoutError extends AuthSdkError { + #timeout: boolean = false; + + constructor (...args: ConstructorParameters) { + const [message, ...rest] = args; + super(message ?? 'timeout', ...rest); + this.#timeout = true; + } + + get timeout (): boolean { + return this.#timeout; + } + } } diff --git a/packages/auth-foundation/test/spec/utils/TaskBridge.spec.ts b/packages/auth-foundation/test/spec/utils/TaskBridge.spec.ts index f05e98e..b24c987 100644 --- a/packages/auth-foundation/test/spec/utils/TaskBridge.spec.ts +++ b/packages/auth-foundation/test/spec/utils/TaskBridge.spec.ts @@ -1,6 +1,13 @@ -import { BroadcastChannelLike, JsonRecord } from 'src/types'; +/** + * A real `BroadcastChannel` implementation is needed within this test file + * Make sure the `global.BroadcastChannel = X` line executes before any additional imports + */ +import { BroadcastChannel as BC } from 'node:worker_threads'; +// @ts-expect-error - Seemingly a JSDOM vs Node impl difference, ignore +global.BroadcastChannel = BC; import { TaskBridge } from 'src/utils/TaskBridge.ts'; + type TestRequest = { ADD: { foo: number; @@ -23,58 +30,21 @@ type TestResponse = { } }; -class TestChannel implements BroadcastChannelLike { - channel: BroadcastChannel; - #handler: BroadcastChannelLike['onmessage'] = null; - - constructor (public name: string) { - this.channel = new BroadcastChannel(name); - } - - get onmessage () { - return this.#handler; +class TestBus extends TaskBridge { + protected createBridgeChannel (): TaskBridge.BridgeChannel { + return new BroadcastChannel(this.name) as TaskBridge.BridgeChannel; } - set onmessage (handler) { - if (handler === null) { - this.channel.onmessage = null; - this.#handler = null; - } - - console.log('handler set', handler); - - this.#handler = async (event) => { - console.log('got message', event.data); - // const reply = (response) => this.channel.postMessage(response); - // @ts-ignore - await handler(event.data); - }; - - this.channel.onmessage = this.#handler; - } - - postMessage(message: M): void { - this.channel.postMessage(message); - } - - close () { - this.channel.close(); - } -} - -class TestBus extends TaskBridge { - - protected createBridgeChannel (): TaskBridge.BridgeChannel { - return new TestChannel(this.name); - } - - protected createTaskChannel(name: string): TaskBridge.TaskChannel { - return new TestChannel(name); + protected createTaskChannel(name: string): TaskBridge.TaskChannel { + return new BroadcastChannel(name) as TaskBridge.TaskChannel; } } +const sleep = (ms: number) => new Promise(resolve => { + setTimeout(resolve, ms); +}); -describe.skip('TaskBridge', () => { +describe('TaskBridge', () => { let receiver: TaskBridge; let sender: TaskBridge; @@ -84,34 +54,313 @@ describe.skip('TaskBridge', () => { }); afterEach(() => { + expect(receiver.pending).toEqual(0); + expect(sender.pending).toEqual(0); + receiver.close(); sender.close(); + jest.clearAllTimers(); }); - describe('test', () => { - it('sends and receives messages', async () => { - const channel = new BroadcastChannel('test'); - channel.onmessage = (event) => { - console.log('[monitor]: ', event.data); - }; - - receiver.subscribe(async (message, reply) => { - console.log('handler called'); - reply({ foo: '2', bar: '1' }); - }); - - const result = await sender.send({ foo: 1, bar: 2 }).result; - expect(result).toEqual({ bar: 'baz' }); + it('sends and receives messages between separate instances', async () => { + jest.useFakeTimers(); + + const response = { foo: '2', bar: '1' }; + + receiver.subscribe(async (message, reply) => { + reply(response); + }); - channel.close(); + const { result } = sender.send({ foo: 1, bar: 2 }); + await expect(result).resolves.toEqual(response); + expect(jest.getTimerCount()).toBe(0); + + jest.useRealTimers(); + }); + + it('handles multiple tasks simultaneously', async () => { + jest.useFakeTimers(); + + const response = { foo: '2', bar: '1' }; + + receiver.subscribe(async (message, reply) => { + reply(response); }); + + const promises = Promise.allSettled(Array.from({ length: 3 }, (_, i) => { + const { result } = sender.send({ foo: 1 + i, bar: 2 + i }); + return result; + })); + + await expect(promises).resolves.toEqual([ + { status: 'fulfilled', value: { ...response} }, + { status: 'fulfilled', value: { ...response } }, + { status: 'fulfilled', value: { ...response } }, + ]); + + expect(jest.getTimerCount()).toBe(0); + + jest.useRealTimers(); + }); + + it('handles a single task throwing gracefully', async () => { + jest.useFakeTimers(); + + const response = { foo: '2', bar: '1' }; + + let taskCount = 0; + receiver.subscribe(async (message, reply) => { + const isEven = taskCount === 0 || taskCount % 2 === 0; + taskCount += 1; + if (isEven) { + reply(response); + } + else { + throw new Error('test error'); + } + }); + + const promises = Promise.allSettled(Array.from({ length: 3 }, (_, i) => { + const { result } = sender.send({ foo: 1 + i, bar: 2 + i }); + return result; + })); + + await expect(promises).resolves.toEqual([ + { status: 'fulfilled', value: { ...response} }, + { status: 'fulfilled', value: { error: 'test error' } }, + { status: 'fulfilled', value: { ...response } }, + ]); + + expect(jest.getTimerCount()).toBe(0); + + jest.useRealTimers(); + }); + + it('gracefully handles an error being thrown by the subscribe handler', async () => { + jest.useFakeTimers(); + + const handler = jest.fn().mockImplementation(async () => { + throw new Error('test'); + }); + receiver.subscribe(handler); + + const { result } = sender.send({ foo: 1, bar: 2 }); + await expect(result).resolves.toEqual({ error: 'test' }); + expect(jest.getTimerCount()).toBe(0); + + jest.useRealTimers(); + }); + + it('can handle aborting pending tasks', async () => { + jest.useFakeTimers(); + + const abortListener = jest.fn(); + const handler = jest.fn().mockImplementation( async (message, reply, { signal }) => { + signal.addEventListener('abort', abortListener, { once: true }); + + await sleep(5000); // sleep to delay responding to the message, so the abort fires first + reply({ foo: '1', bar: '2' }); + }); + receiver.subscribe(handler); + + const { result, abort } = sender.send({ foo: 1, bar: 2 }); + // bind abort error listener before the error is thrown + const expectation = expect(result).rejects.toThrow(new DOMException('Aborted')); + + // flush microtasks to ensure subscribe abortHandler is set up + await jest.advanceTimersByTimeAsync(10); + // flush microtasks again so messages are sent over the bridge + await jest.advanceTimersByTimeAsync(10); + + abort(); // trigger abort + // flush microtask queue again + await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(10); + + // await the `expect(DOMException)` from above + await expectation; + // flush all timers from queue + await jest.runAllTimersAsync(); + + expect(handler).toHaveBeenCalled(); + expect(abortListener).toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + + jest.useRealTimers(); }); - // xdescribe('', async () => { + it('will not timeout a pending request when host is available', async () => { + jest.useFakeTimers(); - // }); + const response = { foo: '2', bar: '1' }; + const largeDelay = 10000; - // xdescribe('', async () => { + // clever way of capturing the requestId + let requestId; + const bc = new BroadcastChannel('test'); + bc.onmessage = (evt => { + if (evt.data.requestId) { + requestId = evt.data.requestId; + } + }); + + receiver.subscribe(async (message, reply) => { + await sleep(largeDelay); // very long delay + reply(response); + }); + + const { result } = sender.send({ foo: 1, bar: 2 }); + // advance timers to send BroadcastChannel messages + await jest.advanceTimersByTimeAsync(100); + + // listen on "response channel" and count number of `PENDING` "pings" + let pendingCount = 0; + const channel = new BroadcastChannel(requestId); + channel.onmessage = (evt) => { + if (evt.data.status === 'PENDING') { + pendingCount++; + } + }; + + // advance the timers to the length of the delay, so response is finally returned + await jest.advanceTimersByTimeAsync(largeDelay); + + await expect(result).resolves.toEqual(response); + // expect a predictable number of 'PENDING' pings given the large delay + expect(pendingCount).toEqual(largeDelay / receiver.heartbeatInterval); + expect(jest.getTimerCount()).toBe(0); + + // cleanup + jest.useRealTimers(); + bc.close(); + channel.close(); + }); + + it('will timeout when host does not response within default timeout window', async () => { + expect.assertions(4); // ensures `result.catch()` is invoked + jest.useFakeTimers(); + + receiver.close(); + + const { result } = sender.send({ foo: 1, bar: 2 }); + + // use `.catch` to bind listener synchronously + const promise = result.catch(err => { + expect(err).toBeInstanceOf(TaskBridge.TimeoutError); + }); + + await jest.advanceTimersByTimeAsync(10000); + await promise; + + expect(jest.getTimerCount()).toBe(0); + + jest.useRealTimers(); + }); + + it('will timeout when host does not response within user defined timeout window', async () => { + expect.assertions(4); // ensures `result.catch()` is invoked + jest.useFakeTimers(); + + const largeTimeout = 10000; + + receiver.close(); + + const { result } = sender.send({ foo: 1, bar: 2 }, { timeout: largeTimeout - 100 }); + + // use `.catch` to bind listener synchronously + const promise = result.catch(err => { + expect(err).toBeInstanceOf(TaskBridge.TimeoutError); + }); + + await jest.advanceTimersByTimeAsync(10000); + await promise; + + expect(jest.getTimerCount()).toBe(0); + + jest.useRealTimers(); + }); + + it('will timeout when no host is avaiable', async () => { + expect.assertions(4); // ensures `result.catch()` is invoked + jest.useFakeTimers(); + + const timeout = 100; + + // NOTE: no `receiver.subscribe` call + + const { result } = sender.send({ foo: 1, bar: 2 }, { timeout }); + + // use `.catch` to bind listener synchronously + const promise = result.catch(err => { + expect(err).toBeInstanceOf(TaskBridge.TimeoutError); + }); + + await jest.advanceTimersByTimeAsync(timeout); + await promise; + + expect(jest.getTimerCount()).toBe(0); + + jest.useRealTimers(); + }); + + it('will abort pending tasks when closed', async () => { + jest.useFakeTimers(); + + const abortListener = jest.fn(); + const handler = jest.fn().mockImplementation(async (message, reply, { signal }) => { + // confirm the `signal` instance fires an `abort` event + signal.addEventListener('abort', abortListener); + + // returns Promise which rejects when event is fired + function rejectWhenFired (target: EventTarget, event: string) { + return new Promise((_, reject) => { + target.addEventListener(event, reject, { once: true }); + }); + } + + // track the timers set by `sleep()` within this test + let sleepTimeout; + function sleep (delay) { + return new Promise((resolve) => { + sleepTimeout = setTimeout(resolve, delay); + }); + } + + // sleep to delay responding to the message, so the abort fires first + try { + await Promise.race([ + sleep(sender.heartbeatInterval * 10), + rejectWhenFired(signal, 'abort'), + ]); + + reply({ foo: '1', bar: '2' }); + } + finally { + // timeouts set via `sleep()` need to be cleared. Test requires no timers remain + clearTimeout(sleepTimeout); + } + }); + receiver.subscribe(handler); + + const promises = Promise.allSettled(Array.from({ length: 3 }, (_, i) => { + const { result } = sender.send({ foo: 1 + i, bar: 2 + i }); + return result; + })); + + // flush microtasks to ensure subscribe handler is set up + await jest.advanceTimersByTimeAsync(10); + + expect(handler).toHaveBeenCalledTimes(3); + + receiver.close(); + const result = await promises; + await jest.advanceTimersByTimeAsync(10); + + expect(result).toEqual(Array(3).fill({ status: 'rejected', reason: expect.any(DOMException) })); + expect(abortListener).toHaveBeenCalledTimes(3); + expect(jest.getTimerCount()).toBe(0); + + jest.useRealTimers(); + }); - // }); }); diff --git a/packages/spa-platform/src/Credential/CredentialCoordinator.ts b/packages/spa-platform/src/Credential/CredentialCoordinator.ts index 8535a12..a36ab7f 100644 --- a/packages/spa-platform/src/Credential/CredentialCoordinator.ts +++ b/packages/spa-platform/src/Credential/CredentialCoordinator.ts @@ -90,6 +90,7 @@ export class CredentialCoordinatorImpl extends CredentialCoordinatorBase impleme } protected broadcast (eventName: string, data: Record) { + // TODO: consider catching `InvalidStateError` thrown here (thrown when .postMessage is called on a closed channel) this.channel.postMessage({ eventName, source: this.id, // id associated with CredentialCoordinator instance (aka per tab) diff --git a/packages/spa-platform/src/orchestrators/HostOrchestrator/Host.ts b/packages/spa-platform/src/orchestrators/HostOrchestrator/Host.ts index e3a7243..e33eb33 100644 --- a/packages/spa-platform/src/orchestrators/HostOrchestrator/Host.ts +++ b/packages/spa-platform/src/orchestrators/HostOrchestrator/Host.ts @@ -84,7 +84,9 @@ export abstract class HostOrchestrator let response: HO.ResponseEvent[keyof HO.ResponseEvent]; switch (eventName) { case 'ACTIVATED': - return this.handleHostActivated(request); + this.handleHostActivated(request); + response = {}; + break; case 'PING': response = { message: 'PONG' } satisfies HO.PingResponse; break; diff --git a/packages/spa-platform/test/helpers/makeTestResource.ts b/packages/spa-platform/test/helpers/makeTestResource.ts index 0d39beb..2c86a9e 100644 --- a/packages/spa-platform/test/helpers/makeTestResource.ts +++ b/packages/spa-platform/test/helpers/makeTestResource.ts @@ -4,6 +4,7 @@ import { Token, OAuth2Client } from 'src/platform'; import { Credential } from 'src/Credential'; import { mockIDToken, mockTokenResponse } from '@repo/jest-helpers/browser/helpers'; + class JestOAuth2Client extends OAuth2Client { public async fetch (url: string | URL, options: RequestInit = {}): Promise { throw new Error('JEST CLIENT BOUNDARY, NO NETWORK REQUEST SHOULD BE MADE!'); diff --git a/packages/spa-platform/test/spec/BrowserTokenStorage.spec.ts b/packages/spa-platform/test/spec/BrowserTokenStorage.spec.ts index aa5ffad..27947c2 100644 --- a/packages/spa-platform/test/spec/BrowserTokenStorage.spec.ts +++ b/packages/spa-platform/test/spec/BrowserTokenStorage.spec.ts @@ -3,7 +3,7 @@ import { Token } from 'src/platform'; import { BrowserTokenStorage } from 'src/Credential/TokenStorage'; import { makeTestToken, MockIndexedDBStore } from '../helpers/makeTestResource'; -describe('BrowserTokenStorage' , () => { +describe('BrowserTokenStorage', () => { it('can construct', () => { const storage = new BrowserTokenStorage(); expect(storage).toBeInstanceOf(BrowserTokenStorage); diff --git a/packages/spa-platform/test/spec/orchestrators/HostOrchestrator.spec.ts b/packages/spa-platform/test/spec/orchestrators/HostOrchestrator.spec.ts index fa4a81c..ee8691a 100644 --- a/packages/spa-platform/test/spec/orchestrators/HostOrchestrator.spec.ts +++ b/packages/spa-platform/test/spec/orchestrators/HostOrchestrator.spec.ts @@ -1,7 +1,7 @@ import { TokenOrchestrator, TokenOrchestratorError } from '@okta/auth-foundation'; import { mockTokenResponse } from '@repo/jest-helpers/browser/helpers'; import { Token } from 'src/platform'; -import { HostOrchestrator } from 'src/orchestrators'; +import { HostOrchestrator } from 'src/orchestrators/HostOrchestrator/index'; import { LocalBroadcastChannel } from 'src/utils/LocalBroadcastChannel'; @@ -26,10 +26,9 @@ class MockOrchestrator extends TokenOrchestrator { } } -// TODO: revisit -// console.warn = () => {}; describe('HostOrchestrator', () => { + const authParams = { issuer: 'http://fake.okta.com', clientId: 'fakeClientId', @@ -51,57 +50,68 @@ describe('HostOrchestrator', () => { jest.spyOn((LocalBroadcastChannel.prototype as any), 'isTrustedMessage').mockReturnValue(true); }); - it('.activate / .close', () => { - const activateSpy = jest.spyOn(MockHost.prototype, 'activate'); - // don't pollute logs with warnings during testing - jest.spyOn(console, 'warn').mockReturnValue(undefined); - - const host1 = new MockHost('TestHost'); - expect(host1).toBeInstanceOf(HostOrchestrator.Host); - expect(host1.isActive).toBe(true); - expect(activateSpy).toHaveBeenCalledTimes(1); - - const duplicateHostListener = jest.fn(); - host1.on('duplicate_host', duplicateHostListener); - - const host2 = new MockHost('TestHost'); - expect(host2).toBeInstanceOf(HostOrchestrator.Host); - expect(host2.isActive).toBe(true); - expect(activateSpy).toHaveBeenCalledTimes(2); - expect(duplicateHostListener).toHaveBeenCalledTimes(1); - expect(duplicateHostListener.mock.lastCall?.[0]).toMatchObject({ - id: host1.id, - duplicateId: host2.id + describe('duplicate hosts', () => { + let hosts: MockHost[] = []; + + afterEach(() => { + const [host1, host2, host3, host4] = hosts; + + // clean up, important to not bleed into other tests + host1.close(); host2.close(); host3.close(); host4.close(); + // assert clean up was completed succesfully + expect(host1.isActive).toBe(false); + expect(host2.isActive).toBe(false); + expect(host3.isActive).toBe(false); + expect(host4.isActive).toBe(false); }); - // should not auto-activate since `window.top` is null (mocking iframe mounting) - jest.spyOn((HostOrchestrator.Host as any).prototype, 'shouldActive').mockReturnValue(false); - const host3 = new MockHost('TestHost'); - expect(host3.isActive).toBe(false); - expect(activateSpy).toHaveBeenCalledTimes(2); - expect(duplicateHostListener).toHaveBeenCalledTimes(1); - - // manually activate - host3.activate(); - expect(host3.isActive).toBe(true); - expect(activateSpy).toHaveBeenCalledTimes(3); - expect(duplicateHostListener).toHaveBeenCalledTimes(2); - - const host4 = new MockHost('TestHost--FOO'); - host4.activate(); // iframe mock still in place, requires manual activation - expect(host4.isActive).toBe(true); - expect(activateSpy).toHaveBeenCalledTimes(4); - // host4 is named differently, therefore does not trigger dup host event - expect(duplicateHostListener).toHaveBeenCalledTimes(2); - - // clean up, important to not bleed into other tests - host1.close(); host2.close(); host3.close(); host4.close(); - expect(host1.isActive).toBe(false); - expect(host2.isActive).toBe(false); - expect(host3.isActive).toBe(false); - expect(host4.isActive).toBe(false); - - host1.off('duplicate_host', duplicateHostListener); + it('.activate / .close', async () => { + const activateSpy = jest.spyOn(MockHost.prototype, 'activate'); + // don't pollute logs with warnings during testing + jest.spyOn(console, 'warn').mockReturnValue(undefined); + + const host1 = new MockHost('TestHost'); + expect(host1).toBeInstanceOf(HostOrchestrator.Host); + expect(host1.isActive).toBe(true); + expect(activateSpy).toHaveBeenCalledTimes(1); + + const duplicateHostListener = jest.fn(); + host1.on('duplicate_host', duplicateHostListener); + + const host2 = new MockHost('TestHost'); + expect(host2).toBeInstanceOf(HostOrchestrator.Host); + expect(host2.isActive).toBe(true); + expect(activateSpy).toHaveBeenCalledTimes(2); + expect(duplicateHostListener).toHaveBeenCalledTimes(1); + expect(duplicateHostListener.mock.lastCall?.[0]).toMatchObject({ + id: host1.id, + duplicateId: host2.id + }); + + // should not auto-activate since `window.top` is null (mocking iframe mounting) + jest.spyOn((HostOrchestrator.Host as any).prototype, 'shouldActive').mockReturnValue(false); + const host3 = new MockHost('TestHost'); + expect(host3.isActive).toBe(false); + expect(activateSpy).toHaveBeenCalledTimes(2); + expect(duplicateHostListener).toHaveBeenCalledTimes(1); + + // manually activate + host3.activate(); + expect(host3.isActive).toBe(true); + expect(activateSpy).toHaveBeenCalledTimes(3); + expect(duplicateHostListener).toHaveBeenCalledTimes(2); + + const host4 = new MockHost('TestHost--FOO'); + host4.activate(); // iframe mock still in place, requires manual activation + expect(host4.isActive).toBe(true); + expect(activateSpy).toHaveBeenCalledTimes(4); + // host4 is named differently, therefore does not trigger dup host event + expect(duplicateHostListener).toHaveBeenCalledTimes(2); + + // test cleanup move to `afterEach` to allow for microtasks to fire + hosts = [host1, host2, host3, host4]; // therefore list hosts within array for cleanup + host1.off('duplicate_host', duplicateHostListener); + }); }); describe('events', () => { @@ -408,6 +418,7 @@ describe('HostOrchestrator', () => { }); describe('getToken', () => { + it('can request a token or load one from cache', async () => { const sub = new HostOrchestrator.SubApp('Test'); @@ -425,6 +436,7 @@ describe('HostOrchestrator', () => { expect(broadcastSpy).toHaveBeenCalledTimes(1); }); + it('will resolve the same pending promise for requests with same authParams', async () => { const sub = new HostOrchestrator.SubApp('Test'); @@ -469,12 +481,15 @@ describe('HostOrchestrator', () => { jest.useFakeTimers(); const sub = new HostOrchestrator.SubApp('Test'); + const broadcastSpy = jest.spyOn((sub as any), 'broadcast'); - jest.spyOn((sub as any), 'broadcast'); + const promise = sub.getToken(); - const promise = expect(sub.getToken()).rejects.toThrow(new TokenOrchestratorError('timeout')); + const expectation = expect(promise).rejects.toThrow(); await jest.advanceTimersByTimeAsync(5000); - await promise; + + await expectation; + expect(broadcastSpy).toHaveBeenCalled(); jest.useRealTimers(); }); diff --git a/tooling/jest-helpers/browser/jest.environment.js b/tooling/jest-helpers/browser/jest.environment.js index f025a39..f6e76b1 100644 --- a/tooling/jest-helpers/browser/jest.environment.js +++ b/tooling/jest-helpers/browser/jest.environment.js @@ -15,6 +15,7 @@ class CustomJSDomEnv extends JSDOMEnv { this.global.Request = Request; this.global.Response = Response; this.global.Headers = Headers; + this.global.DOMException = DOMException; } } diff --git a/tooling/jest-helpers/browser/jest.setup.ts b/tooling/jest-helpers/browser/jest.setup.ts index 43e699c..cfc56a1 100644 --- a/tooling/jest-helpers/browser/jest.setup.ts +++ b/tooling/jest-helpers/browser/jest.setup.ts @@ -2,6 +2,7 @@ const crypto = require('node:crypto'); const { TextEncoder, TextDecoder } = require('node:util'); import { randStr } from './helpers'; + Object.defineProperty(global, 'crypto', { value: { randomUUID: () => randStr(15), // do not use actual crypto alg for testing to for speed @@ -23,9 +24,9 @@ class MockBroadcastChannel implements BroadcastChannel { close = jest.fn(); } +global.BroadcastChannel = MockBroadcastChannel; global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder; -global.BroadcastChannel = MockBroadcastChannel; global.fetch = () => { throw new Error(` From 6eb2170b1b0df176e2fa608ba850da0e3c4fa361 Mon Sep 17 00:00:00 2001 From: Jared Perreault Date: Fri, 23 May 2025 15:33:59 -0400 Subject: [PATCH 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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); + }); }); }); From 9a3275ae61645653f90fb8757e59d2cdb726cbf8 Mon Sep 17 00:00:00 2001 From: Jared Perreault Date: Tue, 3 Mar 2026 20:57:54 -0500 Subject: [PATCH 8/8] refactor: PlatformRegistry pattern --- e2e/apps/express-oidc/strategy.mjs | 9 +- .../apps/orchestrators/pages/ProxyHost.tsx | 9 +- .../src/apps/orchestrators/pages/Redirect.tsx | 8 +- .../src/apps/orchestrators/pages/Silent.tsx | 8 +- e2e/apps/redirect-model/src/auth.tsx | 10 ++- .../src/component/LogoutCallback.tsx | 2 +- e2e/apps/redirect-model/src/router.tsx | 2 - .../resource-server/middleware.ts | 2 +- e2e/apps/token-broker/src/auth.tsx | 14 ++- e2e/apps/token-broker/src/broker.tsx | 6 +- .../src/component/LogoutCallback.tsx | 2 +- e2e/apps/token-broker/src/resourceClient.ts | 7 +- .../auth-foundation/jest.browser.config.js | 3 + packages/auth-foundation/jest.node.config.js | 5 +- packages/auth-foundation/package.json | 4 + packages/auth-foundation/rollup.config.mjs | 2 +- packages/auth-foundation/src/Token.ts | 12 +-- packages/auth-foundation/src/client.ts | 7 -- packages/auth-foundation/src/core.ts | 35 ++++++++ packages/auth-foundation/src/index.ts | 34 ++------ packages/auth-foundation/src/internal.ts | 2 +- .../src/jwt/IDTokenValidator.ts | 10 ++- packages/auth-foundation/src/jwt/JWT.ts | 7 +- packages/auth-foundation/src/oauth2/client.ts | 38 ++++----- .../auth-foundation/src/oauth2/dpop/index.ts | 6 +- .../auth-foundation/src/platform/Platform.ts | 85 +++++++++++++++++++ .../src/utils/TimeCoordinator.ts | 27 ++++-- .../test/jest.setupAfterEnv.ts | 8 ++ .../test/spec/oauth2/client.spec.ts | 15 ++-- packages/auth-foundation/test/tsconfig.json | 3 +- packages/oauth2-flows/jest.browser.config.js | 2 +- packages/oauth2-flows/jest.node.config.js | 2 +- packages/oauth2-flows/rollup.config.mjs | 2 +- packages/oauth2-flows/src/AuthTransaction.ts | 2 +- .../oauth2-flows/src/AuthenticationFlow.ts | 2 +- .../src/AuthorizationCodeFlow/index.ts | 4 +- packages/oauth2-flows/src/LogoutFlow.ts | 2 +- .../src/SessionLogoutFlow/index.ts | 4 +- .../test/spec/AuthTransaction.spec.ts | 1 + .../test/spec/AuthorizationCodeFlow.spec.ts | 4 +- .../test/spec/SessionLogoutFlow.spec.ts | 3 +- packages/spa-platform/jest.config.js | 4 +- packages/spa-platform/package.json | 12 --- packages/spa-platform/rollup.config.mjs | 8 +- .../spa-platform/src/Credential/Credential.ts | 2 +- .../src/Credential/CredentialCoordinator.ts | 6 +- .../src/Credential/CredentialDataSource.ts | 2 +- .../src/Credential/TokenStorage.ts | 10 +-- .../spa-platform/src/FetchClient/index.ts | 2 +- .../src/flows/AuthorizationCodeFlow.ts | 2 +- .../src/flows/TransactionStorage.ts | 2 +- packages/spa-platform/src/index.ts | 29 ++++++- .../AuthorizationCodeFlowOrchestrator.ts | 4 +- .../orchestrators/HostOrchestrator/Host.ts | 4 +- .../HostOrchestrator/OrchestrationBridge.ts | 2 +- .../orchestrators/HostOrchestrator/SubApp.ts | 4 +- .../orchestrators/HostOrchestrator/index.ts | 2 +- .../spa-platform/src/platform/OAuth2Client.ts | 17 ++-- packages/spa-platform/src/platform/Token.ts | 32 ------- .../src/platform/dpop/authority.ts | 2 +- .../src/platform/dpop/nonceCache.ts | 7 +- packages/spa-platform/src/platform/index.ts | 1 - .../src/utils/LocalBroadcastChannel.ts | 2 +- .../src/utils/LocalStorageCache.ts | 15 ++-- .../test/helpers/makeTestResource.ts | 3 +- .../test/spec/BrowserTokenStorage.spec.ts | 3 +- .../spec/flows/AuthorizationCodeFlow.spec.ts | 3 +- .../AuthorizationCodeFlowOrchestrator.spec.ts | 3 +- .../orchestrators/HostOrchestrator.spec.ts | 3 +- .../test/spec/platform/Token.spec.ts | 12 --- tooling/eslint-config/sdk.js | 15 +++- 71 files changed, 359 insertions(+), 264 deletions(-) delete mode 100644 packages/auth-foundation/src/client.ts create mode 100644 packages/auth-foundation/src/core.ts create mode 100644 packages/auth-foundation/src/platform/Platform.ts create mode 100644 packages/auth-foundation/test/jest.setupAfterEnv.ts delete mode 100644 packages/spa-platform/src/platform/Token.ts delete mode 100644 packages/spa-platform/test/spec/platform/Token.spec.ts diff --git a/e2e/apps/express-oidc/strategy.mjs b/e2e/apps/express-oidc/strategy.mjs index 423561c..1f04cbd 100644 --- a/e2e/apps/express-oidc/strategy.mjs +++ b/e2e/apps/express-oidc/strategy.mjs @@ -1,4 +1,5 @@ import { Strategy} from 'passport'; +import { OAuth2Client } from '@okta/auth-foundation'; import { AuthorizationCodeFlow, SessionLogoutFlow, AuthTransaction } from '@okta/oauth2-flows'; @@ -20,8 +21,8 @@ export class OIDCStrategy extends Strategy { } async authenticate (req) { - const flow = new AuthorizationCodeFlow({ - ...authParams, + const client = new OAuth2Client(authParams); + const flow = new AuthorizationCodeFlow(client, { redirectUri: 'http://localhost:8080/login/callback' }); @@ -61,8 +62,8 @@ export class OIDCStrategy extends Strategy { } static async logout (idToken) { - const flow = new SessionLogoutFlow({ - ...authParams, + const client = new OAuth2Client(authParams); + const flow = new SessionLogoutFlow(client, { logoutRedirectUri: 'http://localhost:8080/' }); diff --git a/e2e/apps/redirect-model/src/apps/orchestrators/pages/ProxyHost.tsx b/e2e/apps/redirect-model/src/apps/orchestrators/pages/ProxyHost.tsx index 3c7f600..43ee860 100644 --- a/e2e/apps/redirect-model/src/apps/orchestrators/pages/ProxyHost.tsx +++ b/e2e/apps/redirect-model/src/apps/orchestrators/pages/ProxyHost.tsx @@ -1,6 +1,9 @@ -import { AuthorizationCodeFlow } from '@okta/oauth2-flows'; -import { AuthorizationCodeFlowOrchestrator, HostOrchestrator } from '@okta/spa-platform'; -import { FetchClient } from '@okta/spa-platform/fetch'; +import { + AuthorizationCodeFlow, + AuthorizationCodeFlowOrchestrator, + HostOrchestrator, + FetchClient +} from '@okta/spa-platform'; import { client } from '@/auth'; import { createMessageComponent } from '../createMessageComponent'; diff --git a/e2e/apps/redirect-model/src/apps/orchestrators/pages/Redirect.tsx b/e2e/apps/redirect-model/src/apps/orchestrators/pages/Redirect.tsx index cc49dac..b3f59af 100644 --- a/e2e/apps/redirect-model/src/apps/orchestrators/pages/Redirect.tsx +++ b/e2e/apps/redirect-model/src/apps/orchestrators/pages/Redirect.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; -import { AuthorizationCodeFlow } from '@okta/oauth2-flows'; -import { AuthorizationCodeFlowOrchestrator } from '@okta/spa-platform'; -import { FetchClient } from '@okta/spa-platform/fetch'; +import { + AuthorizationCodeFlow, + AuthorizationCodeFlowOrchestrator, + FetchClient +} from '@okta/spa-platform'; import { client } from '@/auth'; import { Loading } from '@/component/Loading'; import { createMessageComponent } from '../createMessageComponent'; diff --git a/e2e/apps/redirect-model/src/apps/orchestrators/pages/Silent.tsx b/e2e/apps/redirect-model/src/apps/orchestrators/pages/Silent.tsx index 8dfb442..09c9002 100644 --- a/e2e/apps/redirect-model/src/apps/orchestrators/pages/Silent.tsx +++ b/e2e/apps/redirect-model/src/apps/orchestrators/pages/Silent.tsx @@ -1,6 +1,8 @@ -import { AuthorizationCodeFlow } from '@okta/oauth2-flows'; -import { AuthorizationCodeFlowOrchestrator } from '@okta/spa-platform'; -import { FetchClient } from '@okta/spa-platform/fetch'; +import { + AuthorizationCodeFlow, + AuthorizationCodeFlowOrchestrator, + FetchClient +} from '@okta/spa-platform'; import { client } from '@/auth'; import { createMessageComponent } from '../createMessageComponent'; diff --git a/e2e/apps/redirect-model/src/auth.tsx b/e2e/apps/redirect-model/src/auth.tsx index d93fd53..d644afd 100644 --- a/e2e/apps/redirect-model/src/auth.tsx +++ b/e2e/apps/redirect-model/src/auth.tsx @@ -1,6 +1,10 @@ -import { Credential, OAuth2Client, clearDPoPKeyPairs } from '@okta/spa-platform'; -import { AuthorizationCodeFlow, SessionLogoutFlow } from '@okta/spa-platform/flows'; -import { AuthorizationCodeFlowOrchestrator } from '@okta/spa-platform/orchestrator'; +import { + Credential, + OAuth2Client, + clearDPoPKeyPairs, + AuthorizationCodeFlow, + SessionLogoutFlow +} from '@okta/spa-platform'; const USE_DPOP = __USE_DPOP__ === "true"; diff --git a/e2e/apps/redirect-model/src/component/LogoutCallback.tsx b/e2e/apps/redirect-model/src/component/LogoutCallback.tsx index 0727180..b9112ca 100644 --- a/e2e/apps/redirect-model/src/component/LogoutCallback.tsx +++ b/e2e/apps/redirect-model/src/component/LogoutCallback.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useSearchParams } from 'react-router-dom'; -import { getSearchParam } from '@okta/auth-foundation'; +import { getSearchParam } from '@okta/spa-platform'; import { Loading } from './Loading'; diff --git a/e2e/apps/redirect-model/src/router.tsx b/e2e/apps/redirect-model/src/router.tsx index e1dbdba..7427485 100644 --- a/e2e/apps/redirect-model/src/router.tsx +++ b/e2e/apps/redirect-model/src/router.tsx @@ -1,7 +1,5 @@ import { createBrowserRouter } from 'react-router-dom'; import { Credential } from '@okta/spa-platform'; -import { AuthorizationCodeFlow } from '@okta/spa-platform/flows'; -import { signInFlow } from '@/auth'; // import Page components import { App } from './App'; diff --git a/e2e/apps/token-broker/resource-server/middleware.ts b/e2e/apps/token-broker/resource-server/middleware.ts index 6dcf617..f7a96b8 100644 --- a/e2e/apps/token-broker/resource-server/middleware.ts +++ b/e2e/apps/token-broker/resource-server/middleware.ts @@ -1,5 +1,5 @@ import { MockLayer } from 'vite-plugin-mock-server'; -import { JWT, shortID } from '@okta/auth-foundation'; +import { JWT, shortID } from '@okta/auth-foundation/core'; const dpopNonceError = diff --git a/e2e/apps/token-broker/src/auth.tsx b/e2e/apps/token-broker/src/auth.tsx index 2c0a421..7f7e35d 100644 --- a/e2e/apps/token-broker/src/auth.tsx +++ b/e2e/apps/token-broker/src/auth.tsx @@ -1,6 +1,13 @@ -import { Credential, OAuth2Client, clearDPoPKeyPairs } from '@okta/spa-platform'; -import { AuthorizationCodeFlow, SessionLogoutFlow } from '@okta/spa-platform/flows'; -import { AcrValues, JsonRecord, isOAuth2ErrorResponse } from '@okta/auth-foundation'; +import { + Credential, + OAuth2Client, + clearDPoPKeyPairs, + AuthorizationCodeFlow, + SessionLogoutFlow, + type AcrValues, + type JsonRecord, + isOAuth2ErrorResponse, +} from '@okta/spa-platform'; const ADMIN_SPA_REFRESH_TOKEN_TAG = 'admin-spa:mordor-token'; @@ -24,7 +31,6 @@ oauthConfig.baseURL = oauthConfig.issuer; export const client = new OAuth2Client(oauthConfig); - // ############# OAuth Flow Instances ############# // export const signInFlow = new AuthorizationCodeFlow(client, { redirectUri: `${window.location.origin}/login/callback`, diff --git a/e2e/apps/token-broker/src/broker.tsx b/e2e/apps/token-broker/src/broker.tsx index 13c36fc..8f85aca 100644 --- a/e2e/apps/token-broker/src/broker.tsx +++ b/e2e/apps/token-broker/src/broker.tsx @@ -1,10 +1,12 @@ import { + Credential, + Token, + HostOrchestrator, OAuth2ErrorResponse, isOAuth2ErrorResponse, hasSameValues, AcrValues -} from '@okta/auth-foundation'; -import { Credential, Token, HostOrchestrator } from '@okta/spa-platform'; +} from '@okta/spa-platform'; import { signIn, signOutFlow, getMordorToken, handleAcrStepUp } from './auth'; diff --git a/e2e/apps/token-broker/src/component/LogoutCallback.tsx b/e2e/apps/token-broker/src/component/LogoutCallback.tsx index 0727180..b9112ca 100644 --- a/e2e/apps/token-broker/src/component/LogoutCallback.tsx +++ b/e2e/apps/token-broker/src/component/LogoutCallback.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useSearchParams } from 'react-router-dom'; -import { getSearchParam } from '@okta/auth-foundation'; +import { getSearchParam } from '@okta/spa-platform'; import { Loading } from './Loading'; diff --git a/e2e/apps/token-broker/src/resourceClient.ts b/e2e/apps/token-broker/src/resourceClient.ts index 1ff00c3..f233da6 100644 --- a/e2e/apps/token-broker/src/resourceClient.ts +++ b/e2e/apps/token-broker/src/resourceClient.ts @@ -1,5 +1,4 @@ -import { FetchClient } from '@okta/spa-platform/fetch'; -import { HostOrchestrator } from '@okta/spa-platform/orchestrator'; +import { FetchClient, HostOrchestrator, type APIRequest } from '@okta/spa-platform'; import { customScopes } from '@/auth'; @@ -11,11 +10,11 @@ orchestrator.defaultTimeout = 15000; export const fetchClient = new FetchClient(orchestrator); // testing APIClient request interceptors -const interceptor1 = (req: Request) => { +const interceptor1 = (req: APIRequest) => { req.headers.append('foo', '1'); return req; }; -const interceptor2 = (req: Request) => { +const interceptor2 = (req: APIRequest) => { req.headers.append('bar', '1'); return req; }; diff --git a/packages/auth-foundation/jest.browser.config.js b/packages/auth-foundation/jest.browser.config.js index 4b739a2..d74b450 100644 --- a/packages/auth-foundation/jest.browser.config.js +++ b/packages/auth-foundation/jest.browser.config.js @@ -8,6 +8,9 @@ const config = { __PKG_NAME__: pkg.name, __PKG_VERSION__: pkg.version, }, + setupFilesAfterEnv: [ + '/test/jest.setupAfterEnv.ts' + ] }; export default config; diff --git a/packages/auth-foundation/jest.node.config.js b/packages/auth-foundation/jest.node.config.js index 8510470..85a54b5 100644 --- a/packages/auth-foundation/jest.node.config.js +++ b/packages/auth-foundation/jest.node.config.js @@ -7,7 +7,10 @@ const config = { globals: { __PKG_NAME__: pkg.name, __PKG_VERSION__: pkg.version, - } + }, + setupFilesAfterEnv: [ + '/test/jest.setupAfterEnv.ts' + ] }; export default config; diff --git a/packages/auth-foundation/package.json b/packages/auth-foundation/package.json index 3e01842..2503bb8 100644 --- a/packages/auth-foundation/package.json +++ b/packages/auth-foundation/package.json @@ -22,6 +22,10 @@ "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js" }, + "./core" : { + "types": "./dist/types/core.d.ts", + "import": "./dist/esm/core.js" + }, "./client": { "types": "./dist/types/client.d.ts", "import": "./dist/esm/client.js" diff --git a/packages/auth-foundation/rollup.config.mjs b/packages/auth-foundation/rollup.config.mjs index 76c9f5e..3195b02 100644 --- a/packages/auth-foundation/rollup.config.mjs +++ b/packages/auth-foundation/rollup.config.mjs @@ -7,6 +7,6 @@ const base = baseConfig(ts, pkg); export default { ...base, - input: [base.input, 'src/client.ts', 'src/internal.ts'], + input: [base.input, 'src/core.ts', 'src/internal.ts'], external: [...Object.keys(pkg.dependencies)], }; diff --git a/packages/auth-foundation/src/Token.ts b/packages/auth-foundation/src/Token.ts index 966f68c..652a36d 100644 --- a/packages/auth-foundation/src/Token.ts +++ b/packages/auth-foundation/src/Token.ts @@ -20,7 +20,7 @@ import { JWT } from './jwt/index.ts'; import { OAuth2Request } from './http/index.ts'; import { DefaultDPoPSigningAuthority, DPoPSigningAuthority } from './oauth2/dpop/index.ts'; import { Timestamp } from './utils/TimeCoordinator.ts'; -import TimeCoordinator from './utils/TimeCoordinator.ts'; +import { Platform } from './platform/Platform.ts'; /** * @module Token @@ -114,7 +114,7 @@ export class Token implements JSONSerializable, Expires, RequestAuthorizer { constructor (obj: TokenInit) { const id = obj?.id ?? shortID(); this.id = id; - this.issuedAt = obj?.issuedAt ? new Date(obj?.issuedAt) : TimeCoordinator.now().asDate; + this.issuedAt = obj?.issuedAt ? new Date(obj?.issuedAt) : Platform.TimeCoordinator.now().asDate; this.accessToken = obj.accessToken; if (obj.idToken) { @@ -200,7 +200,7 @@ export class Token implements JSONSerializable, Expires, RequestAuthorizer { */ get isExpired (): boolean { // TODO: revisit - const now = TimeCoordinator.now().asDate; + const now = Platform.TimeCoordinator.now().asDate; return +this.expiresAt - +now <= 0; } @@ -219,7 +219,7 @@ export class Token implements JSONSerializable, Expires, RequestAuthorizer { * @see {@link Token.willBeValidIn} */ willBeExpiredIn (duration: Seconds) { - const ts = Timestamp.from(TimeCoordinator.now().value + duration); + const ts = Timestamp.from(Platform.TimeCoordinator.now().value + duration); return ts.isAfter(this.expiresAt); } @@ -289,7 +289,7 @@ export class Token implements JSONSerializable, Expires, RequestAuthorizer { return Token.create({ id: this.id, - issuedAt: (this.issuedAt ?? token.issuedAt ?? TimeCoordinator.now().asDate).valueOf() / 1000, + issuedAt: (this.issuedAt ?? token.issuedAt ?? Platform.TimeCoordinator.now().asDate).valueOf() / 1000, tokenType: this.tokenType, expiresIn: this.expiresIn, accessToken: this.accessToken, @@ -320,7 +320,7 @@ export class Token implements JSONSerializable, Expires, RequestAuthorizer { if (this.tokenType === 'DPoP') { const keyPairId = this.context.dpopPairId; // .generateDPoPProof() will throw if dpopPairId is undefined - await this.dpopSigningAuthority.sign(request, { keyPairId, nonce: dpopNonce, accessToken: this.accessToken }); + await Platform.DPoPSigningAuthority.sign(request, { keyPairId, nonce: dpopNonce, accessToken: this.accessToken }); } request.headers.set('Authorization', `${this.tokenType} ${this.accessToken}`); diff --git a/packages/auth-foundation/src/client.ts b/packages/auth-foundation/src/client.ts deleted file mode 100644 index 876e208..0000000 --- a/packages/auth-foundation/src/client.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @packageDocumentation - * @internal - */ - -import { OAuth2Client } from './oauth2/client.ts'; -export default OAuth2Client; diff --git a/packages/auth-foundation/src/core.ts b/packages/auth-foundation/src/core.ts new file mode 100644 index 0000000..dd43bd1 --- /dev/null +++ b/packages/auth-foundation/src/core.ts @@ -0,0 +1,35 @@ +/** + * @module Core + */ + +// types +export * from './types/index.ts'; + +// common +export * from './http/index.ts'; +export * from './errors/index.ts'; +export * from './utils/index.ts'; +export * from './utils/EventEmitter.ts'; +export * from './utils/TimeCoordinator.ts'; +export * from './utils/TaskBridge.ts'; + +// crypto / jwt +export { randomBytes, shortID } from './crypto/index.ts'; +export * from './jwt/index.ts'; + +// oauth2 +export * from './oauth2/pkce.ts'; +export * from './oauth2/dpop/index.ts'; +export * from './oauth2/client.ts'; + +// Credential & Token +export * from './Token.ts'; +export * from './Credential/index.ts'; +export * from './TokenOrchestrator.ts'; + +// FetchClient +export * from './FetchClient.ts'; + +export { addEnv } from './http/oktaUserAgent.ts'; + +export { Platform } from './platform/Platform.ts'; diff --git a/packages/auth-foundation/src/index.ts b/packages/auth-foundation/src/index.ts index 552fd67..2f5c748 100644 --- a/packages/auth-foundation/src/index.ts +++ b/packages/auth-foundation/src/index.ts @@ -2,31 +2,13 @@ * @module Core */ -// types -export * from './types/index.ts'; +export * from './core.ts'; -// common -export * from './http/index.ts'; -export * from './errors/index.ts'; -export * from './utils/index.ts'; -export * from './utils/EventEmitter.ts'; -export * from './utils/TimeCoordinator.ts'; -export * from './utils/TaskBridge.ts'; +import { Platform } from './platform/Platform.ts'; +import timeCoordinator from './utils/TimeCoordinator.ts'; +import { DefaultDPoPSigningAuthority } from './oauth2/dpop/index.ts'; -// crypto / jwt -export { randomBytes, shortID } from './crypto/index.ts'; -export * from './jwt/index.ts'; - -// oauth2 -export * from './oauth2/pkce.ts'; -export * from './oauth2/dpop/index.ts'; - -// Credential & Token -export * from './Token.ts'; -export * from './Credential/index.ts'; -export * from './TokenOrchestrator.ts'; - -// FetchClient -export * from './FetchClient.ts'; - -export { addEnv } from './http/oktaUserAgent.ts'; +Platform.registerDefaultsLoader(() => ({ + TimeCoordinator: timeCoordinator, + DPoPSigningAuthority: DefaultDPoPSigningAuthority +})); diff --git a/packages/auth-foundation/src/internal.ts b/packages/auth-foundation/src/internal.ts index ed6a46b..3758a99 100644 --- a/packages/auth-foundation/src/internal.ts +++ b/packages/auth-foundation/src/internal.ts @@ -6,5 +6,5 @@ export * from './internals/index.ts'; export { addEnv } from './http/oktaUserAgent.ts'; - export { buf, b64u } from './crypto/index.ts'; +export { default as TimeCoordinator } from './utils/TimeCoordinator.ts'; diff --git a/packages/auth-foundation/src/jwt/IDTokenValidator.ts b/packages/auth-foundation/src/jwt/IDTokenValidator.ts index ec56bd9..7511727 100644 --- a/packages/auth-foundation/src/jwt/IDTokenValidator.ts +++ b/packages/auth-foundation/src/jwt/IDTokenValidator.ts @@ -8,7 +8,9 @@ import type { AcrValues } from '../types/index.ts'; import type { JWT } from './JWT.ts'; import { JWTError } from '../errors/index.ts'; -import TimeCoordinator, { Timestamp } from '../utils/TimeCoordinator.ts'; +import { Timestamp } from '../utils/TimeCoordinator.ts'; +import { Platform } from '../platform/Platform.ts'; + /** * @group JWT @@ -98,7 +100,7 @@ export const DefaultIDTokenValidator: IDTokenValidator = { break; case 'expirationTime': - const now = TimeCoordinator.now(); + const now = Platform.TimeCoordinator.now(); if (jwt.expirationTime && now.isBefore(jwt.expirationTime)) { break; } @@ -107,7 +109,7 @@ export const DefaultIDTokenValidator: IDTokenValidator = { case 'issuedAtTime': if (jwt.issuedAt) { const issuedAt: Date = jwt.issuedAt; - const now = TimeCoordinator.now(); + const now = Platform.TimeCoordinator.now(); if (Math.abs(now.timeSince(issuedAt)) <= DefaultIDTokenValidator.issuedAtGraceInterval) { break; } @@ -131,7 +133,7 @@ export const DefaultIDTokenValidator: IDTokenValidator = { // compare `auth_time` to a timestamp to determine how long ago authentication was completed // the timestamp can either be the issuedAt (iat) claim or a coordinated .now() - const issuedAt = Timestamp.from(jwt?.issuedAt ?? TimeCoordinator.now()); + const issuedAt = Timestamp.from(jwt?.issuedAt ?? Platform.TimeCoordinator.now()); const elapsedTime = issuedAt.timeSince(authTime); if (elapsedTime > context.maxAge) { throw new JWTError('exceeds maxAge'); diff --git a/packages/auth-foundation/src/jwt/JWT.ts b/packages/auth-foundation/src/jwt/JWT.ts index a81f7cb..e7f94d5 100644 --- a/packages/auth-foundation/src/jwt/JWT.ts +++ b/packages/auth-foundation/src/jwt/JWT.ts @@ -7,10 +7,11 @@ import type { JsonRecord, RawRepresentable, Expires, TimeInterval } from '../types/index.ts'; import { JWTError } from '../errors/index.ts'; import { validateString } from '../internals/validators.ts'; -import TimeCoordinator from '../utils/TimeCoordinator.ts'; import { JWK, JWKS } from './JWK.ts'; import { buf, b64u } from '../crypto/index.ts'; import { IDTokenValidator } from './IDTokenValidator.ts'; +import { Platform } from '../platform/Platform.ts'; + /** * @group JWT @@ -70,7 +71,7 @@ function validateBody (claims: JsonRecord) { throw new JWTError('Unexpected `nbf` claim type'); } - if (!TimeCoordinator.now().isAfter(claims.nbf)) { + if (!Platform.TimeCoordinator.now().isAfter(claims.nbf)) { throw new JWTError('`nbf` claim is unexpectedly in the past'); } } @@ -242,7 +243,7 @@ export class JWT implements RawRepresentable, Expires { if (!this.expirationTime) { return false; } - const now = TimeCoordinator.now(); + const now = Platform.TimeCoordinator.now(); return now.isBefore(this.expirationTime); } get isValid(): boolean { diff --git a/packages/auth-foundation/src/oauth2/client.ts b/packages/auth-foundation/src/oauth2/client.ts index f73f6bc..c72a0f5 100644 --- a/packages/auth-foundation/src/oauth2/client.ts +++ b/packages/auth-foundation/src/oauth2/client.ts @@ -21,14 +21,14 @@ import { TokenHashValidator } from '../jwt/index.ts'; import { APIClient, APIRequest } from '../http/index.ts'; -import { DefaultDPoPSigningAuthority, type DPoPSigningAuthority } from './dpop/index.ts'; import { Configuration as ConfigurationConstructor, type ConfigurationParams } from './configuration.ts'; import { TokenInit, Token } from '../Token.ts'; 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'; +import { Timestamp } from '../utils/TimeCoordinator.ts'; +import { Platform } from '../platform/Platform.ts'; // ref: https://developer.okta.com/docs/reference/api/oidc/ @@ -38,10 +38,6 @@ import TimeCoordinator, { Timestamp } from '../utils/TimeCoordinator.ts'; * @noInheritDoc */ export class OAuth2Client extends APIClient { - /** - * @group Customizations - */ - public readonly dpopSigningAuthority: DPoPSigningAuthority = DefaultDPoPSigningAuthority; /** * @group Customizations */ @@ -67,11 +63,6 @@ export class OAuth2Client e this.configuration = configuration; } - /** @internal */ - protected createToken (init: TokenInit): Token { - return new Token(init); - } - /** @internal */ protected getDPoPNonceCacheKey (request: APIRequest): string { return `${this.configuration.clientId}.${super.getDPoPNonceCacheKey(request)}`; @@ -101,7 +92,8 @@ export class OAuth2Client e } /** @internal */ - protected async prepareDPoPNonceRetry (request: APIRequest): Promise { + protected async prepareDPoPNonceRetry (request: APIRequest, nonce: string): Promise { + request.context.dpopNonce = nonce; return this.signTokenRequestWithDPoP(request); } @@ -109,7 +101,7 @@ export class OAuth2Client e const { dpopPairId } = request.context; // 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 }); + await Platform.DPoPSigningAuthority.sign(request, { keyPairId: dpopPairId, nonce: dpopNonce }); } protected async processResponse(response: Response, request: APIRequest): Promise { @@ -123,7 +115,7 @@ export class OAuth2Client e if (parsedDate.toString() !== 'Invalid Date') { const serverTime = Timestamp.from(parsedDate); const skew = Math.round(serverTime.timeSince(Date.now() / 1000)); - TimeCoordinator.clockSkew = skew; + Platform.TimeCoordinator.clockSkew = skew; } } } @@ -188,7 +180,7 @@ export class OAuth2Client e tokenRequest: Token.TokenRequest, requestContext: OAuth2Client.TokenRequestContext = {} ): Promise { - const { keyPairId: dpopPairId } = requestContext; + const { dpopPairId } = requestContext; const request = tokenRequest.prepare({ dpopPairId }); const { acrValues, maxAge } = tokenRequest; @@ -208,7 +200,7 @@ export class OAuth2Client e // 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 + Math.abs(Date.now() - Platform.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 @@ -255,7 +247,7 @@ export class OAuth2Client e result.id = tokenRequest.id; } - const token = this.createToken(result); + const token = new Token(result); return token; } @@ -320,8 +312,8 @@ export class OAuth2Client e public async exchange (request: Token.TokenRequest): Promise { const context: OAuth2Client.TokenRequestContext = {}; if (this.configuration.dpop) { - const keyPairId = await this.dpopSigningAuthority.createDPoPKeyPair(); - context.keyPairId = keyPairId; + const dpopPairId = await Platform.DPoPSigningAuthority.createDPoPKeyPair(); + context.dpopPairId = dpopPairId; } const [keySet, response] = await Promise.all([ @@ -405,7 +397,7 @@ export class OAuth2Client e const context: OAuth2Client.TokenRequestContext = {}; if (token.context?.dpopPairId) { - context.keyPairId = token.context.dpopPairId; + context.dpopPairId = token.context.dpopPairId; } const [keySet, response] = await Promise.all([ @@ -424,14 +416,14 @@ export class OAuth2Client e // 1. providing a sub-set of scopes // 2. providing no scopes (empty array) if (!hasSameValues(response.scopes, token.scopes) || scopes?.length === 0) { - refreshedToken = this.createToken({ + refreshedToken = new Token({ ...(token.toJSON() as TokenInit), id: token.id, refreshToken: response.refreshToken }); const tokenInit = { ...response.toJSON() } as TokenInit; - newToken = this.createToken({ + newToken = new Token({ ...tokenInit, // downscoped token should "inherit" context from "parent" token context: { ...refreshedToken.context, ...tokenInit.context }, @@ -582,7 +574,7 @@ export namespace OAuth2Client { /** @internal */ export type TokenRequestContext = { - keyPairId?: string; + dpopPairId?: string; }; /** @internal */ diff --git a/packages/auth-foundation/src/oauth2/dpop/index.ts b/packages/auth-foundation/src/oauth2/dpop/index.ts index e1b745e..d91011b 100644 --- a/packages/auth-foundation/src/oauth2/dpop/index.ts +++ b/packages/auth-foundation/src/oauth2/dpop/index.ts @@ -9,7 +9,7 @@ import { JWT } from '../../jwt/index.ts'; import { DPoPStorage } from './storage.ts'; import { DPoPNonceCache } from './nonceCache.ts'; import { DPoPError } from '../../errors/DPoPError.ts'; -import TimeCoordinator from '../../utils/TimeCoordinator.ts'; +import { Platform } from '../../platform/Platform.ts'; export { DPoPNonceCache }; export type { DPoPHeaders, DPoPClaims, DPoPProofParams, DPoPStorage }; @@ -62,11 +62,9 @@ export class DPoPSigningAuthorityImpl implements DPoPSigningAuthority { * @returns `id` representing the generated key pair */ async createDPoPKeyPair (): Promise { - // export async function createDPoPKeyPair (): Promise<{keyPair: CryptoKeyPair, keyPairId: string}> { const keyPairId = shortID(); const keyPair = await this.generateKeyPair(); await this.store.add(keyPairId, keyPair); - // return { keyPair, keyPairId }; return keyPairId; } @@ -118,7 +116,7 @@ export class DPoPSigningAuthorityImpl implements DPoPSigningAuthority { const claims: DPoPClaims = { htm: request.method, htu: `${url.origin}${url.pathname}`, - iat: TimeCoordinator.now().value, + iat: Platform.TimeCoordinator.now().value, jti: randomBytes(), nonce }; diff --git a/packages/auth-foundation/src/platform/Platform.ts b/packages/auth-foundation/src/platform/Platform.ts new file mode 100644 index 0000000..3d13b50 --- /dev/null +++ b/packages/auth-foundation/src/platform/Platform.ts @@ -0,0 +1,85 @@ +import { AuthSdkError } from '../errors/AuthSdkError.ts'; +import type { DPoPSigningAuthority } from '../oauth2/dpop/index.ts'; +import type { TimeCoordinator } from '../utils/TimeCoordinator.ts'; + + +export interface PlatformDependencies { + TimeCoordinator: TimeCoordinator; + DPoPSigningAuthority: DPoPSigningAuthority; +} + +export class PlatformRegistryError extends AuthSdkError {} + +export class PlatformRegistry { + #deps: PlatformDependencies | null = null; + #defaultsLoader: (() => PlatformDependencies) | null = null; + + /** + * Override default platform dependencies globally + * + * @remarks + * Call this once at application startup before using any SDK components. + * Partial updates are supported - only override what you need. + */ + public configure (dependencies: Partial): void { + this.#deps = { + ...this.getDefaults(), + ...dependencies + }; + } + + /** @internal - Called by full build entry point */ + public registerDefaultsLoader(loader: () => PlatformDependencies): void { + this.#defaultsLoader = loader; + } + + /** + * Resets loaded dependencies. For testing purposes mostly. + */ + public reset (): void { + this.#deps = null; + } + + /** + * Get all current dependencies (configured or defaults) + */ + protected get resolved (): PlatformDependencies { + return this.#deps ?? this.getDefaults(); + } + + /** + * Override in subclasses to provide platform-specific defaults + * + * @internal + */ + protected getDefaults(): PlatformDependencies { + if (!this.#defaultsLoader) { + throw new PlatformRegistryError( + `No platform defaults available. Import from "@okta/auth-foundation" directly or call Platform.configure()` + ); + } + return this.#defaultsLoader(); + } + + /** + * Get the current TimeCoordinator instance + * + * @remarks + * Returns configured override or factory default + */ + public get TimeCoordinator(): TimeCoordinator { + return this.resolved.TimeCoordinator; + } + + /** + * Get the current DPoPSigningAuthority instance + * + * @remarks + * Returns configured override or factory default + */ + public get DPoPSigningAuthority(): DPoPSigningAuthority { + return this.resolved.DPoPSigningAuthority; + } +} + +export const Platform = new PlatformRegistry(); diff --git a/packages/auth-foundation/src/utils/TimeCoordinator.ts b/packages/auth-foundation/src/utils/TimeCoordinator.ts index 431e7b0..8824e29 100644 --- a/packages/auth-foundation/src/utils/TimeCoordinator.ts +++ b/packages/auth-foundation/src/utils/TimeCoordinator.ts @@ -51,7 +51,7 @@ export class Timestamp { isBefore (t: EpochTimestamp | Date | Timestamp): boolean { t = Timestamp.from(t).value; // eslint-disable-next-line @typescript-eslint/no-use-before-define - return this.ts < t - TimeCoordinator.clockTolerance; + return this.ts < t - timeCoordinator.clockTolerance; } isAfter (t: Timestamp): boolean; @@ -60,7 +60,7 @@ export class Timestamp { isAfter (t: EpochTimestamp | Date | Timestamp): boolean { t = Timestamp.from(t).value; // eslint-disable-next-line @typescript-eslint/no-use-before-define - return this.ts > t + TimeCoordinator.clockTolerance; + return this.ts > t + timeCoordinator.clockTolerance; } timeSince (t: Timestamp): Seconds; @@ -81,9 +81,18 @@ export class Timestamp { /** * @group TimeCoordinator */ -class TimeCoordinator { +export interface TimeCoordinator { + clockSkew: Seconds; + clockTolerance: Seconds; + now: () => Timestamp; +} + +/** + * @group TimeCoordinator + */ +class DefaultTimeCoordinator implements TimeCoordinator { #skew = 0; - static #tolerance = 0; + #tolerance = 0; get clockSkew (): Seconds { return this.#skew; @@ -93,12 +102,12 @@ class TimeCoordinator { this.#skew = skew; } - static get clockTolerance (): Seconds { - return TimeCoordinator.#tolerance; + get clockTolerance (): Seconds { + return this.#tolerance; } - static set clockTolerance (tolerance: Seconds) { - TimeCoordinator.#tolerance = tolerance; + set clockTolerance (tolerance: Seconds) { + this.#tolerance = tolerance; } now (): Timestamp { @@ -107,7 +116,7 @@ class TimeCoordinator { } } -const timeCoordinator = new TimeCoordinator(); +const timeCoordinator = new DefaultTimeCoordinator(); /** @internal */ export default timeCoordinator; diff --git a/packages/auth-foundation/test/jest.setupAfterEnv.ts b/packages/auth-foundation/test/jest.setupAfterEnv.ts new file mode 100644 index 0000000..34898ca --- /dev/null +++ b/packages/auth-foundation/test/jest.setupAfterEnv.ts @@ -0,0 +1,8 @@ +import { Platform } from 'src/platform/Platform'; +import TimeCoordinator from 'src/utils/TimeCoordinator'; +import { DefaultDPoPSigningAuthority } from 'src/oauth2/dpop'; + +Platform.registerDefaultsLoader(() => ({ + TimeCoordinator, + DPoPSigningAuthority: DefaultDPoPSigningAuthority +})); diff --git a/packages/auth-foundation/test/spec/oauth2/client.spec.ts b/packages/auth-foundation/test/spec/oauth2/client.spec.ts index 0ec8a7c..38bccfd 100644 --- a/packages/auth-foundation/test/spec/oauth2/client.spec.ts +++ b/packages/auth-foundation/test/spec/oauth2/client.spec.ts @@ -8,6 +8,7 @@ jest.mock('src/http/oktaUserAgent', () => { import { Token, TokenInit } from 'src/Token'; import { OAuth2Client } from 'src/oauth2/client'; import { OAuth2Error, TokenError, JWTError } from 'src/errors'; +import { DefaultDPoPSigningAuthority } from 'src/oauth2/dpop'; import { mockTokenResponse } from '@repo/jest-helpers/browser/helpers'; const fetchSpy = global.fetch = jest.fn(); @@ -122,8 +123,8 @@ describe('OAuth2Client', () => { it('dpop nonce / cache', async () => { client.configuration.dpop = true; jest.spyOn(client, 'jwks').mockResolvedValue({}); - jest.spyOn(client.dpopSigningAuthority, 'createDPoPKeyPair').mockResolvedValue('dpopPairId'); - jest.spyOn(client.dpopSigningAuthority, 'sign').mockImplementation((request) => Promise.resolve(request)); + jest.spyOn(DefaultDPoPSigningAuthority, 'createDPoPKeyPair').mockResolvedValue('dpopPairId'); + jest.spyOn(DefaultDPoPSigningAuthority, 'sign').mockImplementation((request) => Promise.resolve(request)); fetchSpy.mockImplementation( () => Response.json({ token_type: 'DPoP', expires_in: 300, @@ -145,8 +146,8 @@ describe('OAuth2Client', () => { }); await client.exchange(tokenRequest1); expect(fetchSpy).toHaveBeenLastCalledWith(expect.any(Request)); - expect(client.dpopSigningAuthority.sign).toHaveBeenCalledTimes(1); - expect(client.dpopSigningAuthority.sign).toHaveBeenLastCalledWith( + expect(DefaultDPoPSigningAuthority.sign).toHaveBeenCalledTimes(1); + expect(DefaultDPoPSigningAuthority.sign).toHaveBeenLastCalledWith( expect.any(Request), expect.objectContaining({ keyPairId: 'dpopPairId' @@ -164,8 +165,8 @@ describe('OAuth2Client', () => { }); await client.exchange(tokenRequest2); expect(fetchSpy).toHaveBeenLastCalledWith(expect.any(Request)); - expect(client.dpopSigningAuthority.sign).toHaveBeenCalledTimes(2); - expect(client.dpopSigningAuthority.sign).toHaveBeenLastCalledWith( + expect(DefaultDPoPSigningAuthority.sign).toHaveBeenCalledTimes(2); + expect(DefaultDPoPSigningAuthority.sign).toHaveBeenLastCalledWith( expect.any(Request), expect.objectContaining({ keyPairId: 'dpopPairId', @@ -549,7 +550,7 @@ describe('OAuth2Client', () => { describe('DPoP proof clock skew recovery', () => { beforeEach(() => { client.configuration.dpop = true; - jest.spyOn(client.dpopSigningAuthority, 'sign').mockImplementation((request) => request); + jest.spyOn(DefaultDPoPSigningAuthority, 'sign').mockImplementation((request) => Promise.resolve(request)); }); test('isDPoPProofClockSkewError', () => { diff --git a/packages/auth-foundation/test/tsconfig.json b/packages/auth-foundation/test/tsconfig.json index cb145ae..86eca29 100644 --- a/packages/auth-foundation/test/tsconfig.json +++ b/packages/auth-foundation/test/tsconfig.json @@ -12,7 +12,8 @@ "apps/**/*.ts", "e2e/**/*.ts", "helpers/**/*.ts", - "spec/**/*.ts", + "spec/**/*.ts", + "jest.setupAfterEnv.ts", ], "exclude": [ "node_modules" diff --git a/packages/oauth2-flows/jest.browser.config.js b/packages/oauth2-flows/jest.browser.config.js index f0815db..b2239e9 100644 --- a/packages/oauth2-flows/jest.browser.config.js +++ b/packages/oauth2-flows/jest.browser.config.js @@ -9,7 +9,7 @@ const config = { __PKG_VERSION__: pkg.version, }, moduleNameMapper: { - '^@okta/auth-foundation/client$': '/../auth-foundation/src/client.ts', + '^@okta/auth-foundation/core$': '/../auth-foundation/src/core.ts', '^@okta/auth-foundation/internal$': '/../auth-foundation/src/internal.ts', '^@okta/auth-foundation$': '/../auth-foundation/src/index.ts' }, diff --git a/packages/oauth2-flows/jest.node.config.js b/packages/oauth2-flows/jest.node.config.js index 2bee184..172c544 100644 --- a/packages/oauth2-flows/jest.node.config.js +++ b/packages/oauth2-flows/jest.node.config.js @@ -9,7 +9,7 @@ const config = { __PKG_VERSION__: pkg.version, }, moduleNameMapper: { - '^@okta/auth-foundation/client$': '/../auth-foundation/src/client.ts', + '^@okta/auth-foundation/core$': '/../auth-foundation/src/core.ts', '^@okta/auth-foundation/internal$': '/../auth-foundation/src/internal.ts', '^@okta/auth-foundation$': '/../auth-foundation/src/index.ts', }, diff --git a/packages/oauth2-flows/rollup.config.mjs b/packages/oauth2-flows/rollup.config.mjs index 542a166..8757ec8 100644 --- a/packages/oauth2-flows/rollup.config.mjs +++ b/packages/oauth2-flows/rollup.config.mjs @@ -6,7 +6,7 @@ export default { ...baseConfig(ts, pkg), external: [ ...Object.keys(pkg.peerDependencies), - '@okta/auth-foundation/client', + '@okta/auth-foundation/core', '@okta/auth-foundation/internal' ], }; diff --git a/packages/oauth2-flows/src/AuthTransaction.ts b/packages/oauth2-flows/src/AuthTransaction.ts index f43e8ad..544a6a2 100644 --- a/packages/oauth2-flows/src/AuthTransaction.ts +++ b/packages/oauth2-flows/src/AuthTransaction.ts @@ -3,7 +3,7 @@ * @mergeModuleWith Core */ -import { randomBytes, type JsonRecord } from '@okta/auth-foundation'; +import { randomBytes, type JsonRecord } from '@okta/auth-foundation/core'; import { AuthContext } from './types.ts'; diff --git a/packages/oauth2-flows/src/AuthenticationFlow.ts b/packages/oauth2-flows/src/AuthenticationFlow.ts index ec9175c..b67f962 100644 --- a/packages/oauth2-flows/src/AuthenticationFlow.ts +++ b/packages/oauth2-flows/src/AuthenticationFlow.ts @@ -2,7 +2,7 @@ * @module Core */ -import { EventEmitter, Emitter, AuthSdkError } from '@okta/auth-foundation'; +import { EventEmitter, Emitter, AuthSdkError } from '@okta/auth-foundation/core'; /** diff --git a/packages/oauth2-flows/src/AuthorizationCodeFlow/index.ts b/packages/oauth2-flows/src/AuthorizationCodeFlow/index.ts index d6dbde7..a9b56b1 100644 --- a/packages/oauth2-flows/src/AuthorizationCodeFlow/index.ts +++ b/packages/oauth2-flows/src/AuthorizationCodeFlow/index.ts @@ -10,6 +10,7 @@ import { type TimeInterval, type AcrValues, type JsonRecord, + OAuth2Client, PKCE, OAuth2Error, isOAuth2ErrorResponse, @@ -18,8 +19,7 @@ import { mergeURLSearchParameters, Token, AuthSdkError, -} from '@okta/auth-foundation'; -import OAuth2Client from '@okta/auth-foundation/client'; +} from '@okta/auth-foundation/core'; import { AuthenticationFlow, AuthenticationFlowError diff --git a/packages/oauth2-flows/src/LogoutFlow.ts b/packages/oauth2-flows/src/LogoutFlow.ts index cb979fd..487a75e 100644 --- a/packages/oauth2-flows/src/LogoutFlow.ts +++ b/packages/oauth2-flows/src/LogoutFlow.ts @@ -2,7 +2,7 @@ * @module Core */ -import { AuthSdkError } from '@okta/auth-foundation'; +import { AuthSdkError } from '@okta/auth-foundation/core'; import { AuthenticationFlow } from './AuthenticationFlow.ts'; /** diff --git a/packages/oauth2-flows/src/SessionLogoutFlow/index.ts b/packages/oauth2-flows/src/SessionLogoutFlow/index.ts index 39f3fe0..04f7067 100644 --- a/packages/oauth2-flows/src/SessionLogoutFlow/index.ts +++ b/packages/oauth2-flows/src/SessionLogoutFlow/index.ts @@ -4,11 +4,11 @@ import type { AuthContext } from '../types.ts'; import { + OAuth2Client, randomBytes, OAuth2Error, mergeURLSearchParameters -} from '@okta/auth-foundation'; -import OAuth2Client from '@okta/auth-foundation/client'; +} from '@okta/auth-foundation/core'; import { LogoutFlow } from '../LogoutFlow.ts'; diff --git a/packages/oauth2-flows/test/spec/AuthTransaction.spec.ts b/packages/oauth2-flows/test/spec/AuthTransaction.spec.ts index 9d55090..8bbaf77 100644 --- a/packages/oauth2-flows/test/spec/AuthTransaction.spec.ts +++ b/packages/oauth2-flows/test/spec/AuthTransaction.spec.ts @@ -1,5 +1,6 @@ import { AuthTransaction, type DefaultTransactionStorage } from 'src/AuthTransaction'; + it('AuthTransaction', async () => { const storage = AuthTransaction.storage as DefaultTransactionStorage; diff --git a/packages/oauth2-flows/test/spec/AuthorizationCodeFlow.spec.ts b/packages/oauth2-flows/test/spec/AuthorizationCodeFlow.spec.ts index 27d9973..a5b7f7a 100644 --- a/packages/oauth2-flows/test/spec/AuthorizationCodeFlow.spec.ts +++ b/packages/oauth2-flows/test/spec/AuthorizationCodeFlow.spec.ts @@ -1,9 +1,9 @@ -import { OAuth2Error } from '@okta/auth-foundation'; -import OAuth2Client from '@okta/auth-foundation/client'; +import { OAuth2Client, OAuth2Error } from '@okta/auth-foundation'; import { AuthenticationFlowError } from 'src/AuthenticationFlow'; import { AuthorizationCodeFlow } from 'src/AuthorizationCodeFlow'; import { AuthTransaction } from 'src/AuthTransaction'; + describe('AuthorizationCodeFlow', () => { const authParams = { baseURL: 'https://fake.okta.com', diff --git a/packages/oauth2-flows/test/spec/SessionLogoutFlow.spec.ts b/packages/oauth2-flows/test/spec/SessionLogoutFlow.spec.ts index 2787a9a..999a852 100644 --- a/packages/oauth2-flows/test/spec/SessionLogoutFlow.spec.ts +++ b/packages/oauth2-flows/test/spec/SessionLogoutFlow.spec.ts @@ -1,5 +1,4 @@ -import { OAuth2Error } from '@okta/auth-foundation'; -import OAuth2Client from '@okta/auth-foundation/client'; +import { OAuth2Client, OAuth2Error } from '@okta/auth-foundation'; import { SessionLogoutFlow } from 'src/SessionLogoutFlow'; diff --git a/packages/spa-platform/jest.config.js b/packages/spa-platform/jest.config.js index b7722f8..39b548e 100644 --- a/packages/spa-platform/jest.config.js +++ b/packages/spa-platform/jest.config.js @@ -15,8 +15,8 @@ const config = { '/src/index.ts', ], moduleNameMapper: { - // TODO: why is this required? yarn workspace and jest don't seem to get along? - '^@okta/auth-foundation/client$': '/../auth-foundation/src/client.ts', + // NOTE: auth-foundation/core maps to src/index.ts so Default Dependencies (like TimeCoordinator) are loaded + '^@okta/auth-foundation/core$': '/../auth-foundation/src/index.ts', '^@okta/auth-foundation/internal$': '/../auth-foundation/src/internal.ts', '^@okta/auth-foundation$': '/../auth-foundation/src/index.ts', '^@okta/oauth2-flows$': '/../oauth2-flows/src/index.ts', diff --git a/packages/spa-platform/package.json b/packages/spa-platform/package.json index c403d2d..fb37364 100644 --- a/packages/spa-platform/package.json +++ b/packages/spa-platform/package.json @@ -18,18 +18,6 @@ "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js" }, - "./fetch": { - "types": "./dist/types/FetchClient/index.d.ts", - "import": "./dist/esm/FetchClient/index.js" - }, - "./orchestrator": { - "types": "./dist/types/orchestrators/index.d.ts", - "import": "./dist/esm/orchestrators/index.js" - }, - "./flows": { - "types": "./dist/types/flows/index.d.ts", - "import": "./dist/esm/flows/index.js" - }, "./package.json": "./package.json" }, "scripts": { diff --git a/packages/spa-platform/rollup.config.mjs b/packages/spa-platform/rollup.config.mjs index d63bb0f..fe4cce7 100644 --- a/packages/spa-platform/rollup.config.mjs +++ b/packages/spa-platform/rollup.config.mjs @@ -6,15 +6,9 @@ const base = baseConfig(ts, pkg); export default { ...base, - input: [ - base.input, - 'src/FetchClient/index.ts', - 'src/orchestrators/index.ts', - 'src/flows/index.ts' - ], external: [ ...Object.keys(pkg.peerDependencies), - '@okta/auth-foundation/client', + '@okta/auth-foundation/core', '@okta/auth-foundation/internal', ], }; diff --git a/packages/spa-platform/src/Credential/Credential.ts b/packages/spa-platform/src/Credential/Credential.ts index 45ce2ef..62861a5 100644 --- a/packages/spa-platform/src/Credential/Credential.ts +++ b/packages/spa-platform/src/Credential/Credential.ts @@ -7,7 +7,7 @@ import { Credential as CredentialBase, type RequestAuthorizer, type JSONSerializable, -} from '@okta/auth-foundation'; +} from '@okta/auth-foundation/core'; import { CredentialCoordinatorImpl } from './CredentialCoordinator.ts'; diff --git a/packages/spa-platform/src/Credential/CredentialCoordinator.ts b/packages/spa-platform/src/Credential/CredentialCoordinator.ts index a36ab7f..b3fc4d5 100644 --- a/packages/spa-platform/src/Credential/CredentialCoordinator.ts +++ b/packages/spa-platform/src/Credential/CredentialCoordinator.ts @@ -9,14 +9,14 @@ import type { TokenStorageEvents, JsonRecord, TokenInit, -} from '@okta/auth-foundation'; +} from '@okta/auth-foundation/core'; import { + Token, CredentialCoordinator, CredentialCoordinatorImpl as CredentialCoordinatorBase, shortID, pause, -} from '@okta/auth-foundation'; -import { Token } from '../platform/index.ts'; +} from '@okta/auth-foundation/core'; import { DefaultCredentialDataSource } from './CredentialDataSource.ts'; import { BrowserTokenStorage } from './TokenStorage.ts'; import { isFirefox } from '../utils/UserAgent.ts'; diff --git a/packages/spa-platform/src/Credential/CredentialDataSource.ts b/packages/spa-platform/src/Credential/CredentialDataSource.ts index 1d3b789..172fe00 100644 --- a/packages/spa-platform/src/Credential/CredentialDataSource.ts +++ b/packages/spa-platform/src/Credential/CredentialDataSource.ts @@ -7,7 +7,7 @@ import { type ConfigurationParams, DefaultCredentialDataSource as BaseCredentialDataSource, CredentialDataSource -} from '@okta/auth-foundation'; +} from '@okta/auth-foundation/core'; import { OAuth2Client } from '../platform/index.ts'; diff --git a/packages/spa-platform/src/Credential/TokenStorage.ts b/packages/spa-platform/src/Credential/TokenStorage.ts index 099cb1e..04206eb 100644 --- a/packages/spa-platform/src/Credential/TokenStorage.ts +++ b/packages/spa-platform/src/Credential/TokenStorage.ts @@ -4,14 +4,14 @@ */ import { - JsonRecord, - TokenStorage, - TokenStorageEvents, + Token, + type JsonRecord, + type TokenStorage, + type TokenStorageEvents, CredentialError, EventEmitter -} from '@okta/auth-foundation'; +} from '@okta/auth-foundation/core'; import { buf, b64u } from '@okta/auth-foundation/internal'; -import { Token } from '../platform/index.ts'; import { IndexedDBStore } from '../utils/IndexedDBStore.ts'; diff --git a/packages/spa-platform/src/FetchClient/index.ts b/packages/spa-platform/src/FetchClient/index.ts index f74c6d6..9375907 100644 --- a/packages/spa-platform/src/FetchClient/index.ts +++ b/packages/spa-platform/src/FetchClient/index.ts @@ -1,7 +1,7 @@ import { FetchClient as FetchClientBase, type DPoPNonceCache -} from '@okta/auth-foundation'; +} from '@okta/auth-foundation/core'; import { PersistentCache } from '../platform/dpop/index.ts'; /** diff --git a/packages/spa-platform/src/flows/AuthorizationCodeFlow.ts b/packages/spa-platform/src/flows/AuthorizationCodeFlow.ts index 187c386..37d86db 100644 --- a/packages/spa-platform/src/flows/AuthorizationCodeFlow.ts +++ b/packages/spa-platform/src/flows/AuthorizationCodeFlow.ts @@ -2,7 +2,7 @@ import { type OAuth2ErrorResponse, isOAuth2ErrorResponse, OAuth2Error -} from '@okta/auth-foundation'; +} from '@okta/auth-foundation/core'; import { AuthTransaction, AuthorizationCodeFlow as AuthorizationCodeFlowBase, diff --git a/packages/spa-platform/src/flows/TransactionStorage.ts b/packages/spa-platform/src/flows/TransactionStorage.ts index 3414732..6cfc538 100644 --- a/packages/spa-platform/src/flows/TransactionStorage.ts +++ b/packages/spa-platform/src/flows/TransactionStorage.ts @@ -1,4 +1,4 @@ -import type { JsonRecord } from '@okta/auth-foundation'; +import type { JsonRecord } from '@okta/auth-foundation/core'; import type { TransactionStorage } from '@okta/oauth2-flows'; import { LocalStorageCache } from '../utils/LocalStorageCache.ts'; diff --git a/packages/spa-platform/src/index.ts b/packages/spa-platform/src/index.ts index c95870c..3fbc355 100644 --- a/packages/spa-platform/src/index.ts +++ b/packages/spa-platform/src/index.ts @@ -9,12 +9,33 @@ import { addEnv } from '@okta/auth-foundation/internal'; declare const __PKG_NAME__: string; declare const __PKG_VERSION__: string; - addEnv(`${__PKG_NAME__}/${__PKG_VERSION__}`); -export * from './platform/index.ts'; -export * from './Credential/index.ts'; +import { Platform } from '@okta/auth-foundation/core'; +import { DefaultSigningAuthority } from './platform/dpop/authority.ts'; +import { TimeCoordinator } from '@okta/auth-foundation/internal'; + +Platform.registerDefaultsLoader(() => ({ + TimeCoordinator, + DPoPSigningAuthority: DefaultSigningAuthority +})); + +export * from '@okta/auth-foundation/core'; + +export { Credential } from './Credential/Credential.ts'; +export { CredentialCoordinatorImpl } from './Credential/CredentialCoordinator.ts'; +export { BrowserTokenStorage } from './Credential/TokenStorage.ts'; +export { DefaultCredentialDataSource } from './Credential/CredentialDataSource.ts'; + +export { FetchClient } from './FetchClient/index.ts'; + export * from './orchestrators/index.ts'; -export * from './FetchClient/index.ts'; + +export * from './flows/index.ts'; + +export { DefaultSigningAuthority } from './platform/dpop/authority.ts'; +export { clearDPoPKeyPairs } from './platform/index.ts'; +export { PersistentCache } from './platform/dpop/nonceCache.ts'; +export { OAuth2Client } from './platform/OAuth2Client.ts'; export * from './utils/isModernBrowser.ts'; diff --git a/packages/spa-platform/src/orchestrators/AuthorizationCodeFlowOrchestrator.ts b/packages/spa-platform/src/orchestrators/AuthorizationCodeFlowOrchestrator.ts index 8ec8981..4f4960a 100644 --- a/packages/spa-platform/src/orchestrators/AuthorizationCodeFlowOrchestrator.ts +++ b/packages/spa-platform/src/orchestrators/AuthorizationCodeFlowOrchestrator.ts @@ -4,14 +4,14 @@ */ import { + Token, hasSameValues, toRelativeUrl, TokenOrchestrator, TokenOrchestratorError, EventEmitter -} from '@okta/auth-foundation'; +} from '@okta/auth-foundation/core'; import { AuthorizationCodeFlow } from '../flows/index.ts'; -import { Token } from '../platform/index.ts'; import { Credential } from '../Credential/index.ts'; diff --git a/packages/spa-platform/src/orchestrators/HostOrchestrator/Host.ts b/packages/spa-platform/src/orchestrators/HostOrchestrator/Host.ts index e33eb33..c56ef87 100644 --- a/packages/spa-platform/src/orchestrators/HostOrchestrator/Host.ts +++ b/packages/spa-platform/src/orchestrators/HostOrchestrator/Host.ts @@ -1,12 +1,12 @@ import type { HostOrchestrator as HO } from './index.ts'; import { + Token, shortID, type TokenPrimitiveInit, EventEmitter, type Emitter, TokenOrchestrator -} from '@okta/auth-foundation'; -import { Token } from '../../platform/index.ts'; +} from '@okta/auth-foundation/core'; import { OrchestrationBridge } from './OrchestrationBridge.ts'; diff --git a/packages/spa-platform/src/orchestrators/HostOrchestrator/OrchestrationBridge.ts b/packages/spa-platform/src/orchestrators/HostOrchestrator/OrchestrationBridge.ts index 266f72a..ac5543d 100644 --- a/packages/spa-platform/src/orchestrators/HostOrchestrator/OrchestrationBridge.ts +++ b/packages/spa-platform/src/orchestrators/HostOrchestrator/OrchestrationBridge.ts @@ -1,5 +1,5 @@ import type { HostOrchestrator } from './index.ts'; -import { TaskBridge } from '@okta/auth-foundation'; +import { TaskBridge } from '@okta/auth-foundation/core'; import { LocalBroadcastChannel } from '../../utils/LocalBroadcastChannel.ts'; const defaultOptions = { diff --git a/packages/spa-platform/src/orchestrators/HostOrchestrator/SubApp.ts b/packages/spa-platform/src/orchestrators/HostOrchestrator/SubApp.ts index 9a363a3..2cb5cb1 100644 --- a/packages/spa-platform/src/orchestrators/HostOrchestrator/SubApp.ts +++ b/packages/spa-platform/src/orchestrators/HostOrchestrator/SubApp.ts @@ -1,5 +1,6 @@ import type { HostOrchestrator as HO } from './index.ts'; import { + Token, shortID, type SubSet, ignoreUndefineds, @@ -7,9 +8,8 @@ import { TokenOrchestratorError, EventEmitter, hashObject -} from '@okta/auth-foundation'; +} from '@okta/auth-foundation/core'; import { validateString } from '@okta/auth-foundation/internal'; -import { Token } from '../../platform/index.ts'; import { OrchestrationBridge } from './OrchestrationBridge.ts'; diff --git a/packages/spa-platform/src/orchestrators/HostOrchestrator/index.ts b/packages/spa-platform/src/orchestrators/HostOrchestrator/index.ts index a3d445f..3c23f6c 100644 --- a/packages/spa-platform/src/orchestrators/HostOrchestrator/index.ts +++ b/packages/spa-platform/src/orchestrators/HostOrchestrator/index.ts @@ -9,7 +9,7 @@ import { type JsonRecord, type TokenPrimitiveInit, Token -} from '@okta/auth-foundation'; +} from '@okta/auth-foundation/core'; import { HostOrchestrator as HostApp } from './Host.ts'; import { SubAppOrchestrator } from './SubApp.ts'; diff --git a/packages/spa-platform/src/platform/OAuth2Client.ts b/packages/spa-platform/src/platform/OAuth2Client.ts index 5a4a8e8..d9a952b 100644 --- a/packages/spa-platform/src/platform/OAuth2Client.ts +++ b/packages/spa-platform/src/platform/OAuth2Client.ts @@ -4,18 +4,16 @@ */ import { - TokenInit, + Token, + OAuth2Client as OAuth2ClientBase, + type TokenInit, OAuth2ErrorResponse, isOAuth2ErrorResponse, OAuth2Error, - DPoPSigningAuthority, - DPoPNonceCache -} from '@okta/auth-foundation'; -import OAuth2ClientBase from '@okta/auth-foundation/client'; + type DPoPNonceCache +} from '@okta/auth-foundation/core'; import { SynchronizedResult } from '../utils/SynchronizedResult.ts'; -import { Token } from './Token.ts'; import { PersistentCache } from './dpop/index.ts'; -import { DefaultSigningAuthority } from './dpop/authority.ts'; /** @@ -24,13 +22,8 @@ import { DefaultSigningAuthority } from './dpop/authority.ts'; * @group OAuth2Client */ export class OAuth2Client extends OAuth2ClientBase { - public readonly dpopSigningAuthority: DPoPSigningAuthority = DefaultSigningAuthority; protected readonly dpopNonceCache: DPoPNonceCache = new PersistentCache('okta-dpop-nonce'); - protected createToken (init: TokenInit): Token { - return new Token(init); - } - protected prepareRefreshRequest (token: Token, scopes?: string[]): Promise { if (!token.refreshToken) { throw new OAuth2Error(`Missing token: refreshToken`); diff --git a/packages/spa-platform/src/platform/Token.ts b/packages/spa-platform/src/platform/Token.ts deleted file mode 100644 index 5976ae7..0000000 --- a/packages/spa-platform/src/platform/Token.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @module - * @mergeModuleWith Platform - */ - -import { Token as TokenBase, DPoPSigningAuthority } from '@okta/auth-foundation'; -import { DefaultSigningAuthority } from './dpop/authority.ts'; - - -/** - * Browser-specific implementation of {@link AuthFoundation!Token | Token} - * - * @group Token - * @noInheritDoc - */ -export class Token extends TokenBase { - public readonly dpopSigningAuthority: DPoPSigningAuthority = DefaultSigningAuthority; -} - -/** - * @group Token - */ -export namespace Token { - /** - * Re-exports `@okta/auth-foundation` `Token.Metadata` - */ - export type Metadata = TokenBase.Metadata; - /** - * Re-exports `@okta/auth-foundation` `Token.Context` - */ - export type Context = TokenBase.Context; -} diff --git a/packages/spa-platform/src/platform/dpop/authority.ts b/packages/spa-platform/src/platform/dpop/authority.ts index b7bb859..4e4fb84 100644 --- a/packages/spa-platform/src/platform/dpop/authority.ts +++ b/packages/spa-platform/src/platform/dpop/authority.ts @@ -3,7 +3,7 @@ * @internal */ -import { type DPoPStorage, DPoPSigningAuthorityImpl, DPoPSigningAuthority } from '@okta/auth-foundation'; +import { type DPoPStorage, DPoPSigningAuthorityImpl, DPoPSigningAuthority } from '@okta/auth-foundation/core'; import { IndexedDBStore } from '../../utils/IndexedDBStore.ts'; diff --git a/packages/spa-platform/src/platform/dpop/nonceCache.ts b/packages/spa-platform/src/platform/dpop/nonceCache.ts index 1fceeff..672725c 100644 --- a/packages/spa-platform/src/platform/dpop/nonceCache.ts +++ b/packages/spa-platform/src/platform/dpop/nonceCache.ts @@ -3,12 +3,9 @@ * @internal */ -import { DPoPNonceCache } from '@okta/auth-foundation'; +import type { DPoPNonceCache } from '@okta/auth-foundation/core'; import { LocalStorageCache } from '../../utils/LocalStorageCache.ts'; -/** @internal */ -const _20_HOURS = 60 * 60 * 20; - /** * @internal @@ -18,7 +15,7 @@ export class PersistentCache implements DPoPNonceCache { readonly #cache: LocalStorageCache; constructor (storageKey: string, clearOnParseError: boolean = true) { - this.#cache = new LocalStorageCache(storageKey, _20_HOURS, clearOnParseError); + this.#cache = new LocalStorageCache(storageKey, clearOnParseError); } public async getNonce (key: string): Promise { diff --git a/packages/spa-platform/src/platform/index.ts b/packages/spa-platform/src/platform/index.ts index 940510d..ef5fec4 100644 --- a/packages/spa-platform/src/platform/index.ts +++ b/packages/spa-platform/src/platform/index.ts @@ -2,7 +2,6 @@ * @module Platform */ -export * from './Token.ts'; export * from './OAuth2Client.ts'; import { DefaultSigningAuthority } from './dpop/authority.ts'; diff --git a/packages/spa-platform/src/utils/LocalBroadcastChannel.ts b/packages/spa-platform/src/utils/LocalBroadcastChannel.ts index e66fd87..0a87eea 100644 --- a/packages/spa-platform/src/utils/LocalBroadcastChannel.ts +++ b/packages/spa-platform/src/utils/LocalBroadcastChannel.ts @@ -3,7 +3,7 @@ * @internal */ -import type { JsonRecord, BroadcastChannelLike } from '@okta/auth-foundation'; +import type { JsonRecord, BroadcastChannelLike } from '@okta/auth-foundation/core'; import { validateURL } from '@okta/auth-foundation/internal'; export type LocalBroadcastChannelMessage = { diff --git a/packages/spa-platform/src/utils/LocalStorageCache.ts b/packages/spa-platform/src/utils/LocalStorageCache.ts index 2db2347..ee20ec2 100644 --- a/packages/spa-platform/src/utils/LocalStorageCache.ts +++ b/packages/spa-platform/src/utils/LocalStorageCache.ts @@ -3,10 +3,12 @@ * @internal */ -import { Timestamp, type Json, type JsonPrimitive } from '@okta/auth-foundation'; - -/** @internal */ -const _20_HOURS = 60 * 60 * 20; +import { + Timestamp, + type Json, + type JsonPrimitive, + type Seconds +} from '@okta/auth-foundation/core'; /** @@ -17,10 +19,11 @@ const _20_HOURS = 60 * 60 * 20; export class LocalStorageCache { constructor ( protected storageKey: string, - protected expirationDuration: number = _20_HOURS, public clearOnParseError: boolean = true ) {} + static cacheDuration: Seconds = 60 * 60 * 20; // defaults to 20 hours + protected getStore (): Record { let store: Record = {}; try { @@ -40,7 +43,7 @@ export class LocalStorageCache { for (const [key, value] of Object.entries(store)) { // cast as `any` because .entries assumes type is `unknown` const ts = (value as any).ts; - if (!ts || Math.abs(Timestamp.from(ts).timeSinceNow()) > _20_HOURS) { + if (!ts || Math.abs(Timestamp.from(ts).timeSinceNow()) > LocalStorageCache.cacheDuration) { delete store[key]; } } diff --git a/packages/spa-platform/test/helpers/makeTestResource.ts b/packages/spa-platform/test/helpers/makeTestResource.ts index 2c86a9e..f263c3d 100644 --- a/packages/spa-platform/test/helpers/makeTestResource.ts +++ b/packages/spa-platform/test/helpers/makeTestResource.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { Token, OAuth2Client } from 'src/platform'; +import { Token } from '@okta/auth-foundation/core'; +import { OAuth2Client } from 'src/platform'; import { Credential } from 'src/Credential'; import { mockIDToken, mockTokenResponse } from '@repo/jest-helpers/browser/helpers'; diff --git a/packages/spa-platform/test/spec/BrowserTokenStorage.spec.ts b/packages/spa-platform/test/spec/BrowserTokenStorage.spec.ts index 27947c2..d97f667 100644 --- a/packages/spa-platform/test/spec/BrowserTokenStorage.spec.ts +++ b/packages/spa-platform/test/spec/BrowserTokenStorage.spec.ts @@ -1,5 +1,4 @@ -import { CredentialError } from '@okta/auth-foundation'; -import { Token } from 'src/platform'; +import { Token, CredentialError } from '@okta/auth-foundation'; import { BrowserTokenStorage } from 'src/Credential/TokenStorage'; import { makeTestToken, MockIndexedDBStore } from '../helpers/makeTestResource'; diff --git a/packages/spa-platform/test/spec/flows/AuthorizationCodeFlow.spec.ts b/packages/spa-platform/test/spec/flows/AuthorizationCodeFlow.spec.ts index 41e2669..f6745c5 100644 --- a/packages/spa-platform/test/spec/flows/AuthorizationCodeFlow.spec.ts +++ b/packages/spa-platform/test/spec/flows/AuthorizationCodeFlow.spec.ts @@ -13,8 +13,7 @@ jest.mock('@okta/auth-foundation', () => { }; }); -import { OAuth2Error } from '@okta/auth-foundation'; -import { Token } from 'src/platform'; +import { Token, OAuth2Error } from '@okta/auth-foundation'; import { AuthorizationCodeFlow as Base, AuthenticationFlowError } from '@okta/oauth2-flows'; import { AuthorizationCodeFlow } from 'src/flows'; import { oauthClient, makeTestToken } from '../../helpers/makeTestResource'; diff --git a/packages/spa-platform/test/spec/orchestrators/AuthorizationCodeFlowOrchestrator.spec.ts b/packages/spa-platform/test/spec/orchestrators/AuthorizationCodeFlowOrchestrator.spec.ts index 795df29..b307c92 100644 --- a/packages/spa-platform/test/spec/orchestrators/AuthorizationCodeFlowOrchestrator.spec.ts +++ b/packages/spa-platform/test/spec/orchestrators/AuthorizationCodeFlowOrchestrator.spec.ts @@ -1,5 +1,4 @@ -import { TokenOrchestratorError, OAuth2Error } from '@okta/auth-foundation'; -import { Token } from 'src/platform'; +import { Token, TokenOrchestratorError, OAuth2Error } from '@okta/auth-foundation'; import { Credential } from 'src/Credential'; import { AuthorizationCodeFlow } from 'src/flows/AuthorizationCodeFlow'; import { AuthorizationCodeFlowOrchestrator } from 'src/orchestrators'; diff --git a/packages/spa-platform/test/spec/orchestrators/HostOrchestrator.spec.ts b/packages/spa-platform/test/spec/orchestrators/HostOrchestrator.spec.ts index ee8691a..ba1eeb1 100644 --- a/packages/spa-platform/test/spec/orchestrators/HostOrchestrator.spec.ts +++ b/packages/spa-platform/test/spec/orchestrators/HostOrchestrator.spec.ts @@ -1,6 +1,5 @@ -import { TokenOrchestrator, TokenOrchestratorError } from '@okta/auth-foundation'; +import { Token, TokenOrchestrator, TokenOrchestratorError } from '@okta/auth-foundation'; import { mockTokenResponse } from '@repo/jest-helpers/browser/helpers'; -import { Token } from 'src/platform'; import { HostOrchestrator } from 'src/orchestrators/HostOrchestrator/index'; import { LocalBroadcastChannel } from 'src/utils/LocalBroadcastChannel'; diff --git a/packages/spa-platform/test/spec/platform/Token.spec.ts b/packages/spa-platform/test/spec/platform/Token.spec.ts deleted file mode 100644 index 89ae42c..0000000 --- a/packages/spa-platform/test/spec/platform/Token.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Token } from 'src/platform'; -import { makeRawTestToken } from '../../helpers/makeTestResource'; - - -// TODO: write some more tests -describe('Token', () => { - - it('can construct', () => { - const t1 = new Token(makeRawTestToken()); - expect(t1).toBeInstanceOf(Token); - }); -}); diff --git a/tooling/eslint-config/sdk.js b/tooling/eslint-config/sdk.js index 2d426de..990c136 100644 --- a/tooling/eslint-config/sdk.js +++ b/tooling/eslint-config/sdk.js @@ -56,6 +56,7 @@ module.exports = { "max-len": 0, "max-statements": 0, "camelcase": 0, + "no-restricted-imports": 0, "@typescript-eslint/ban-ts-comment": 0, "@typescript-eslint/no-non-null-assertion": 0 } @@ -113,6 +114,18 @@ module.exports = { "ignoreRestSiblings": true, "caughtErrors": "none" }], - "@typescript-eslint/no-namespace": 0 + "@typescript-eslint/no-namespace": 0, + // NOTE: important linting rule + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@okta/auth-foundation', + message: 'Import from "@okta/auth-foundation/core" instead to avoid bundling default platform implementations.', + }, + ], + }, + ], } }