Skip to content

Commit

Permalink
feat(browser): sign-in session storage
Browse files Browse the repository at this point in the history
  • Loading branch information
IceHe committed Feb 15, 2022
1 parent 436fe7f commit de95f51
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 14 deletions.
2 changes: 1 addition & 1 deletion packages/browser/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module.exports = {
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!<rootDir>/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)$',
Expand Down
6 changes: 5 additions & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions packages/browser/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NormalizeKeyPaths } from '@silverhand/essentials';
import get from 'lodash.get';

const logtoClientErrorCodes = Object.freeze({
sign_in_session: {
not_found: 'Sign-in session not found.',
},
});

export type LogtoClientErrorCode = NormalizeKeyPaths<typeof logtoClientErrorCodes>;

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;
}
}
87 changes: 76 additions & 11 deletions packages/browser/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,95 @@
import LogtoClient from './index';
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(): LogtoSignInSessionItem {
return this.signInSession;
}

public setSignInSessionItem(item: LogtoSignInSessionItem) {
this.signInSession = item;
}
}

describe('LogtoClient', () => {
test('constructor', () => {
expect(() => new LogtoClient({ endpoint, clientId, requester })).not.toThrow();
});

describe('signInSession', () => {
test('getter should throw LogtoClientError when signInSession has not been set', () => {
const signInSessionAccessor = new LogtoClientSignInSessionAccessor({
endpoint,
clientId,
requester,
});

expect(() => signInSessionAccessor.getSignInSessionItem()).toMatchError(
new LogtoClientError('sign_in_session.not_found')
);
});

test('getter should throw StructError 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()).toThrow(
'At path: state -- Expected a string, but received: undefined'
);
});

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);
});
});
});
41 changes: 40 additions & 1 deletion packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
Requester,
withReservedScopes,
} from '@logto/js';
import { Optional } from '@silverhand/essentials';
import { create, Infer, string, type } from 'superstruct';

import { LogtoClientError } from './errors';

const discoveryPath = '/oidc/.well-known/openid-configuration';
const logtoStorageItemKeyPrefix = `logto`;
Expand All @@ -29,21 +33,50 @@ export type AccessToken = {
expiresAt: number; // Unix Timestamp in seconds
};

export const LogtoSignInSessionItemSchema = type({
redirectUri: string(),
codeVerifier: string(),
state: string(),
});

export type LogtoSignInSessionItem = Infer<typeof LogtoSignInSessionItemSchema>;

export default class LogtoClient {
protected accessTokenMap = new Map<string, AccessToken>();
protected refreshToken?: string;
protected idToken?: string;
protected logtoConfig: LogtoConfig;
protected oidcConfig?: OidcConfigResponse;
protected logtoSignInSessionKey: string;

private readonly _signInSession: Optional<LogtoSignInSessionItem>;

constructor(logtoConfig: LogtoConfig) {
this.logtoConfig = logtoConfig;
this.logtoSignInSessionKey = getLogtoKey(logtoConfig.clientId);
}

public get isAuthenticated() {
return Boolean(this.idToken);
}

protected get signInSession(): LogtoSignInSessionItem {
const jsonItem = sessionStorage.getItem(this.logtoSignInSessionKey);

if (!jsonItem) {
throw new LogtoClientError('sign_in_session.not_found');
}

const item: unknown = JSON.parse(jsonItem);

return create(item, LogtoSignInSessionItemSchema);
}

protected set signInSession(logtoSignInSessionItem: LogtoSignInSessionItem) {
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();
Expand All @@ -64,7 +97,7 @@ export default class LogtoClient {
resources,
});

// TODO: save redirectUri, codeVerifier and state
this.signInSession = { redirectUri, codeVerifier, state };
window.location.assign(signInUri);
}

Expand All @@ -77,4 +110,10 @@ export default class LogtoClient {

return this.oidcConfig;
}

protected clearSignInSession() {
sessionStorage.removeItem(this.logtoSignInSessionKey);
}
}

export * from './errors';
5 changes: 5 additions & 0 deletions packages/browser/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"compilerOptions": {
"outDir": "lib",
"target": "es5",
"types": [
"node",
"jest",
"jest-matcher-specific-error"
]
},
"include": [
"src"
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit de95f51

Please sign in to comment.