diff --git a/packages/browser/jest.config.js b/packages/browser/jest.config.js index 5ed1e62a4..3253f062c 100644 --- a/packages/browser/jest.config.js +++ b/packages/browser/jest.config.js @@ -2,7 +2,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!/node_modules/'], coverageReporters: ['text-summary', 'lcov'], moduleFileExtensions: ['ts', 'js'], - setupFilesAfterEnv: ['./jest.setup.js'], + setupFilesAfterEnv: ['./jest.setup.js', 'jest-matcher-specific-error'], testEnvironment: 'jsdom', testPathIgnorePatterns: ['lib'], testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|js)$', diff --git a/packages/browser/package.json b/packages/browser/package.json index 23301555d..88bb19a01 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -20,15 +20,19 @@ }, "dependencies": { "@logto/js": "^0.0.1", - "@silverhand/essentials": "^1.1.5" + "@silverhand/essentials": "^1.1.5", + "lodash.get": "^4.4.2", + "superstruct": "^0.15.3" }, "devDependencies": { "@silverhand/eslint-config": "^0.7.0", "@silverhand/ts-config": "^0.7.0", + "@types/lodash.get": "^4.4.6", "codecov": "^3.8.3", "eslint": "^8.8.0", "jest": "^27.0.6", "jest-location-mock": "^1.0.9", + "jest-matcher-specific-error": "^1.0.0", "lint-staged": "^12.3.3", "prettier": "^2.3.2", "text-encoder": "^0.0.4", diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts new file mode 100644 index 000000000..50e8190ad --- /dev/null +++ b/packages/browser/src/errors.ts @@ -0,0 +1,33 @@ +import { NormalizeKeyPaths } from '@silverhand/essentials'; +import get from 'lodash.get'; + +const logtoClientErrorCodes = Object.freeze({ + sign_in_session: { + invalid: 'Invalid sign-in session.', + }, +}); + +export type LogtoClientErrorCode = NormalizeKeyPaths; + +const getMessageByErrorCode = (errorCode: LogtoClientErrorCode): string => { + // TODO: linear issue LOG-1419 + // 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/browser/src/index.test.ts b/packages/browser/src/index.test.ts index 7579fd8cf..60eddae9a 100644 --- a/packages/browser/src/index.test.ts +++ b/packages/browser/src/index.test.ts @@ -1,30 +1,100 @@ -import LogtoClient from './index'; +import { Optional } from '@silverhand/essentials'; + +import LogtoClient, { LogtoClientError, LogtoSignInSessionItem } from '.'; const clientId = 'client_id_value'; const endpoint = 'https://logto.dev'; const authorizationEndpoint = `${endpoint}/oidc/auth`; +const mockedCodeVerifier = 'code_verifier_value'; +const mockedState = 'state_value'; +const mockedSignInUri = `${authorizationEndpoint}?foo=bar`; const redirectUri = 'http://localhost:3000/callback'; const requester = jest.fn(); -const signInUri = `${authorizationEndpoint}?foo=bar`; - -jest.mock('@logto/js', () => { - return { - ...jest.requireActual('@logto/js'), - fetchOidcConfig: async () => ({ authorizationEndpoint }), - generateSignInUri: () => signInUri, - }; -}); + +jest.mock('@logto/js', () => ({ + ...jest.requireActual('@logto/js'), + fetchOidcConfig: async () => ({ authorizationEndpoint }), + generateCodeChallenge: jest.fn().mockResolvedValue('code_challenge_value'), + generateCodeVerifier: jest.fn(() => mockedCodeVerifier), + generateSignInUri: jest.fn(() => mockedSignInUri), + generateState: jest.fn(() => mockedState), +})); + +/** + * Make LogtoClient.signInSession accessible for test + */ +class LogtoClientSignInSessionAccessor extends LogtoClient { + public getSignInSessionItem(): Optional { + return this.signInSession; + } + + public setSignInSessionItem(item: Optional) { + this.signInSession = item; + } +} describe('LogtoClient', () => { test('constructor', () => { expect(() => new LogtoClient({ endpoint, clientId, requester })).not.toThrow(); }); + describe('signInSession', () => { + test('getter should throw LogtoClientError when signInSession does not contain the required property', () => { + const signInSessionAccessor = new LogtoClientSignInSessionAccessor({ + endpoint, + clientId, + requester, + }); + + // @ts-expect-error + 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') + ) + ); + }); + + test('should be able to set and get the undefined item (for clearing sign-in session)', () => { + const signInSessionAccessor = new LogtoClientSignInSessionAccessor({ + endpoint, + clientId, + requester, + }); + + // @ts-expect-error + signInSessionAccessor.setSignInSessionItem(); + expect(signInSessionAccessor.getSignInSessionItem()).toBeUndefined(); + }); + + test('should be able to set and get the correct item', () => { + const signInSessionAccessor = new LogtoClientSignInSessionAccessor({ + endpoint, + clientId, + requester, + }); + + const logtoSignInSessionItem: LogtoSignInSessionItem = { + redirectUri, + codeVerifier: mockedCodeVerifier, + state: mockedState, + }; + + signInSessionAccessor.setSignInSessionItem(logtoSignInSessionItem); + expect(signInSessionAccessor.getSignInSessionItem()).toEqual(logtoSignInSessionItem); + }); + }); + describe('signIn', () => { test('window.location should be correct signInUri', async () => { const logtoClient = new LogtoClient({ endpoint, clientId, requester }); await logtoClient.signIn(redirectUri); - expect(window.location.toString()).toEqual(signInUri); + expect(window.location.toString()).toEqual(mockedSignInUri); }); }); }); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 86cedfd6e..f09061302 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -8,6 +8,10 @@ import { Requester, withReservedScopes, } from '@logto/js'; +import { Optional } from '@silverhand/essentials'; +import { assert, Infer, string, type } from 'superstruct'; + +import { LogtoClientError } from './errors'; const discoveryPath = '/oidc/.well-known/openid-configuration'; const logtoStorageItemKeyPrefix = `logto`; @@ -29,21 +33,59 @@ export type AccessToken = { expiresAt: number; // Unix Timestamp in seconds }; +export const LogtoSignInSessionItemSchema = type({ + redirectUri: string(), + codeVerifier: string(), + state: string(), +}); + +export type LogtoSignInSessionItem = Infer; + export default class LogtoClient { protected accessTokenMap = new Map(); protected refreshToken?: string; protected idToken?: string; protected logtoConfig: LogtoConfig; protected oidcConfig?: OidcConfigResponse; + protected logtoSignInSessionKey: string; constructor(logtoConfig: LogtoConfig) { this.logtoConfig = logtoConfig; + this.logtoSignInSessionKey = getLogtoKey(logtoConfig.clientId); } public get isAuthenticated() { return Boolean(this.idToken); } + protected get signInSession(): Optional { + const jsonItem = sessionStorage.getItem(this.logtoSignInSessionKey); + + if (!jsonItem) { + return undefined; + } + + 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: Optional) { + if (!logtoSignInSessionItem) { + sessionStorage.removeItem(this.logtoSignInSessionKey); + + return; + } + + const jsonItem = JSON.stringify(logtoSignInSessionItem); + sessionStorage.setItem(this.logtoSignInSessionKey, jsonItem); + } + public async signIn(redirectUri: string) { const { clientId, resources, scopes: customScopes } = this.logtoConfig; const oidcConfig = await this.getOidcConfig(); @@ -64,7 +106,7 @@ export default class LogtoClient { resources, }); - // TODO: save redirectUri, codeVerifier and state + this.signInSession = { redirectUri, codeVerifier, state }; window.location.assign(signInUri); } @@ -78,3 +120,5 @@ export default class LogtoClient { return this.oidcConfig; } } + +export * from './errors'; diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json index 93501fec8..61740a924 100644 --- a/packages/browser/tsconfig.json +++ b/packages/browser/tsconfig.json @@ -3,6 +3,11 @@ "compilerOptions": { "outDir": "lib", "target": "es5", + "types": [ + "node", + "jest", + "jest-matcher-specific-error" + ] }, "include": [ "src" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee689ad39..ea2b3077b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,12 +24,16 @@ importers: '@silverhand/eslint-config': ^0.7.0 '@silverhand/essentials': ^1.1.5 '@silverhand/ts-config': ^0.7.0 + '@types/lodash.get': ^4.4.6 codecov: ^3.8.3 eslint: ^8.8.0 jest: ^27.0.6 jest-location-mock: ^1.0.9 + jest-matcher-specific-error: ^1.0.0 lint-staged: ^12.3.3 + lodash.get: ^4.4.2 prettier: ^2.3.2 + superstruct: ^0.15.3 text-encoder: ^0.0.4 ts-jest: ^27.0.4 ts-loader: ^9.2.6 @@ -40,13 +44,17 @@ importers: dependencies: '@logto/js': link:../js '@silverhand/essentials': 1.1.5 + lodash.get: 4.4.2 + superstruct: 0.15.3 devDependencies: '@silverhand/eslint-config': 0.7.0_93c6ce1132bfea333665fcd5223669e0 '@silverhand/ts-config': 0.7.0_typescript@4.4.4 + '@types/lodash.get': 4.4.6 codecov: 3.8.3 eslint: 8.8.0 jest: 27.0.6 jest-location-mock: 1.0.9 + jest-matcher-specific-error: 1.0.0 lint-staged: 12.3.3 prettier: 2.3.2 text-encoder: 0.0.4