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 14, 2022
1 parent 64e7434 commit 4a3a5fa
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 13 deletions.
5 changes: 4 additions & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@
},
"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",
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;
}
}
59 changes: 48 additions & 11 deletions packages/browser/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,67 @@
import LogtoClient from './index';
import { assert } from 'superstruct';

import LogtoClient, {
getLogtoKey,
LogtoSignInSessionItem,
LogtoSignInSessionItemSchema,
} from './index';

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),
}));

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

describe('signIn', () => {
test('sessionStorage should contain correct properties', async () => {
const logtoClient = new LogtoClient({ endpoint, clientId, requester });
const originalRedirectUri = 'http://localhost:3000/callback';
await logtoClient.signIn(originalRedirectUri);

const jsonItem = sessionStorage.getItem(getLogtoKey(clientId));
expect(jsonItem).not.toBeNull();

if (!jsonItem) {
return;
}

const parseJsonItem = (): unknown => JSON.parse(jsonItem);
expect(parseJsonItem).not.toThrow();

const item: unknown = parseJsonItem();
expect(() => {
assert(item, LogtoSignInSessionItemSchema);
}).not.toThrow();

const logtoSignInSessionItem = item as LogtoSignInSessionItem;
const { redirectUri, codeVerifier, state } = logtoSignInSessionItem;

expect(redirectUri).toEqual(originalRedirectUri);
expect(codeVerifier).toEqual(mockedCodeVerifier);
expect(state).toEqual(mockedState);
});

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);
});
});
});
35 changes: 34 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,17 +33,46 @@ 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;

private readonly _signInSession: Optional<LogtoSignInSessionItem>;

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

protected get signInSession(): LogtoSignInSessionItem {
const logtoKey = getLogtoKey(this.logtoConfig.clientId);
const jsonItem = sessionStorage.getItem(logtoKey);

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 logtoKey = getLogtoKey(this.logtoConfig.clientId);
const jsonItem = JSON.stringify(logtoSignInSessionItem);
sessionStorage.setItem(logtoKey, jsonItem);
}

public async signIn(redirectUri: string) {
const { clientId, resources, scopes: customScopes } = this.logtoConfig;
const oidcConfig = await this.getOidcConfig();
Expand All @@ -60,7 +93,7 @@ export default class LogtoClient {
resources,
});

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

Expand Down
6 changes: 6 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 4a3a5fa

Please sign in to comment.