Skip to content

chore(test): init unit tests for oidc client #1425

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
179 changes: 179 additions & 0 deletions packages/oidc-client/src/__tests__/oidc.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { afterEach, describe, expect, it, Mock, vi } from 'vitest';

import * as initWorker from '../initWorker';
import { Oidc, OidcAuthorizationServiceConfiguration } from '../oidc';
import { AuthorityConfiguration, OidcConfiguration, TokenAutomaticRenewMode } from '../types';

vi.mock('../initWorker');

describe.only('OIDC service', () => {
const authorityConfigurationMock: AuthorityConfiguration = {
issuer: 'test_issuer',
authorization_endpoint: 'test_authorization_endpoint',
token_endpoint: 'test_token_endpoint',
revocation_endpoint: 'test_revocation_endpoint',
end_session_endpoint: 'test_end_session_endpoint', // optional
userinfo_endpoint: 'test_userinfo_endpoint', // optional
check_session_iframe: 'test_check_session_iframe', // optional
};

const oidcConfigMock: OidcConfiguration = {
client_id: 'test_client_id',
redirect_uri: 'test_redirect_uri',
silent_redirect_uri: 'test_silent_redirect_uri', // optional
silent_login_uri: 'test_silent_login_uri', // optional
silent_login_timeout: 1000, // optional
scope: 'openid tenant_id email profile offline_access',
authority: 'test_authority',
authority_time_cache_wellknowurl_in_second: 1000, // optional
authority_timeout_wellknowurl_in_millisecond: 1000, // optional
authority_configuration: undefined, // optional
refresh_time_before_tokens_expiration_in_second: 1000, // optional
token_automatic_renew_mode: TokenAutomaticRenewMode.AutomaticBeforeTokenExpiration, // optional
token_request_timeout: 1000, // optional
service_worker_relative_url: 'test_service_worker_relative_url', // optional
service_worker_register: vi.fn().mockResolvedValue({} as ServiceWorkerRegistration), // optional
service_worker_keep_alive_path: 'test_service_worker_keep_alive_path', // optional
service_worker_activate: () => true, // optional
service_worker_only: true, // optional
service_worker_convert_all_requests_to_cors: true, // optional
service_worker_update_require_callback: vi.fn().mockResolvedValue(void 0), // optional
extras: {}, // optional
token_request_extras: {}, // optional
// storage?: Storage;
monitor_session: true, // optional
token_renew_mode: 'test_token_renew_mode', // optional
logout_tokens_to_invalidate: ['access_token', 'refresh_token'], // optional
// demonstrating_proof_of_possession: false, // optional
// demonstrating_proof_of_possession_configuration?: DemonstratingProofOfPossessionConfiguration;
preload_user_info: false, // optional
};

const oidcConfigMockWithAuthorityConfiguration: OidcConfiguration = {
...oidcConfigMock,
authority_configuration: authorityConfigurationMock,
};

const fetchMock = vi.fn();

const createStorageMock = (): Storage => {
const storage = {
getItem(key: string) {
const value = this[key];
return typeof value === 'undefined' ? null : value;
},
setItem(key: string, value: unknown) {
this[key] = value;
this.length = Object.keys(this).length - 6; // kind'a ignore mock methods and props
},
removeItem: function (key: string) {
return delete this[key];
},
length: 0,
key: () => {
return null;
},
clear() {
window.localStorage = window.sessionStorage = createStorageMock();
},
};

return storage;
};

window.localStorage = window.sessionStorage = createStorageMock();

afterEach(() => {
vi.clearAllMocks();

window.localStorage.clear();
});

describe('init flow', () => {
it('should create new oidc instance', async () => {
const sut = new Oidc(
oidcConfigMockWithAuthorityConfiguration,
'test_oidc_client_id',
() => fetchMock,
);

expect(sut).toBeDefined();
});

it('should init oidc instance with predefined authority_configuration', async () => {
const sut = new Oidc(
oidcConfigMockWithAuthorityConfiguration,
'test_oidc_client_id',
() => fetchMock,
);

expect(sut.initPromise).toBeDefined();

const result = await sut.initPromise;

expect(sut.initPromise).toBeNull();

expect(result).toEqual(new OidcAuthorizationServiceConfiguration(authorityConfigurationMock));
});

it('should init oidc instance with fetched authority_configuration and enabled service worker', async () => {
fetchMock.mockResolvedValue({
status: 200,
json: vi.fn().mockResolvedValue(authorityConfigurationMock),
});

// we don't care about the return value of initWorker.initWorkerAsync
// as it is used only as boolean flag to set storage to local storage or not
(initWorker.initWorkerAsync as Mock<any, any>).mockResolvedValue({});

const sut = new Oidc(oidcConfigMock, 'test_oidc_client_id', () => fetchMock);

expect(sut.initPromise).toBeDefined();

const result = await sut.initPromise;

expect(result).toEqual(new OidcAuthorizationServiceConfiguration(authorityConfigurationMock));

expect(sut.initPromise).toBeNull();

// oh this side effects... can we avoid them and make it better?
const localCache = JSON.parse(
window.localStorage.getItem(`oidc.server:${oidcConfigMock.authority}`),
).result;

expect(localCache).toEqual(authorityConfigurationMock);
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith(
'test_authority/.well-known/openid-configuration',
expect.anything(),
);
});

// TODO: cache.ts has second level side-effect, so this test is impacted by previous one
// as it is not possible to refresh/clear that cache at current moment of time
it.skip('should take authority_configuration from local storage on subsequent initAsync calls', async () => {
fetchMock.mockResolvedValue({
status: 200,
json: vi.fn().mockResolvedValue(authorityConfigurationMock),
});

// we don't care about the return value of initWorker.initWorkerAsync
// as it is used only as boolean flag to set storage to local storage or not
(initWorker.initWorkerAsync as Mock<any, any>).mockResolvedValue({});

const sut = new Oidc(oidcConfigMock, 'test_oidc_client_id', () => fetchMock);

await sut.initPromise;

// internal cache.ts makes some wildest magic,
// so any subsequential call could obtain the authority_configuration from internal cache or null
// no other options. Sounds like a bug. What's a point of localStorage/sessionStorage cache then?
expect(fetchMock).toHaveBeenCalledOnce();

// const secondCallResult = await sut.initAsync(oidcConfigMock.authority, null);

// expect(fetchMock).toHaveBeenCalledOnce();
// expect(secondCallResult).toEqual(new OidcAuthorizationServiceConfiguration(authorityConfigurationMock));
});
});
});
108 changes: 91 additions & 17 deletions packages/oidc-client/src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,103 @@
const fetchFromIssuerCache = {};

