diff --git a/packages/client/jest.config.ts b/packages/client/jest.config.ts new file mode 100644 index 000000000..d19e81125 --- /dev/null +++ b/packages/client/jest.config.ts @@ -0,0 +1,10 @@ +import type { Config } from '@jest/types'; + +const config: Config.InitialOptions = { + preset: 'ts-jest', + collectCoverageFrom: ['src/**/*.ts'], + coverageReporters: ['lcov', 'text-summary'], + setupFilesAfterEnv: ['/jest.setup.js', 'jest-matcher-specific-error'], +}; + +export default config; diff --git a/packages/client/jest.setup.js b/packages/client/jest.setup.js new file mode 100644 index 000000000..ac31f5c5e --- /dev/null +++ b/packages/client/jest.setup.js @@ -0,0 +1,15 @@ +// Need to disable following rules to mock text-decode/text-encoder and crypto for jsdom +// https://github.com/jsdom/jsdom/issues/1612 +/* eslint-disable unicorn/prefer-module */ +// TODO: crypto related to linear issue LOG-1517 +const { Crypto } = require('@peculiar/webcrypto'); +const fetch = require('node-fetch'); +const { TextDecoder, TextEncoder } = require('text-encoder'); +/* eslint-enable unicorn/prefer-module */ + +/* eslint-disable @silverhand/fp/no-mutation */ +global.crypto = new Crypto(); +global.fetch = fetch; +global.TextDecoder = TextDecoder; +global.TextEncoder = TextEncoder; +/* eslint-enable @silverhand/fp/no-mutation */ diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 000000000..fa937740e --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,73 @@ +{ + "name": "@logto/client", + "version": "1.0.0-alpha.2", + "source": "./src/index.ts", + "main": "./lib/index.js", + "exports": { + "require": "./lib/index.js", + "import": "./lib/module.js" + }, + "module": "./lib/module.js", + "types": "./lib/index.d.ts", + "files": [ + "lib" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/logto-io/js.git", + "directory": "packages/client" + }, + "scripts": { + "dev:tsc": "tsc -p tsconfig.build.json -w --preserveWatchOutput", + "precommit": "lint-staged", + "check": "tsc --noEmit", + "build": "rm -rf lib/ && pnpm check && parcel build", + "lint": "eslint --ext .ts src", + "test": "jest", + "test:coverage": "jest --silent --env=jsdom && jest --silent --coverage", + "prepack": "pnpm test" + }, + "dependencies": { + "@logto/js": "^1.0.0-alpha.2", + "@silverhand/essentials": "^1.1.6", + "camelcase-keys": "^7.0.1", + "jose": "^4.3.8", + "js-base64": "^3.7.2", + "lodash.get": "^4.4.2", + "lodash.once": "^4.1.1", + "superstruct": "^0.16.0" + }, + "devDependencies": { + "@jest/types": "^27.5.1", + "@parcel/core": "^2.6.2", + "@parcel/packager-ts": "^2.6.2", + "@parcel/transformer-typescript-types": "^2.6.2", + "@peculiar/webcrypto": "^1.1.7", + "@silverhand/eslint-config": "^0.14.0", + "@silverhand/ts-config": "^0.14.0", + "@types/jest": "^27.4.1", + "@types/lodash.get": "^4.4.6", + "@types/lodash.once": "^4.1.7", + "@types/node": "^17.0.19", + "eslint": "^8.9.0", + "jest": "^27.5.1", + "jest-matcher-specific-error": "^1.0.0", + "lint-staged": "^13.0.0", + "nock": "^13.1.3", + "node-fetch": "^2.6.7", + "parcel": "^2.6.2", + "prettier": "^2.3.2", + "text-encoder": "^0.0.4", + "ts-jest": "^27.0.4", + "type-fest": "^2.10.0", + "typescript": "^4.5.5" + }, + "eslintConfig": { + "extends": "@silverhand" + }, + "prettier": "@silverhand/eslint-config/.prettierrc", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts new file mode 100644 index 000000000..911d35639 --- /dev/null +++ b/packages/client/src/errors.ts @@ -0,0 +1,36 @@ +import { NormalizeKeyPaths } from '@silverhand/essentials'; +import get from 'lodash.get'; + +const logtoClientErrorCodes = Object.freeze({ + sign_in_session: { + invalid: 'Invalid sign-in session.', + not_found: 'Sign-in session not found.', + }, + not_authenticated: 'Not authenticated.', + get_access_token_by_refresh_token_failed: 'Failed to get access token by refresh token.', + invalid_id_token: 'Invalid id token.', +}); + +export type LogtoClientErrorCode = NormalizeKeyPaths; + +const getMessageByErrorCode = (errorCode: LogtoClientErrorCode): string => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const message = get(logtoClientErrorCodes, errorCode); + + if (typeof message === 'string') { + return message; + } + + return errorCode; +}; + +export class LogtoClientError extends Error { + code: LogtoClientErrorCode; + data: unknown; + + constructor(code: LogtoClientErrorCode, data?: unknown) { + super(getMessageByErrorCode(code)); + this.code = code; + this.data = data; + } +} diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts new file mode 100644 index 000000000..eab671592 --- /dev/null +++ b/packages/client/src/index.test.ts @@ -0,0 +1,429 @@ +import { Prompt } from '@logto/js'; + +import LogtoClient, { LogtoClientError, LogtoSignInSessionItem } from '.'; +import { + appId, + currentUnixTimeStamp, + endpoint, + fetchOidcConfig, + handleRedirect, + LogtoClientSignInSessionAccessor, + mockCodeChallenge, + mockedCodeVerifier, + mockedState, + MockedStorage, + requester, + redirectUri, + createClient, + mockedSignInUri, + mockedSignInUriWithLoginPrompt, + accessToken, + refreshToken, + idToken, + tokenEndpoint, + postSignOutRedirectUri, + revocationEndpoint, + endSessionEndpoint, + failingRequester, +} from './mock'; + +jest.mock('@logto/js', () => ({ + ...jest.requireActual('@logto/js'), + fetchOidcConfig: async () => fetchOidcConfig(), + decodeIdToken: jest.fn(() => ({ + iss: 'issuer_value', + sub: 'subject_value', + aud: 'audience_value', + exp: currentUnixTimeStamp + 3600, + iat: currentUnixTimeStamp, + at_hash: 'at_hash_value', + })), + generateCodeChallenge: jest.fn(async () => mockCodeChallenge), + generateCodeVerifier: jest.fn(() => mockedCodeVerifier), + generateState: jest.fn(() => mockedState), + verifyIdToken: jest.fn(), +})); + +const createRemoteJWKSet = jest.fn(async () => ''); + +jest.mock('jose', () => ({ + ...jest.requireActual('jose'), + createRemoteJWKSet: async () => createRemoteJWKSet(), +})); + +describe('LogtoClient', () => { + describe('constructor', () => { + it('should not throw', () => { + expect(() => createClient()).not.toThrow(); + }); + + it('should append reserved scopes', () => { + const logtoClient = new LogtoClientSignInSessionAccessor( + { endpoint, appId, scopes: ['foo'] }, + { + requester, + storage: new MockedStorage(), + handleRedirect, + } + ); + expect(logtoClient.getLogtoConfig()).toHaveProperty('scopes', [ + 'openid', + 'offline_access', + 'profile', + 'foo', + ]); + }); + + it('should use the default prompt value "consent" if we does not provide the custom prompt', () => { + const logtoClient = new LogtoClientSignInSessionAccessor( + { endpoint, appId }, + { + requester, + storage: new MockedStorage(), + handleRedirect, + } + ); + expect(logtoClient.getLogtoConfig()).toHaveProperty('prompt', Prompt.Consent); + }); + + it('should use the custom prompt value "login"', () => { + const logtoClient = new LogtoClientSignInSessionAccessor( + { endpoint, appId, prompt: Prompt.Login }, + { + requester, + storage: new MockedStorage(), + handleRedirect, + } + ); + expect(logtoClient.getLogtoConfig()).toHaveProperty('prompt', 'login'); + }); + }); + + describe('signInSession', () => { + test('getter should throw LogtoClientError when signInSession does not contain the required property', () => { + const signInSessionAccessor = new LogtoClientSignInSessionAccessor( + { endpoint, appId }, + { + requester, + storage: new MockedStorage(), + handleRedirect, + } + ); + + // @ts-expect-error expected to set object without required property + signInSessionAccessor.setSignInSessionItem({ + redirectUri, + codeVerifier: mockedCodeVerifier, + }); + + expect(() => signInSessionAccessor.getSignInSessionItem()).toMatchError( + new LogtoClientError( + 'sign_in_session.invalid', + new Error('At path: state -- Expected a string, but received: undefined') + ) + ); + }); + + it('should be able to set and get the undefined item (for clearing sign-in session)', () => { + const signInSessionAccessor = new LogtoClientSignInSessionAccessor( + { endpoint, appId }, + { + requester, + storage: new MockedStorage(), + handleRedirect, + } + ); + + signInSessionAccessor.setSignInSessionItem(null); + expect(signInSessionAccessor.getSignInSessionItem()).toBeNull(); + }); + + it('should be able to set and get the correct item', () => { + const signInSessionAccessor = new LogtoClientSignInSessionAccessor( + { endpoint, appId }, + { + requester, + storage: new MockedStorage(), + handleRedirect, + } + ); + + const logtoSignInSessionItem: LogtoSignInSessionItem = { + redirectUri, + codeVerifier: mockedCodeVerifier, + state: mockedState, + }; + + signInSessionAccessor.setSignInSessionItem(logtoSignInSessionItem); + expect(signInSessionAccessor.getSignInSessionItem()).toEqual(logtoSignInSessionItem); + }); + }); + + describe('signIn', () => { + it('should reuse oidcConfig', async () => { + fetchOidcConfig.mockClear(); + const logtoClient = createClient(); + await Promise.all([logtoClient.signIn(redirectUri), logtoClient.signIn(redirectUri)]); + expect(fetchOidcConfig).toBeCalledTimes(1); + }); + + it('should redirect to signInUri just after calling signIn', async () => { + const logtoClient = createClient(); + await logtoClient.signIn(redirectUri); + expect(handleRedirect).toHaveBeenCalledWith(mockedSignInUri); + }); + + it('should redirect to signInUri just after calling signIn with user specified prompt', async () => { + const logtoClient = createClient(Prompt.Login); + await logtoClient.signIn(redirectUri); + expect(handleRedirect).toHaveBeenCalledWith(mockedSignInUriWithLoginPrompt); + }); + }); + + describe('isSignInRedirected', () => { + it('should return true after calling signIn', async () => { + const logtoClient = createClient(); + expect(logtoClient.isSignInRedirected(redirectUri)).toBeFalsy(); + await logtoClient.signIn(redirectUri); + expect(logtoClient.isSignInRedirected(redirectUri)).toBeTruthy(); + }); + }); + + describe('handleSignInCallback', () => { + it('should throw LogtoClientError when the sign-in session does not exist', async () => { + const logtoClient = createClient(); + await expect(logtoClient.handleSignInCallback(redirectUri)).rejects.toMatchError( + new LogtoClientError('sign_in_session.not_found') + ); + }); + + it('should set tokens after calling signIn and handleSignInCallback successfully', async () => { + requester.mockClear().mockImplementation(async () => ({ + accessToken, + refreshToken, + idToken, + scope: 'read register manage', + expiresIn: 3600, + })); + const storage = new MockedStorage(); + const logtoClient = createClient(undefined, storage); + await logtoClient.signIn(redirectUri); + + const code = `code_value`; + const callbackUri = `${redirectUri}?code=${code}&state=${mockedState}&codeVerifier=${mockedCodeVerifier}`; + + expect(storage.getItem('signInSession')).not.toBeNull(); + await expect(logtoClient.handleSignInCallback(callbackUri)).resolves.not.toThrow(); + await expect(logtoClient.getAccessToken()).resolves.toEqual(accessToken); + expect(storage.getItem('refreshToken')).toEqual(refreshToken); + expect(storage.getItem('idToken')).toEqual(idToken); + expect(requester).toHaveBeenCalledWith(tokenEndpoint, expect.anything()); + expect(storage.getItem('signInSession')).toBeNull(); + }); + }); + + describe('signOut', () => { + const storage = new MockedStorage(); + + beforeEach(() => { + storage.reset({ + idToken: 'id_token_value', + refreshToken: 'refresh_token_value', + }); + }); + + it('should call token revocation endpoint with requester', async () => { + const logtoClient = createClient(undefined, storage); + await logtoClient.signOut(postSignOutRedirectUri); + + expect(requester).toHaveBeenCalledWith(revocationEndpoint, expect.anything()); + }); + + it('should clear id token and refresh token in local storage', async () => { + const logtoClient = createClient(undefined, storage); + await logtoClient.signOut(postSignOutRedirectUri); + + expect(storage.getItem('idToken')).toBeNull(); + expect(storage.getItem('refreshToken')).toBeNull(); + }); + + it('should redirect to post sign-out URI after signing out', async () => { + const logtoClient = createClient(undefined, storage); + await logtoClient.signOut(postSignOutRedirectUri); + const encodedRedirectUri = encodeURIComponent(postSignOutRedirectUri); + + expect(handleRedirect).toHaveBeenCalledWith( + `${endSessionEndpoint}?id_token_hint=id_token_value&post_logout_redirect_uri=${encodedRedirectUri}` + ); + }); + + it('should not block sign out flow even if token revocation is failed', async () => { + const logtoClient = new LogtoClient( + { endpoint, appId }, + { + requester: failingRequester, + storage, + handleRedirect, + } + ); + + await expect(logtoClient.signOut()).resolves.not.toThrow(); + expect(failingRequester).toBeCalledTimes(1); + expect(storage.getItem('idToken')).toBeNull(); + expect(storage.getItem('refreshToken')).toBeNull(); + expect(handleRedirect).toHaveBeenCalledWith( + `${endSessionEndpoint}?id_token_hint=id_token_value` + ); + }); + }); + + describe('getAccessToken', () => { + it('should throw if idToken is empty', async () => { + const logtoClient = createClient( + undefined, + new MockedStorage({ + refreshToken: 'refresh_token_value', + }) + ); + + await expect(logtoClient.getAccessToken()).rejects.toMatchError( + new LogtoClientError('not_authenticated') + ); + }); + + it('should throw if refresh token is empty', async () => { + const logtoClient = createClient( + undefined, + new MockedStorage({ + idToken: 'id_token_value', + }) + ); + + await expect(logtoClient.getAccessToken()).rejects.toMatchError( + new LogtoClientError('not_authenticated') + ); + }); + + it('should return access token by valid refresh token', async () => { + // eslint-disable-next-line @silverhand/fp/no-let + let count = 0; + + requester.mockClear().mockImplementation(async () => { + // eslint-disable-next-line @silverhand/fp/no-mutation + count += 1; + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + return { + accessToken: count === 1 ? 'access_token_value' : 'nope', + refreshToken: count === 1 ? 'new_refresh_token_value' : 'nope', + expiresIn: 3600, + }; + }); + + const logtoClient = createClient( + undefined, + new MockedStorage({ + idToken: 'id_token_value', + refreshToken: 'refresh_token_value', + }) + ); + const [accessToken_1, accessToken_2] = await Promise.all([ + logtoClient.getAccessToken(), + logtoClient.getAccessToken(), + ]); + + expect(requester).toHaveBeenCalledWith(tokenEndpoint, expect.anything()); + expect(accessToken_1).toEqual('access_token_value'); + expect(accessToken_2).toEqual('access_token_value'); + expect(logtoClient.refreshToken).toEqual('new_refresh_token_value'); + }); + + it('should delete expired access token once', async () => { + requester.mockClear().mockImplementation(async () => ({ + accessToken: 'access_token_value', + refreshToken: 'new_refresh_token_value', + expiresIn: 3600, + })); + + const logtoClient = new LogtoClientSignInSessionAccessor( + { endpoint, appId }, + { + requester, + storage: new MockedStorage({ + idToken: 'id_token_value', + refreshToken: 'refresh_token_value', + }), + handleRedirect, + } + ); + + const accessTokenMap = logtoClient.getAccessTokenMap(); + jest.spyOn(accessTokenMap, 'delete'); + accessTokenMap.set('@', { + token: 'token_value', + scope: 'scope_value', + expiresAt: Date.now() / 1000 - 1, + }); + + await Promise.all([logtoClient.getAccessToken(), logtoClient.getAccessToken()]); + expect(accessTokenMap.delete).toBeCalledTimes(1); + }); + + it('should reuse jwk set', async () => { + requester.mockImplementation(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + return { + IdToken: 'id_token_value', + accessToken: 'access_token_value', + refreshToken: 'new_refresh_token_value', + expiresIn: 3600, + }; + }); + + createRemoteJWKSet.mockClear(); + const logtoClient = createClient( + undefined, + new MockedStorage({ + idToken: 'id_token_value', + refreshToken: 'refresh_token_value', + }) + ); + await Promise.all([logtoClient.getAccessToken('a'), logtoClient.getAccessToken('b')]); + expect(createRemoteJWKSet).toBeCalledTimes(1); + }); + }); + + describe('getIdTokenClaims', () => { + it('should throw if id token is empty', async () => { + const logtoClient = createClient(); + + expect(() => logtoClient.getIdTokenClaims()).toMatchError( + new LogtoClientError('not_authenticated') + ); + }); + + it('should return id token claims', async () => { + const logtoClient = createClient( + undefined, + new MockedStorage({ + idToken: 'id_token_value', + }) + ); + const idTokenClaims = logtoClient.getIdTokenClaims(); + + expect(idTokenClaims).toEqual({ + iss: 'issuer_value', + sub: 'subject_value', + aud: 'audience_value', + exp: currentUnixTimeStamp + 3600, + iat: currentUnixTimeStamp, + at_hash: 'at_hash_value', + }); + }); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 000000000..4a7cd53aa --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,378 @@ +import { + CodeTokenResponse, + decodeIdToken, + fetchOidcConfig, + fetchTokenByAuthorizationCode, + fetchTokenByRefreshToken, + generateCodeChallenge, + generateCodeVerifier, + generateSignInUri, + generateSignOutUri, + generateState, + IdTokenClaims, + Prompt, + Requester, + revoke, + verifyAndParseCodeFromCallbackUri, + verifyIdToken, + withReservedScopes, +} from '@logto/js'; +import { Nullable } from '@silverhand/essentials'; +import { createRemoteJWKSet } from 'jose'; +import once from 'lodash.once'; +import { assert, Infer, string, type } from 'superstruct'; + +import { LogtoClientError } from './errors'; +import { Storage } from './storage'; +import { buildAccessTokenKey, getDiscoveryEndpoint } from './utils'; + +export type { IdTokenClaims, LogtoErrorCode } from '@logto/js'; +export { LogtoError, OidcError, Prompt } from '@logto/js'; +export * from './errors'; + +export type LogtoConfig = { + endpoint: string; + appId: string; + scopes?: string[]; + resources?: string[]; + prompt?: Prompt; + usingPersistStorage?: boolean; +}; + +export type AccessToken = { + token: string; + scope: string; + expiresAt: number; // Unix Timestamp in seconds +}; + +export const LogtoSignInSessionItemSchema = type({ + redirectUri: string(), + codeVerifier: string(), + state: string(), +}); + +export type LogtoSignInSessionItem = Infer; + +export type HandleRedirect = (url: string) => void; + +export default class LogtoClient { + protected readonly logtoConfig: LogtoConfig; + protected readonly getOidcConfig = once(this._getOidcConfig); + protected readonly getJwtVerifyGetKey = once(this._getJwtVerifyGetKey); + + protected readonly storage: Storage; + protected readonly requester: Requester; + protected readonly handleRedirect: HandleRedirect; + + protected readonly accessTokenMap = new Map(); + + private readonly getAccessTokenPromiseMap = new Map>(); + private _idToken: Nullable; + + constructor( + logtoConfig: LogtoConfig, + { + requester, + storage, + handleRedirect, + }: { requester: Requester; storage: Storage; handleRedirect: HandleRedirect } + ) { + this.logtoConfig = { + ...logtoConfig, + prompt: logtoConfig.prompt ?? Prompt.Consent, + scopes: withReservedScopes(logtoConfig.scopes).split(' '), + }; + this.storage = storage; + this.requester = requester; + this.handleRedirect = handleRedirect; + this._idToken = this.storage.getItem('idToken'); + } + + public get isAuthenticated() { + return Boolean(this.idToken); + } + + protected get signInSession(): Nullable { + const jsonItem = this.storage.getItem('signInSession'); + + if (!jsonItem) { + return null; + } + + try { + const item: unknown = JSON.parse(jsonItem); + assert(item, LogtoSignInSessionItemSchema); + + return item; + } catch (error: unknown) { + throw new LogtoClientError('sign_in_session.invalid', error); + } + } + + protected set signInSession(logtoSignInSessionItem: Nullable) { + if (!logtoSignInSessionItem) { + this.storage.removeItem('signInSession'); + + return; + } + + const jsonItem = JSON.stringify(logtoSignInSessionItem); + this.storage.setItem('signInSession', jsonItem); + } + + get refreshToken() { + return this.storage.getItem('refreshToken'); + } + + private set refreshToken(refreshToken: Nullable) { + if (!refreshToken) { + this.storage.removeItem('refreshToken'); + + return; + } + + this.storage.setItem('refreshToken', refreshToken); + } + + get idToken() { + return this._idToken; + } + + private set idToken(idToken: Nullable) { + this._idToken = idToken; + + if (!idToken) { + this.storage.removeItem('idToken'); + + return; + } + + this.storage.setItem('idToken', idToken); + } + + // eslint-disable-next-line complexity + public async getAccessToken(resource?: string): Promise { + if (!this.idToken) { + throw new LogtoClientError('not_authenticated'); + } + + const accessTokenKey = buildAccessTokenKey(resource); + const accessToken = this.accessTokenMap.get(accessTokenKey); + + if (accessToken && accessToken.expiresAt > Date.now() / 1000) { + return accessToken.token; + } + + // Since the access token has expired, delete it from the map. + if (accessToken) { + this.accessTokenMap.delete(accessTokenKey); + } + + /** + * Need to fetch a new access token using refresh token. + * Reuse the cached promise if exists. + */ + const cachedPromise = this.getAccessTokenPromiseMap.get(accessTokenKey); + + if (cachedPromise) { + return cachedPromise; + } + + /** + * Create a new promise and cache in map to avoid race condition. + * Since we enable "refresh token rotation" by default, + * it will be problematic when calling multiple `getAccessToken()` closely. + */ + const promise = this.getAccessTokenByRefreshToken(resource); + this.getAccessTokenPromiseMap.set(accessTokenKey, promise); + + const token = await promise; + this.getAccessTokenPromiseMap.delete(accessTokenKey); + + return token; + } + + public getIdTokenClaims(): IdTokenClaims { + if (!this.idToken) { + throw new LogtoClientError('not_authenticated'); + } + + return decodeIdToken(this.idToken); + } + + public async signIn(redirectUri: string) { + const { appId: clientId, prompt, resources, scopes } = this.logtoConfig; + const { authorizationEndpoint } = await this.getOidcConfig(); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + const state = generateState(); + + const signInUri = generateSignInUri({ + authorizationEndpoint, + clientId, + redirectUri, + codeChallenge, + state, + scopes, + resources, + prompt, + }); + + this.signInSession = { redirectUri, codeVerifier, state }; + this.refreshToken = null; + this.idToken = null; + + this.handleRedirect(signInUri); + } + + public isSignInRedirected(url: string): boolean { + const { signInSession } = this; + + if (!signInSession) { + return false; + } + const { redirectUri } = signInSession; + const { origin, pathname } = new URL(url); + + return `${origin}${pathname}` === redirectUri; + } + + public async handleSignInCallback(callbackUri: string) { + const { signInSession, logtoConfig, requester } = this; + + if (!signInSession) { + throw new LogtoClientError('sign_in_session.not_found'); + } + + const { redirectUri, state, codeVerifier } = signInSession; + const code = verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state); + + const { appId: clientId } = logtoConfig; + const { tokenEndpoint } = await this.getOidcConfig(); + const codeTokenResponse = await fetchTokenByAuthorizationCode( + { + clientId, + tokenEndpoint, + redirectUri, + codeVerifier, + code, + }, + requester + ); + + await this.verifyIdToken(codeTokenResponse.idToken); + + this.saveCodeToken(codeTokenResponse); + this.signInSession = null; + } + + public async signOut(postLogoutRedirectUri?: string) { + if (!this.idToken) { + throw new LogtoClientError('not_authenticated'); + } + + const { appId: clientId } = this.logtoConfig; + const { endSessionEndpoint, revocationEndpoint } = await this.getOidcConfig(); + + if (this.refreshToken) { + try { + await revoke(revocationEndpoint, clientId, this.refreshToken, this.requester); + } catch { + // Do nothing at this point, as we don't want to break the sign-out flow even if the revocation is failed + } + } + + const url = generateSignOutUri({ + endSessionEndpoint, + postLogoutRedirectUri, + idToken: this.idToken, + }); + + this.accessTokenMap.clear(); + this.refreshToken = null; + this.idToken = null; + + this.handleRedirect(url); + } + + private async getAccessTokenByRefreshToken(resource?: string): Promise { + if (!this.refreshToken) { + throw new LogtoClientError('not_authenticated'); + } + + try { + const accessTokenKey = buildAccessTokenKey(resource); + const { appId: clientId } = this.logtoConfig; + const { tokenEndpoint } = await this.getOidcConfig(); + const { accessToken, refreshToken, idToken, scope, expiresIn } = + await fetchTokenByRefreshToken( + { + clientId, + tokenEndpoint, + refreshToken: this.refreshToken, + resource, + scopes: resource ? ['offline_access'] : undefined, // Force remove openid scope from the request + }, + this.requester + ); + + this.accessTokenMap.set(accessTokenKey, { + token: accessToken, + scope, + expiresAt: Math.round(Date.now() / 1000) + expiresIn, + }); + + this.refreshToken = refreshToken; + + if (idToken) { + await this.verifyIdToken(idToken); + this.idToken = idToken; + } + + return accessToken; + } catch (error: unknown) { + throw new LogtoClientError('get_access_token_by_refresh_token_failed', error); + } + } + + private async _getOidcConfig() { + const { endpoint } = this.logtoConfig; + const discoveryEndpoint = getDiscoveryEndpoint(endpoint); + + return fetchOidcConfig(discoveryEndpoint, this.requester); + } + + private async _getJwtVerifyGetKey() { + const { jwksUri } = await this.getOidcConfig(); + + return createRemoteJWKSet(new URL(jwksUri)); + } + + private async verifyIdToken(idToken: string) { + const { appId } = this.logtoConfig; + const { issuer } = await this.getOidcConfig(); + const jwtVerifyGetKey = await this.getJwtVerifyGetKey(); + + try { + await verifyIdToken(idToken, appId, issuer, jwtVerifyGetKey); + } catch (error: unknown) { + throw new LogtoClientError('invalid_id_token', error); + } + } + + private saveCodeToken({ + refreshToken, + idToken, + scope, + accessToken, + expiresIn, + }: CodeTokenResponse) { + this.refreshToken = refreshToken ?? null; + this.idToken = idToken; + + // NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589) + const accessTokenKey = buildAccessTokenKey(); + const expiresAt = Date.now() / 1000 + expiresIn; + this.accessTokenMap.set(accessTokenKey, { token: accessToken, scope, expiresAt }); + } +} diff --git a/packages/client/src/mock.ts b/packages/client/src/mock.ts new file mode 100644 index 000000000..00f0c1393 --- /dev/null +++ b/packages/client/src/mock.ts @@ -0,0 +1,126 @@ +import { generateSignInUri, Prompt } from '@logto/js'; +import { Nullable } from '@silverhand/essentials'; + +import LogtoClient, { AccessToken, LogtoConfig, LogtoSignInSessionItem } from '.'; +import { Storage } from './storage'; + +export const appId = 'app_id_value'; +export const endpoint = 'https://logto.dev'; + +export class MockedStorage implements Storage { + private storage: Record = {}; + + constructor(values?: Record) { + if (values) { + this.storage = values; + } + } + + public getItem(key: string) { + return this.storage[key] ?? null; + } + + public setItem(key: string, value: string): void { + this.storage[key] = value; + } + + public removeItem(key: string): void { + /* eslint-disable @typescript-eslint/no-dynamic-delete */ + // eslint-disable-next-line @silverhand/fp/no-delete + delete this.storage[key]; + /* eslint-enable @typescript-eslint/no-dynamic-delete */ + } + + public reset(values: Record): void { + this.storage = values; + } +} + +export const authorizationEndpoint = `${endpoint}/oidc/auth`; +export const tokenEndpoint = `${endpoint}/oidc/token`; +export const endSessionEndpoint = `${endpoint}/oidc/session/end`; +export const revocationEndpoint = `${endpoint}/oidc/token/revocation`; +export const jwksUri = `${endpoint}/oidc/jwks`; +export const issuer = 'http://localhost:443/oidc'; + +export const redirectUri = 'http://localhost:3000/callback'; +export const postSignOutRedirectUri = 'http://localhost:3000'; + +export const mockCodeChallenge = 'code_challenge_value'; +export const mockedCodeVerifier = 'code_verifier_value'; +export const mockedState = 'state_value'; +export const mockedSignInUri = generateSignInUri({ + authorizationEndpoint, + clientId: appId, + redirectUri, + codeChallenge: mockCodeChallenge, + state: mockedState, +}); +export const mockedSignInUriWithLoginPrompt = generateSignInUri({ + authorizationEndpoint, + clientId: appId, + redirectUri, + codeChallenge: mockCodeChallenge, + state: mockedState, + prompt: Prompt.Login, +}); + +export const refreshTokenStorageKey = `logto:${appId}:refreshToken`; +export const idTokenStorageKey = `logto:${appId}:idToken`; +export const signInSessionStorageKey = `logto:${appId}`; + +export const accessToken = 'access_token_value'; +export const refreshToken = 'new_refresh_token_value'; +export const idToken = 'id_token_value'; + +export const currentUnixTimeStamp = Date.now() / 1000; + +export const fetchOidcConfig = jest.fn(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + return { + authorizationEndpoint, + tokenEndpoint, + endSessionEndpoint, + revocationEndpoint, + jwksUri, + issuer, + }; +}); + +export const requester = jest.fn(); +export const failingRequester = jest.fn().mockRejectedValue(new Error('Failed!')); +export const handleRedirect = jest.fn(); + +export const createClient = (prompt?: Prompt, storage = new MockedStorage()) => + new LogtoClient( + { endpoint, appId, prompt }, + { + requester, + storage, + handleRedirect, + } + ); + +/** + * Make LogtoClient.signInSession accessible for test + */ +export class LogtoClientSignInSessionAccessor extends LogtoClient { + public getLogtoConfig(): Nullable { + return this.logtoConfig; + } + + public getSignInSessionItem(): Nullable { + return this.signInSession; + } + + public setSignInSessionItem(item: Nullable) { + this.signInSession = item; + } + + public getAccessTokenMap(): Map { + return this.accessTokenMap; + } +} diff --git a/packages/client/src/storage.ts b/packages/client/src/storage.ts new file mode 100644 index 000000000..66f08b006 --- /dev/null +++ b/packages/client/src/storage.ts @@ -0,0 +1,9 @@ +import { Nullable } from '@silverhand/essentials'; + +type StorageKey = 'idToken' | 'refreshToken' | 'accessToken' | 'signInSession'; + +export interface Storage { + getItem(key: StorageKey): Nullable; + setItem(key: StorageKey, value: string): void; + removeItem(key: StorageKey): void; +} diff --git a/packages/client/src/utils/index.test.ts b/packages/client/src/utils/index.test.ts new file mode 100644 index 000000000..cf183a5cc --- /dev/null +++ b/packages/client/src/utils/index.test.ts @@ -0,0 +1,13 @@ +import { buildAccessTokenKey, getDiscoveryEndpoint } from '.'; + +describe('client utilities', () => { + test('get discovery endpoint', () => { + const endpoint = getDiscoveryEndpoint('https://example.com'); + expect(endpoint).toEqual('https://example.com/oidc/.well-known/openid-configuration'); + }); + + test('build access token key', () => { + const key = buildAccessTokenKey('resource', ['scope1', 'scope2']); + expect(key).toEqual('scope1 scope2@resource'); + }); +}); diff --git a/packages/client/src/utils/index.ts b/packages/client/src/utils/index.ts new file mode 100644 index 000000000..40235e7bd --- /dev/null +++ b/packages/client/src/utils/index.ts @@ -0,0 +1,7 @@ +import { discoveryPath } from '@logto/js'; + +export const buildAccessTokenKey = (resource = '', scopes: string[] = []): string => + `${scopes.slice().sort().join(' ')}@${resource}`; + +export const getDiscoveryEndpoint = (endpoint: string): string => + new URL(discoveryPath, endpoint).toString(); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 000000000..2318cc8cf --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "types": [ + "node", + "jest", + "jest-matcher-specific-error" + ] + }, + "include": [ + "src", + "jest.config.ts", + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 976f8c90f..c65fde5fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,73 @@ importers: stylelint: 14.8.2 typescript: 4.6.2 + packages/client: + specifiers: + '@jest/types': ^27.5.1 + '@logto/js': ^1.0.0-alpha.2 + '@parcel/core': ^2.6.2 + '@parcel/packager-ts': ^2.6.2 + '@parcel/transformer-typescript-types': ^2.6.2 + '@peculiar/webcrypto': ^1.1.7 + '@silverhand/eslint-config': ^0.14.0 + '@silverhand/essentials': ^1.1.6 + '@silverhand/ts-config': ^0.14.0 + '@types/jest': ^27.4.1 + '@types/lodash.get': ^4.4.6 + '@types/lodash.once': ^4.1.7 + '@types/node': ^17.0.19 + camelcase-keys: ^7.0.1 + eslint: ^8.9.0 + jest: ^27.5.1 + jest-matcher-specific-error: ^1.0.0 + jose: ^4.3.8 + js-base64: ^3.7.2 + lint-staged: ^13.0.0 + lodash.get: ^4.4.2 + lodash.once: ^4.1.1 + nock: ^13.1.3 + node-fetch: ^2.6.7 + parcel: ^2.6.2 + prettier: ^2.3.2 + superstruct: ^0.16.0 + text-encoder: ^0.0.4 + ts-jest: ^27.0.4 + type-fest: ^2.10.0 + typescript: ^4.5.5 + dependencies: + '@logto/js': link:../js + '@silverhand/essentials': 1.1.6 + camelcase-keys: 7.0.2 + jose: 4.5.1 + js-base64: 3.7.2 + lodash.get: 4.4.2 + lodash.once: 4.1.1 + superstruct: 0.16.0 + devDependencies: + '@jest/types': 27.5.1 + '@parcel/core': 2.6.2 + '@parcel/packager-ts': 2.6.2_@parcel+core@2.6.2 + '@parcel/transformer-typescript-types': 2.6.2_lkaqhzbnfibkyckap3hu4t4sui + '@peculiar/webcrypto': 1.2.3 + '@silverhand/eslint-config': 0.14.0_omynrcsa6lreu7imjpprjb3jie + '@silverhand/ts-config': 0.14.0_typescript@4.7.2 + '@types/jest': 27.4.1 + '@types/lodash.get': 4.4.6 + '@types/lodash.once': 4.1.7 + '@types/node': 17.0.19 + eslint: 8.9.0 + jest: 27.5.1 + jest-matcher-specific-error: 1.0.0 + lint-staged: 13.0.0 + nock: 13.2.4 + node-fetch: 2.6.7 + parcel: 2.6.2 + prettier: 2.5.1 + text-encoder: 0.0.4 + ts-jest: 27.1.3_u4suh3umvg724wu2nufptihvny + type-fest: 2.12.0 + typescript: 4.7.2 + packages/js: specifiers: '@jest/types': ^27.5.1 @@ -3064,6 +3131,23 @@ packages: - '@parcel/core' dev: true + /@parcel/transformer-typescript-types/2.6.2_lkaqhzbnfibkyckap3hu4t4sui: + resolution: {integrity: sha512-p2Ctikbnfof/GbWE67Fg0VlKkTYfbDujxHuk+qAm7XXGZe48dOc7l7CQ7swvapkUWkL3rKtsLAf3HecLz4D10Q==} + engines: {node: '>= 12.0.0', parcel: ^2.6.2} + peerDependencies: + typescript: '>=3.0.0' + dependencies: + '@parcel/diagnostic': 2.6.2 + '@parcel/plugin': 2.6.2_@parcel+core@2.6.2 + '@parcel/source-map': 2.0.2 + '@parcel/ts-utils': 2.6.2_typescript@4.7.2 + '@parcel/utils': 2.6.2 + nullthrows: 1.1.1 + typescript: 4.7.2 + transitivePeerDependencies: + - '@parcel/core' + dev: true + /@parcel/transformer-typescript-types/2.6.2_xzish6xme2buyxhmq5bzjnp4jm: resolution: {integrity: sha512-p2Ctikbnfof/GbWE67Fg0VlKkTYfbDujxHuk+qAm7XXGZe48dOc7l7CQ7swvapkUWkL3rKtsLAf3HecLz4D10Q==} engines: {node: '>= 12.0.0', parcel: ^2.6.2} @@ -3101,6 +3185,16 @@ packages: typescript: 4.6.2 dev: true + /@parcel/ts-utils/2.6.2_typescript@4.7.2: + resolution: {integrity: sha512-PqmjyBYIa56bSjeumJj/tEFooYOBePw8PVi9dU1dkuJoBtDJl/+s/GPW2JnupaplKnlEtX8ag922vjua4A9gsg==} + engines: {node: '>= 12.0.0'} + peerDependencies: + typescript: '>=3.0.0' + dependencies: + nullthrows: 1.1.1 + typescript: 4.7.2 + dev: true + /@parcel/types/2.6.2_@parcel+core@2.6.2: resolution: {integrity: sha512-MV8BFpCIs2jMUvK2RHqzkoiuOQ//JIbrD1zocA2YRW3zuPL/iABvbAABJoXpoPCKikVWOoCWASgBfWQo26VvJQ==} dependencies: @@ -3196,6 +3290,7 @@ packages: stylelint-config-xo-scss: 0.15.0_zhymizk4kfitko2u2d4p3qwyee transitivePeerDependencies: - eslint + - eslint-import-resolver-webpack - postcss - prettier - supports-color @@ -3215,6 +3310,7 @@ packages: stylelint-config-xo-scss: 0.15.0_an3wuxxoixcpivwd2moqhhly5q transitivePeerDependencies: - eslint + - eslint-import-resolver-webpack - postcss - prettier - supports-color @@ -3234,6 +3330,7 @@ packages: stylelint-config-xo-scss: 0.15.0_zhymizk4kfitko2u2d4p3qwyee transitivePeerDependencies: - eslint + - eslint-import-resolver-webpack - postcss - prettier - supports-color @@ -3257,7 +3354,7 @@ packages: eslint-import-resolver-typescript: 2.5.0_cmtdok55f7srt3k3ux6kqq5jcq eslint-plugin-consistent-default-export-name: 0.0.7 eslint-plugin-eslint-comments: 3.2.0_eslint@8.9.0 - eslint-plugin-import: 2.25.4_eslint@8.9.0 + eslint-plugin-import: 2.25.4_hkug4hnbgllydtpdygs6xvzedm eslint-plugin-no-use-extend-native: 0.5.0 eslint-plugin-node: 11.1.0_eslint@8.9.0 eslint-plugin-prettier: 3.4.1_t5rlqxhdzybjjhn5fth7z27jl4 @@ -3267,6 +3364,7 @@ packages: pkg-dir: 4.2.0 prettier: 2.5.1 transitivePeerDependencies: + - eslint-import-resolver-webpack - supports-color - typescript dev: true @@ -3288,7 +3386,7 @@ packages: eslint-import-resolver-typescript: 2.5.0_cmtdok55f7srt3k3ux6kqq5jcq eslint-plugin-consistent-default-export-name: 0.0.7 eslint-plugin-eslint-comments: 3.2.0_eslint@8.9.0 - eslint-plugin-import: 2.25.4_eslint@8.9.0 + eslint-plugin-import: 2.25.4_hkug4hnbgllydtpdygs6xvzedm eslint-plugin-no-use-extend-native: 0.5.0 eslint-plugin-node: 11.1.0_eslint@8.9.0 eslint-plugin-prettier: 3.4.1_t5rlqxhdzybjjhn5fth7z27jl4 @@ -3298,6 +3396,39 @@ packages: pkg-dir: 4.2.0 prettier: 2.5.1 transitivePeerDependencies: + - eslint-import-resolver-webpack + - supports-color + - typescript + dev: true + + /@silverhand/eslint-config/0.14.0_omynrcsa6lreu7imjpprjb3jie: + resolution: {integrity: sha512-Fiaf3FUSbHzPeYqmMncgw5sQ/48rF7MMTzKhgKQ5RhVy7ja7rmD7XaptRxqIHkxnsVcQK4NkPMGa+tjIASwRzg==} + engines: {node: '>=14.15.0'} + peerDependencies: + eslint: ^8.1.0 + prettier: ^2.3.2 + dependencies: + '@silverhand/eslint-plugin-fp': 2.5.0_eslint@8.9.0 + '@typescript-eslint/eslint-plugin': 5.12.1_bqfbmxs3j4s52lspk4dl2t5o7a + '@typescript-eslint/parser': 5.12.1_4gmsiq4b4nr3cnbd3xuvl5c34u + eslint: 8.9.0 + eslint-config-prettier: 8.4.0_eslint@8.9.0 + eslint-config-xo: 0.40.0_eslint@8.9.0 + eslint-config-xo-typescript: 0.50.0_6os4jlxfdtlgww76rja3unxxri + eslint-import-resolver-typescript: 2.5.0_cmtdok55f7srt3k3ux6kqq5jcq + eslint-plugin-consistent-default-export-name: 0.0.7 + eslint-plugin-eslint-comments: 3.2.0_eslint@8.9.0 + eslint-plugin-import: 2.25.4_hkug4hnbgllydtpdygs6xvzedm + eslint-plugin-no-use-extend-native: 0.5.0 + eslint-plugin-node: 11.1.0_eslint@8.9.0 + eslint-plugin-prettier: 3.4.1_t5rlqxhdzybjjhn5fth7z27jl4 + eslint-plugin-promise: 6.0.0_eslint@8.9.0 + eslint-plugin-sql: 2.1.0_eslint@8.9.0 + eslint-plugin-unicorn: 39.0.0_eslint@8.9.0 + pkg-dir: 4.2.0 + prettier: 2.5.1 + transitivePeerDependencies: + - eslint-import-resolver-webpack - supports-color - typescript dev: true @@ -3361,6 +3492,15 @@ packages: typescript: 4.6.2 dev: true + /@silverhand/ts-config/0.14.0_typescript@4.7.2: + resolution: {integrity: sha512-ktL4EvhTejlU7KD4tlq/NcVNRiQoH/NmguaqfcFu2GbswDfHEEBTaPkbi9c6UDzpQtjLhC27dYpzgpH6KkT6LA==} + engines: {node: '>=14.15.0'} + peerDependencies: + typescript: ^4.3.5 + dependencies: + typescript: 4.7.2 + dev: true + /@sinonjs/commons/1.8.3: resolution: {integrity: sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==} dependencies: @@ -3518,6 +3658,12 @@ packages: '@types/lodash': 4.14.178 dev: true + /@types/lodash.once/4.1.7: + resolution: {integrity: sha512-XWhnXzWkxoleOoXKmzUtep8vT+wiiQQgmPD+wzG0yO0bdlszmnqHRb2WiY5hK/8V0DTet1+z9DJj9cnbdAhWng==} + dependencies: + '@types/lodash': 4.14.178 + dev: true + /@types/lodash/4.14.178: resolution: {integrity: sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==} dev: true @@ -3601,6 +3747,33 @@ packages: '@types/yargs-parser': 20.2.1 dev: true + /@typescript-eslint/eslint-plugin/5.12.1_bqfbmxs3j4s52lspk4dl2t5o7a: + resolution: {integrity: sha512-M499lqa8rnNK7mUv74lSFFttuUsubIRdAbHcVaP93oFcKkEmHmLqy2n7jM9C8DVmFMYK61ExrZU6dLYhQZmUpw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/parser': ^5.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/parser': 5.12.1_4gmsiq4b4nr3cnbd3xuvl5c34u + '@typescript-eslint/scope-manager': 5.12.1 + '@typescript-eslint/type-utils': 5.12.1_4gmsiq4b4nr3cnbd3xuvl5c34u + '@typescript-eslint/utils': 5.12.1_4gmsiq4b4nr3cnbd3xuvl5c34u + debug: 4.3.4 + eslint: 8.9.0 + functional-red-black-tree: 1.0.1 + ignore: 5.2.0 + regexpp: 3.2.0 + semver: 7.3.5 + tsutils: 3.21.0_typescript@4.7.2 + typescript: 4.7.2 + transitivePeerDependencies: + - supports-color + dev: true + /@typescript-eslint/eslint-plugin/5.12.1_bsej5di4ktw753ws66fiwndpim: resolution: {integrity: sha512-M499lqa8rnNK7mUv74lSFFttuUsubIRdAbHcVaP93oFcKkEmHmLqy2n7jM9C8DVmFMYK61ExrZU6dLYhQZmUpw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3655,6 +3828,26 @@ packages: - supports-color dev: true + /@typescript-eslint/parser/5.12.1_4gmsiq4b4nr3cnbd3xuvl5c34u: + resolution: {integrity: sha512-6LuVUbe7oSdHxUWoX/m40Ni8gsZMKCi31rlawBHt7VtW15iHzjbpj2WLiToG2758KjtCCiLRKZqfrOdl3cNKuw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/scope-manager': 5.12.1 + '@typescript-eslint/types': 5.12.1 + '@typescript-eslint/typescript-estree': 5.12.1_typescript@4.7.2 + debug: 4.3.4 + eslint: 8.9.0 + typescript: 4.7.2 + transitivePeerDependencies: + - supports-color + dev: true + /@typescript-eslint/parser/5.12.1_fhzmdq77bspfhxkfuzq4fbrdsy: resolution: {integrity: sha512-6LuVUbe7oSdHxUWoX/m40Ni8gsZMKCi31rlawBHt7VtW15iHzjbpj2WLiToG2758KjtCCiLRKZqfrOdl3cNKuw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3703,6 +3896,25 @@ packages: '@typescript-eslint/visitor-keys': 5.12.1 dev: true + /@typescript-eslint/type-utils/5.12.1_4gmsiq4b4nr3cnbd3xuvl5c34u: + resolution: {integrity: sha512-Gh8feEhsNLeCz6aYqynh61Vsdy+tiNNkQtc+bN3IvQvRqHkXGUhYkUi+ePKzP0Mb42se7FDb+y2SypTbpbR/Sg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '*' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/utils': 5.12.1_4gmsiq4b4nr3cnbd3xuvl5c34u + debug: 4.3.4 + eslint: 8.9.0 + tsutils: 3.21.0_typescript@4.7.2 + typescript: 4.7.2 + transitivePeerDependencies: + - supports-color + dev: true + /@typescript-eslint/type-utils/5.12.1_fhzmdq77bspfhxkfuzq4fbrdsy: resolution: {integrity: sha512-Gh8feEhsNLeCz6aYqynh61Vsdy+tiNNkQtc+bN3IvQvRqHkXGUhYkUi+ePKzP0Mb42se7FDb+y2SypTbpbR/Sg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3788,6 +4000,45 @@ packages: - supports-color dev: true + /@typescript-eslint/typescript-estree/5.12.1_typescript@4.7.2: + resolution: {integrity: sha512-ahOdkIY9Mgbza7L9sIi205Pe1inCkZWAHE1TV1bpxlU4RZNPtXaDZfiiFWcL9jdxvW1hDYZJXrFm+vlMkXRbBw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 5.12.1 + '@typescript-eslint/visitor-keys': 5.12.1 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.3.5 + tsutils: 3.21.0_typescript@4.7.2 + typescript: 4.7.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils/5.12.1_4gmsiq4b4nr3cnbd3xuvl5c34u: + resolution: {integrity: sha512-Qq9FIuU0EVEsi8fS6pG+uurbhNTtoYr4fq8tKjBupsK5Bgbk2I32UGm0Sh+WOyjOPgo/5URbxxSNV6HYsxV4MQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@types/json-schema': 7.0.9 + '@typescript-eslint/scope-manager': 5.12.1 + '@typescript-eslint/types': 5.12.1 + '@typescript-eslint/typescript-estree': 5.12.1_typescript@4.7.2 + eslint: 8.9.0 + eslint-scope: 5.1.1 + eslint-utils: 3.0.0_eslint@8.9.0 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /@typescript-eslint/utils/5.12.1_fhzmdq77bspfhxkfuzq4fbrdsy: resolution: {integrity: sha512-Qq9FIuU0EVEsi8fS6pG+uurbhNTtoYr4fq8tKjBupsK5Bgbk2I32UGm0Sh+WOyjOPgo/5URbxxSNV6HYsxV4MQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -5123,12 +5374,22 @@ packages: /debug/2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true dependencies: ms: 2.0.0 dev: true /debug/3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true dependencies: ms: 2.1.3 dev: true @@ -5725,6 +5986,19 @@ packages: typescript: 4.6.2 dev: true + /eslint-config-xo-typescript/0.50.0_6os4jlxfdtlgww76rja3unxxri: + resolution: {integrity: sha512-Ru2tXB8y2w9fFHLm4v2AVfY6P81UbfEuDZuxEpeXlfV65Ezlk0xO4nBaT899ojIFkWfr60rP9Ye4CdVUUT1UYg==} + engines: {node: '>=12'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '>=5.8.0' + eslint: '>=8.0.0' + typescript: '>=4.4' + dependencies: + '@typescript-eslint/eslint-plugin': 5.12.1_bqfbmxs3j4s52lspk4dl2t5o7a + eslint: 8.9.0 + typescript: 4.7.2 + dev: true + /eslint-config-xo-typescript/0.50.0_j5lx745m5a5mhkgwniio6hgz24: resolution: {integrity: sha512-Ru2tXB8y2w9fFHLm4v2AVfY6P81UbfEuDZuxEpeXlfV65Ezlk0xO4nBaT899ojIFkWfr60rP9Ye4CdVUUT1UYg==} engines: {node: '>=12'} @@ -5753,6 +6027,8 @@ packages: dependencies: debug: 3.2.7 resolve: 1.22.0 + transitivePeerDependencies: + - supports-color dev: true /eslint-import-resolver-typescript/2.5.0_cmtdok55f7srt3k3ux6kqq5jcq: @@ -5764,7 +6040,7 @@ packages: dependencies: debug: 4.3.4 eslint: 8.9.0 - eslint-plugin-import: 2.25.4_eslint@8.9.0 + eslint-plugin-import: 2.25.4_hkug4hnbgllydtpdygs6xvzedm glob: 7.2.0 is-glob: 4.0.3 resolve: 1.22.0 @@ -5773,12 +6049,31 @@ packages: - supports-color dev: true - /eslint-module-utils/2.7.3: + /eslint-module-utils/2.7.3_e5wtvfn4rlk3af2y4axy7jmfea: resolution: {integrity: sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==} engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true dependencies: + '@typescript-eslint/parser': 5.12.1_fhzmdq77bspfhxkfuzq4fbrdsy debug: 3.2.7 + eslint-import-resolver-node: 0.3.6 + eslint-import-resolver-typescript: 2.5.0_cmtdok55f7srt3k3ux6kqq5jcq find-up: 2.1.0 + transitivePeerDependencies: + - supports-color dev: true /eslint-plugin-consistent-default-export-name/0.0.7: @@ -5813,19 +6108,24 @@ packages: ignore: 5.2.0 dev: true - /eslint-plugin-import/2.25.4_eslint@8.9.0: + /eslint-plugin-import/2.25.4_hkug4hnbgllydtpdygs6xvzedm: resolution: {integrity: sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA==} engines: {node: '>=4'} peerDependencies: + '@typescript-eslint/parser': '*' eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true dependencies: + '@typescript-eslint/parser': 5.12.1_fhzmdq77bspfhxkfuzq4fbrdsy array-includes: 3.1.4 array.prototype.flat: 1.2.5 debug: 2.6.9 doctrine: 2.1.0 eslint: 8.9.0 eslint-import-resolver-node: 0.3.6 - eslint-module-utils: 2.7.3 + eslint-module-utils: 2.7.3_e5wtvfn4rlk3af2y4axy7jmfea has: 1.0.3 is-core-module: 2.8.1 is-glob: 4.0.3 @@ -5833,6 +6133,10 @@ packages: object.values: 1.1.5 resolve: 1.22.0 tsconfig-paths: 3.12.0 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color dev: true /eslint-plugin-no-use-extend-native/0.5.0: @@ -10983,6 +11287,40 @@ packages: yargs-parser: 20.2.9 dev: true + /ts-jest/27.1.3_u4suh3umvg724wu2nufptihvny: + resolution: {integrity: sha512-6Nlura7s6uM9BVUAoqLH7JHyMXjz8gluryjpPXxr3IxZdAXnU6FhjvVLHFtfd1vsE1p8zD1OJfskkc0jhTSnkA==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@types/jest': ^27.0.0 + babel-jest: '>=27.0.0 <28' + esbuild: ~0.14.0 + jest: ^27.0.0 + typescript: '>=3.8 <5.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@types/jest': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + dependencies: + '@types/jest': 27.4.1 + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + jest: 27.5.1 + jest-util: 27.5.1 + json5: 2.2.0 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.3.5 + typescript: 4.7.2 + yargs-parser: 20.2.9 + dev: true + /ts-node/10.8.0_lkv3kpbpgkvbusiaeijtbs5viu: resolution: {integrity: sha512-/fNd5Qh+zTt8Vt1KbYZjRHCE9sI5i7nqfD/dzBBRDeVXZXS6kToW6R7tTU6Nd4XavFs0mAVCg29Q//ML7WsZYA==} hasBin: true @@ -11055,6 +11393,16 @@ packages: typescript: 4.6.2 dev: true + /tsutils/3.21.0_typescript@4.7.2: + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + dependencies: + tslib: 1.14.1 + typescript: 4.7.2 + dev: true + /type-check/0.3.2: resolution: {integrity: sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=} engines: {node: '>= 0.8.0'}