Skip to content

Commit

Permalink
feat(browser): reuse remote jwks and odic config (#231)
Browse files Browse the repository at this point in the history
  • Loading branch information
IceHe authored Mar 18, 2022
1 parent 3e25252 commit 1469bb1
Show file tree
Hide file tree
Showing 4 changed files with 85 additions and 20 deletions.
2 changes: 2 additions & 0 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@silverhand/essentials": "^1.1.6",
"jose": "^4.5.0",
"lodash.get": "^4.4.2",
"lodash.once": "^4.1.1",
"superstruct": "^0.15.3"
},
"devDependencies": {
Expand All @@ -36,6 +37,7 @@
"@silverhand/ts-config": "^0.9.1",
"@types/jest": "^27.4.0",
"@types/lodash.get": "^4.4.6",
"@types/lodash.once": "^4.1.6",
"eslint": "^8.9.0",
"jest": "^27.5.1",
"jest-location-mock": "^1.0.9",
Expand Down
52 changes: 48 additions & 4 deletions packages/browser/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,25 @@ const requester = jest.fn();
const failingRequester = jest.fn().mockRejectedValue(new Error('Failed!'));
const currentUnixTimeStamp = Date.now() / 1000;

jest.mock('@logto/js', () => ({
...jest.requireActual('@logto/js'),
fetchOidcConfig: jest.fn(async () => ({
const fetchOidcConfig = jest.fn(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});

return {
authorizationEndpoint,
userinfoEndpoint,
tokenEndpoint,
endSessionEndpoint,
revocationEndpoint,
jwksUri,
issuer,
})),
};
});

jest.mock('@logto/js', () => ({
...jest.requireActual('@logto/js'),
fetchOidcConfig: async () => fetchOidcConfig(),
decodeIdToken: jest.fn(() => ({
iss: 'issuer_value',
sub: 'subject_value',
Expand All @@ -64,6 +72,13 @@ jest.mock('@logto/js', () => ({
verifyIdToken: jest.fn(),
}));

const createRemoteJWKSet = jest.fn(async () => '');

jest.mock('jose', () => ({
...jest.requireActual('jose'),
createRemoteJWKSet: async () => createRemoteJWKSet(),
}));

/**
* Make LogtoClient.signInSession accessible for test
*/
Expand Down Expand Up @@ -144,6 +159,13 @@ describe('LogtoClient', () => {
sessionStorage.clear();
});

test('should reuse oidcConfig', async () => {
fetchOidcConfig.mockClear();
const logtoClient = new LogtoClient({ endpoint, clientId }, requester);
await Promise.all([logtoClient.signIn(redirectUri), logtoClient.signIn(redirectUri)]);
expect(fetchOidcConfig).toBeCalledTimes(1);
});

test('should redirect to signInUri just after calling signIn', async () => {
const logtoClient = new LogtoClient({ endpoint, clientId }, requester);
await logtoClient.signIn(redirectUri);
Expand Down Expand Up @@ -306,6 +328,28 @@ describe('LogtoClient', () => {
expect(accessTokenMap.delete).toBeCalledTimes(1);
});

test('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,
};
});
localStorage.setItem(idTokenStorageKey, 'id_token_value');
localStorage.setItem(refreshTokenStorageKey, 'refresh_token_value');

createRemoteJWKSet.mockClear();
const logtoClient = new LogtoClient({ endpoint, clientId }, requester);
await Promise.all([logtoClient.getAccessToken('a'), logtoClient.getAccessToken('b')]);
expect(createRemoteJWKSet).toBeCalledTimes(1);
});

afterAll(() => {
localStorage.removeItem(idTokenStorageKey);
localStorage.removeItem(refreshTokenStorageKey);
Expand Down
37 changes: 21 additions & 16 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
generateSignOutUri,
generateState,
IdTokenClaims,
OidcConfigResponse,
Requester,
revoke,
UserInfoResponse,
Expand All @@ -22,15 +21,16 @@ import {
} 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 {
buildAccessTokenKey,
getDiscoveryEndpoint,
buildIdTokenKey,
buildLogtoKey,
buildRefreshTokenKey,
getDiscoveryEndpoint,
} from './utils';

export type { IdTokenClaims, UserInfoResponse } from '@logto/js';
Expand Down Expand Up @@ -59,13 +59,14 @@ export const LogtoSignInSessionItemSchema = type({
export type LogtoSignInSessionItem = Infer<typeof LogtoSignInSessionItemSchema>;

export default class LogtoClient {
protected logtoConfig: LogtoConfig;
protected oidcConfig?: OidcConfigResponse;
protected readonly logtoConfig: LogtoConfig;
protected readonly getOidcConfig = once(this._getOidcConfig);
protected readonly getJwtVerifyGetKey = once(this._getJwtVerifyGetKey);

protected logtoStorageKey: string;
protected requester: Requester;
protected readonly logtoStorageKey: string;
protected readonly requester: Requester;

protected accessTokenMap = new Map<string, AccessToken>();
protected readonly accessTokenMap = new Map<string, AccessToken>();

private readonly getAccessTokenPromiseMap = new Map<string, Promise<string>>();
private _refreshToken: Nullable<string>;
Expand Down Expand Up @@ -332,22 +333,26 @@ export default class LogtoClient {
}
}

private async getOidcConfig(): Promise<OidcConfigResponse> {
if (!this.oidcConfig) {
const { endpoint } = this.logtoConfig;
const discoveryEndpoint = getDiscoveryEndpoint(endpoint);
this.oidcConfig = await fetchOidcConfig(discoveryEndpoint, this.requester);
}
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 this.oidcConfig;
return createRemoteJWKSet(new URL(jwksUri));
}

private async verifyIdToken(idToken: string) {
const { clientId } = this.logtoConfig;
const { issuer, jwksUri } = await this.getOidcConfig();
const { issuer } = await this.getOidcConfig();
const jwtVerifyGetKey = await this.getJwtVerifyGetKey();

try {
await verifyIdToken(idToken, clientId, issuer, createRemoteJWKSet(new URL(jwksUri)));
await verifyIdToken(idToken, clientId, issuer, jwtVerifyGetKey);
} catch (error: unknown) {
throw new LogtoClientError('invalid_id_token', error);
}
Expand Down
14 changes: 14 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 1469bb1

Please sign in to comment.