export const getFromCache = (localStorageKey, storage = window.sessionStorage, timeCacheSecond) => {
if (!fetchFromIssuerCache[localStorageKey]) {
if (storage) {
const cacheJson = storage.getItem(localStorageKey);
if (cacheJson) {
fetchFromIssuerCache[localStorageKey] = JSON.parse(cacheJson);
}
}
const fetchFromIssuerCache: Record<string, InternalCacheItem<any>> = {};

type InternalCacheItem<T> = {
result: T;
timestamp: number;
};

const getResultOrNullIfExpired = <T extends object>(
cachedItem: InternalCacheItem<T> | undefined,
timeCacheSecond: number,
): T | null => {
if (!cachedItem) {
return null;
}

const oneHourMinisecond = 1000 * timeCacheSecond;
// @ts-ignore
if (
fetchFromIssuerCache[localStorageKey] &&
fetchFromIssuerCache[localStorageKey].timestamp + oneHourMinisecond > Date.now()
) {
return fetchFromIssuerCache[localStorageKey].result;
if (cachedItem.timestamp + oneHourMinisecond > Date.now()) {
return cachedItem.result as T;
}

return null;
};

export const setCache = (localStorageKey, result, storage = window.sessionStorage) => {
export const getFromCache = <T extends object>(
localStorageKey: string,
storage: Storage = window.sessionStorage,
timeCacheSecond: number,
): T => {
const fromStorage =
storage &&
storage.getItem(localStorageKey) &&
(JSON.parse(storage.getItem(localStorageKey)) as InternalCacheItem<T> | undefined);

const fromLocalStorage = fetchFromIssuerCache[localStorageKey];

return (
getResultOrNullIfExpired<T>(fromStorage, timeCacheSecond) ||
getResultOrNullIfExpired<T>(fromLocalStorage, timeCacheSecond) ||
null
);
};

export const setCache = <T extends object>(
localStorageKey: string,
result: T,
storage: Storage = window.sessionStorage,
): void => {
const timestamp = Date.now();
fetchFromIssuerCache[localStorageKey] = { result, timestamp };

if (storage) {
storage.setItem(localStorageKey, JSON.stringify({ result, timestamp }));
}
};

export const clearCache = (
localStorageKey?: string,
storage: Storage = window.sessionStorage,
): void => {
if (!localStorageKey) {
for (const key in fetchFromIssuerCache) {
storage.removeItem(key);
delete fetchFromIssuerCache[localStorageKey];
}
}
delete fetchFromIssuerCache[localStorageKey];
storage.removeItem(localStorageKey);
};

// // TODO: refactor this function to be less side-effecty
// // getFromCache has a secrec internal side-effect, which keeps fetchFromIssuer inside internal object
// // which leads to case when cache is never retrieved from storage, but just returned from internal object
// // and even more, if object is expired, but exists in internal object, function will return symple null for ever.
// // only way to get actual data - setCache with same key to override timestamp
// export const getFromCache = (localStorageKey, storage = window.sessionStorage, timeCacheSecond) => {
// if (!fetchFromIssuerCache[localStorageKey]) {
// if (storage) {
// const cacheJson = storage.getItem(localStorageKey);
// if (cacheJson) {
// fetchFromIssuerCache[localStorageKey] = JSON.parse(cacheJson);
// }
// }
// }
// const oneHourMinisecond = 1000 * timeCacheSecond;
// // @ts-ignore
// if (
// fetchFromIssuerCache[localStorageKey] &&
// fetchFromIssuerCache[localStorageKey].timestamp + oneHourMinisecond > Date.now()
// ) {
// return fetchFromIssuerCache[localStorageKey].result;
// }
// return null;
// };

// // what is the point of setting value into storage if it is never accessed later in getFromCache?
// // fetchFromIssuerCache existence prevents access to chached data in storage
// export const setCache = (localStorageKey, result, storage = window.sessionStorage) => {
// const timestamp = Date.now();
// fetchFromIssuerCache[localStorageKey] = { result, timestamp };
// if (storage) {
// storage.setItem(localStorageKey, JSON.stringify({ result, timestamp }));
// }
// };
3 changes: 2 additions & 1 deletion packages/oidc-client/src/initWorker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ILOidcLocation } from './location';
import { OidcAuthorizationServiceConfiguration } from './oidc';
import { parseOriginalTokens } from './parseTokens.js';
import timer from './timer.js';
import { OidcConfiguration } from './types.js';
Expand Down Expand Up @@ -122,7 +123,7 @@ export const initWorkerAsync = async (configuration, configurationName) => {
return sendMessageAsync(registration)({ type: 'clear', data: { status }, configurationName });
};
const initAsync = async (
oidcServerConfiguration,
oidcServerConfiguration: OidcAuthorizationServiceConfiguration,
where,
oidcConfiguration: OidcConfiguration,
) => {
Expand Down
37 changes: 25 additions & 12 deletions packages/oidc-client/src/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CheckSessionIFrame } from './checkSessionIFrame.js';
import { base64urlOfHashOfASCIIEncodingAsync } from './crypto';
import { eventNames } from './events.js';
import { initSession } from './initSession.js';
import { defaultServiceWorkerUpdateRequireCallback, initWorkerAsync } from './initWorker.js';
import { defaultServiceWorkerUpdateRequireCallback, initWorkerAsync } from './initWorker';
import { activateServiceWorker } from './initWorkerOption';
import {
defaultDemonstratingProofOfPossessionConfiguration,
Expand Down Expand Up @@ -37,16 +37,26 @@ export interface OidcAuthorizationServiceConfigurationJson {
issuer: string;
}

export type OidcAuthorizationServiceConfigurationResponse = {
authorization_endpoint: string;
end_session_endpoint: string;
revocation_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
check_session_iframe: string;
issuer: string;
};

export class OidcAuthorizationServiceConfiguration {
private checkSessionIframe: string;
private issuer: string;
private authorizationEndpoint: string;
private tokenEndpoint: string;
private revocationEndpoint: string;
private userInfoEndpoint: string;
private endSessionEndpoint: string;

constructor(request: any) {
public checkSessionIframe: string;
public issuer: string;
public authorizationEndpoint: string;
public tokenEndpoint: string;
public revocationEndpoint: string;
public userInfoEndpoint: string;
public endSessionEndpoint: string;

constructor(request: OidcAuthorizationServiceConfigurationResponse) {
this.authorizationEndpoint = request.authorization_endpoint;
this.tokenEndpoint = request.token_endpoint;
this.revocationEndpoint = request.revocation_endpoint;
Expand Down Expand Up @@ -234,8 +244,11 @@ Please checkout that you are using OIDC hook inside a <OidcProvider configuratio
}
}

initPromise = null;
async initAsync(authority: string, authorityConfiguration: AuthorityConfiguration) {
initPromise: null | Promise<OidcAuthorizationServiceConfiguration> = null;
async initAsync(
authority: string,
authorityConfiguration?: AuthorityConfiguration,
): Promise<OidcAuthorizationServiceConfiguration> {
if (this.initPromise !== null) {
return this.initPromise;
}
Expand Down
Loading
Loading