Skip to content

Commit

Permalink
chore(test): init unit tests for oidc client
Browse files Browse the repository at this point in the history
  • Loading branch information
wermanoid committed Aug 7, 2024
1 parent 0394a6f commit 881ac57
Show file tree
Hide file tree
Showing 6 changed files with 311 additions and 33 deletions.
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

0 comments on commit 881ac57

Please sign in to comment.