Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
version: 2.1

# Placeholder pipeline: this branch predates the real CircleCI config being added on another,
# not-yet-merged branch. This just gives CircleCI a valid config to parse so the pipeline
# succeeds instead of failing on a missing/empty config.yml. Replace once merged with the
# branch that introduces the real pipeline definition.
jobs:
noop:
docker:
- image: cimg/base:current
steps:
- run: echo "No-op CI config - real pipeline will be introduced when merged from its source branch."

workflows:
noop-workflow:
jobs:
- noop
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to this project will be documented in this file.

## [0.8.0] - 2026-09-02

### `@okta/auth-foundation`

#### Added
- Added `dispose()` to `Credential`, `OAuth2Client`, and `APIClient` to release listeners and cached resources when a credential is removed ([#39](https://github.com/okta/okta-client-javascript/pull/39))
- Added an optional `{ signal: AbortSignal }` option to `EventEmitter.on()`, and a `clear()` method, for automatic listener cleanup ([#39](https://github.com/okta/okta-client-javascript/pull/39))

#### Fixed
- Fixed a memory leak where every constructed `Credential` added a listener to the shared `CredentialCoordinator` emitter that was never removed, retaining every `Credential` (and its `OAuth2Client`) for the lifetime of the page ([#39](https://github.com/okta/okta-client-javascript/pull/39))
- `DefaultCredentialDataSource.remove()`/`.clear()` now dispose removed credentials instead of only removing them from the internal cache ([#39](https://github.com/okta/okta-client-javascript/pull/39))

### `@okta/spa-platform`

#### Fixed
- Cross-tab credential sync no longer broadcasts full token payloads over `BroadcastChannel`; tabs now read the current value from storage, and only when they already reference the credential in question, reducing memory pressure across many open tabs ([#39](https://github.com/okta/okta-client-javascript/pull/39))

## [0.7.2] - 2026-04-09

### `@okta/spa-platform`
Expand Down
11 changes: 9 additions & 2 deletions e2e/apps/redirect-model/src/component/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,19 @@ export function Landing () {
setCredentialIds(allIDs);
};

const removeHandler = async ({ id }) => {
if (credential?.id === id) {
setCredential(null);
}
await updateHandler();
}

const defaultHandler = ({ id }) => {
setDefault(id);
};

Credential.on('credential_added', updateHandler);
Credential.on('credential_removed', updateHandler);
Credential.on('credential_removed', removeHandler);
Credential.on('cleared', updateHandler);
Credential.on('default_changed', defaultHandler);

Expand All @@ -45,7 +52,7 @@ export function Landing () {
Credential.off('cleared', updateHandler);
Credential.off('default_changed', defaultHandler);
};
}, [setCredentialIds, setCredential, setDefault]);
}, [credential, setCredentialIds, setCredential, setDefault]);

const clear = async () => {
await Credential.clear();
Expand Down
10 changes: 4 additions & 6 deletions e2e/apps/redirect-model/src/component/Token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,14 @@ export function Token ({ credential }: { credential: Credential }) {
}
Credential.on('tags_updated', tagsHandler);

setToken(credential.token);
setTags(credential.tags);

return () => {
Credential.off('credential_refreshed', handler);
Credential.off('tags_updated', tagsHandler);
};
}, [setToken]);

useEffect(() => {
setToken(credential.token);
setTags(credential.tags);
}, [credential]);
}, [credential, setToken, setTags]);

const remove = async () => {
await credential.remove();
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@okta/okta-client-js",
"version": "0.7.2",
"version": "0.8.0",
"private": true,
"packageManager": "yarn@1.22.19",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-foundation/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@okta/auth-foundation",
"version": "0.7.2",
"version": "0.8.0",
"type": "module",
"main": "dist/esm/index.js",
"module": "dist/esm/index.js",
Expand Down
49 changes: 30 additions & 19 deletions packages/auth-foundation/src/Credential/Credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,8 @@ import { CredentialError, OAuth2Error } from '../errors/index.ts';


type CredentialEvents = {
'credential_added': { credential: Credential };
'credential_removed': { id: string };
'tags_updated': { id: string, tags: string[] };
} & Omit<CredentialCoordinatorEvents, 'credential_added' | 'credential_removed'>;
} & CredentialCoordinatorEvents;

/**
* Wrapper around a {@link Token.Token | Token}, providing methods to interact with Tokens without the hassle of managing them
Expand All @@ -49,19 +47,22 @@ export class Credential implements RequestAuthorizer, JSONSerializable {

// unbinds listeners of previous coordinator
( [
'credential_added', 'credential_removed', 'credential_refreshed', 'default_changed', 'cleared'
'credential_added',
'credential_removed',
'credential_refreshed',
'default_changed',
'cleared',
'metadata_updated'
] satisfies (keyof CredentialCoordinatorEvents)[]
).forEach((evt) => previousCoordinator.emitter.off(evt));

// binds listeners (and event relays) from coordinator to Credential.emitter
this.emitter.relay(this.coordinator.emitter, ['cleared', 'default_changed', 'credential_refreshed']);
this.emitter.relay(this.coordinator.emitter, [
'credential_added', 'credential_removed', 'cleared', 'default_changed', 'credential_refreshed'
]);

this.coordinator.emitter.on('credential_added', ({ credential }) => {
this.emitter.emit('credential_added', { credential });
});

this.coordinator.emitter.on('credential_removed', ({ id }) => {
this.emitter.emit('credential_removed', { id });
this.coordinator.emitter.on('metadata_updated', async ({ id, metadata }) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The tags_updated trigger moved from observeToken

this.emitter.emit('tags_updated', { id, tags: metadata?.tags ?? [] });
});
}

Expand All @@ -87,6 +88,9 @@ export class Credential implements RequestAuthorizer, JSONSerializable {
/** @internal */
protected _userInfo: UserInfo | undefined;

/** @internal */
#controller = new AbortController();

/**
* @remarks
* Do not use directly, use {@link store | Credential.store} instead
Expand Down Expand Up @@ -321,6 +325,20 @@ export class Credential implements RequestAuthorizer, JSONSerializable {

/////// public instances methods ///////

/**
* Cleans up resources associated with the Credential instance, so that it may be garbage collected.
*
* @remarks
* This method is meant to be used in conjunction with {@link CredentialDataSource.remove}. Calling this
* method on an active {@link Credential} may have significant consequences
*
* @internal
*/
public dispose () {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a method to clean resources used by Credential instances. Since the tags_updated listener was moved to static space, the only instance-level listener is token_did_refresh of the OAuth2 instance with Credential. oauth2.dispose clears that listener.

Also added an AbortController instance of each Credential for future proofing of clearing resources

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The pattern of clearing this.oauth2.emitter listeners also solves this nested listener which contributed to the memory leak

https://github.com/okta/okta-client-javascript/blob/master/packages/auth-foundation/src/Credential/CredentialCoordinator.ts#L128

this.oauth2.dispose();
this.#controller.abort('dispose');
}

/**
* Updates tags associated with {@link Credential}
*
Expand Down Expand Up @@ -383,14 +401,7 @@ export class Credential implements RequestAuthorizer, JSONSerializable {
this.oauth2.emitter.on('token_did_refresh', ({ token }) => {
if (Token.isEqual(token, this.token)) { return; }
this.token = token;
});

// bind listener to Derived class instance
this.coordinator.emitter.on('metadata_updated', async ({ id, metadata }) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This listener bound on every Credential instance, but filter to only fire on the specific Credential. It's incorrectly space in method space. Moved this logic to static section where other listeners are bound

if (this.id === id) {
Credential.emitter.emit('tags_updated', { id, tags: metadata?.tags ?? [] });
}
});
}, { signal: this.#controller.signal });
}

// oauth2 methods
Expand Down
10 changes: 6 additions & 4 deletions packages/auth-foundation/src/Credential/CredentialCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ function log (...args: any[]) {}


export type CredentialCoordinatorEvents = {
'credential_added': { credential?: Credential, id: string };
'credential_removed': { id: string }
'credential_expired': { credential: Credential };
'credential_refreshed': { credential: Credential };
'cleared': void;
}
& Pick<TokenStorageEvents, 'default_changed' | 'metadata_updated' | 'token_replaced'>
& CredentialDataSourceEvents;
& Pick<TokenStorageEvents, 'default_changed' | 'metadata_updated' | 'token_replaced'>;

/**
* @public @interface
Expand Down Expand Up @@ -138,13 +139,14 @@ export class CredentialCoordinatorImpl implements CredentialCoordinator {
console.error('Failed to replace token after refresh');
}
});

this.emitter.emit('credential_added', { credential, id: credential.id });
});

this.credentialDataSource.emitter.on('credential_removed', ({ id }) => {
this.clearExpireEventTimeout(id);
this.emitter.emit('credential_removed', { id });
});

this.emitter.relay(this.credentialDataSource.emitter, ['credential_added', 'credential_removed']);
}

public get tokenStorage (): TokenStorage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface CredentialDataSource {
* represents the provided {@link Token.Token | Token}.
*/
hasCredential (token: Token): boolean;
hasCredential (id: string): boolean;
/**
* Checks {@link CredentialDataSource} for an existing {@link Credential} instance which
* represents the provided {@link Token.Token | Token}. If one does not exist, a new {@link Credential}
Expand Down Expand Up @@ -80,8 +81,8 @@ export class DefaultCredentialDataSource implements CredentialDataSource {
return new this.CredentialConstructor(token, client, metadata);
}

public hasCredential (token: Token): boolean {
return this.credentials.has(token.id);
public hasCredential (key: string | Token): boolean {
return this.credentials.has(typeof key === 'string' ? key : key.id);
}

public credentialFor (token: Token, metadata?: Token.Metadata): Credential {
Expand All @@ -101,12 +102,14 @@ export class DefaultCredentialDataSource implements CredentialDataSource {
const id = typeof cred === 'string' ? cred : cred.id;
if (this.credentials.has(id)) {
const cred = this.credentials.get(id)!;
cred.dispose();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

calls new .dipose method on Credentials during .remove()

this.credentials.delete(id);
this.emitter.emit('credential_removed', { dataSource: this, id: cred.id });
}
}

public clear () {
this.credentials.forEach(cred => cred.dispose());
this.credentials.clear();
}

Expand Down
13 changes: 13 additions & 0 deletions packages/auth-foundation/src/http/APIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ export abstract class APIClient<E extends APIClient.Events = APIClient.Events> {
await this.dpopNonceCache.cacheNonce(this.getDPoPNonceCacheKey(request), nonce);
}

/**
* Cleans up resources associated with the client instance, so that it may be garbage collected.
*
* > [!Warning]
* > **DO NOT** use this method on active clients.
*
* @internal
*/
public dispose () {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Used within Credential via OAuth2.dispose. Clears active listeners on the EventEmitter to prevent similar leaks

this.emitter.clear();
this.interceptors.splice(0, this.interceptors.length); // clears array in place
}

/**
* Registers an {@link APIClient.RequestInterceptor} on the {@link APIClient}
*
Expand Down
8 changes: 8 additions & 0 deletions packages/auth-foundation/src/oauth2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ export class OAuth2Client<E extends OAuth2Client.Events = OAuth2Client.Events> e
return json;
}

/**
* Cleans up resourece associated with the client instance to prevent leaks.
*/
public dispose () {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Used within Credential.dispose. Clears active listeners on this.emitter via super.dispose to prevent similar leaks. Also clears the #httpCache

super.dispose();
this.#httpCache.clear();
}

/**
* Retrieves the Authorization Server's OpenID configuration
*/
Expand Down
48 changes: 46 additions & 2 deletions packages/auth-foundation/src/utils/EventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ type EventMap = {
};
type EventListener<T> = T extends void ? () => void : (event: T) => void;

type EventListenerOptions = { signal: AbortSignal }

/**
* @group EventEmitter
*/
Expand All @@ -21,13 +23,39 @@ export interface Emitter<E extends EventMap> {
*/
export class EventEmitter<Events extends EventMap> {
listeners: { [K in keyof Events]?: Array<EventListener<Events[K]>> } = {};
// scoped per-event, since the same `handler` function reference may be registered against
// multiple events (or reused across `on()` calls) with different signals attached
signals: Map<keyof Events, WeakMap<(...arg: any[]) => void, { signal: AbortSignal, abortHandler: () => void }>> = new Map();

on<K extends keyof Events>(
eventName: K,
handler: EventListener<Events[K]>,
options: Partial<EventListenerOptions> = {}
): this {
const { signal } = options;

if (signal?.aborted) {
// if the provided `AbortSignal` has already been aborted, do not bind listener
return this;
}

on<K extends keyof Events>(eventName: K, handler: EventListener<Events[K]>): this {
if (!this.listeners[eventName]) {
this.listeners[eventName] = [];
}
this.listeners[eventName]!.push(handler);

if (signal) {
const abortHandler = () => {
this.off(eventName, handler);
};
signal.addEventListener('abort', abortHandler, { once: true });

if (!this.signals.has(eventName)) {
this.signals.set(eventName, new WeakMap());
}
this.signals.get(eventName)!.set(handler, { signal, abortHandler });
}

return this;
}

Expand All @@ -37,14 +65,25 @@ export class EventEmitter<Events extends EventMap> {
}

if (!handler) {
this.listeners[eventName]?.forEach(h => this.detachSignal(eventName, h));
delete this.listeners[eventName];
return this;
}

this.detachSignal(eventName, handler);
this.listeners[eventName] = this.listeners[eventName]?.filter(l => l !== handler);
return this;
}

/** @internal removes the `AbortSignal` registration (if any) associated with `handler` for `eventName` */
protected detachSignal<K extends keyof Events> (eventName: K, handler: EventListener<any>): void {
const entry = this.signals.get(eventName)?.get(handler);
if (entry) {
entry.signal.removeEventListener('abort', entry.abortHandler);
this.signals.get(eventName)!.delete(handler);
}
}

emit<K extends keyof Events>(eventName: K, data: Events[K]): void;
emit<K extends keyof Events>(eventName: K): void;
emit<K extends keyof Events>(eventName: K, data?: Events[K]): void {
Expand Down Expand Up @@ -82,4 +121,9 @@ export class EventEmitter<Events extends EventMap> {
emitter.on(event, handler);
}
}
}

clear (): this {
(Object.keys(this.listeners) as (keyof Events)[]).forEach(eventName => this.off(eventName));
return this;
}
}
Loading