Skip to content

Commit

Permalink
feat(client): persist access token (#359)
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie authored Jul 20, 2022
1 parent f773ce0 commit 10fb181
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 31 deletions.
63 changes: 63 additions & 0 deletions packages/client/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
failingRequester,
createAdapters,
} from './mock';
import { buildAccessTokenKey } from './utils';

jest.mock('@logto/js', () => ({
...jest.requireActual('@logto/js'),
Expand Down Expand Up @@ -396,4 +397,66 @@ describe('LogtoClient', () => {
});
});
});

describe('getAccessToken when persistAccessToken is true', () => {
it('should load access token', async () => {
const logtoClient = createClient(
undefined,
new MockedStorage({
idToken,
accessToken: JSON.stringify({
[buildAccessTokenKey()]: {
token: accessToken,
scope: '',
expiresAt: Date.now() + 1000,
},
}),
}),
true
);

await expect(logtoClient.getAccessToken()).resolves.toEqual(accessToken);
});

it('should not load access token when storage value is invalid', async () => {
const logtoClient = createClient(
undefined,
new MockedStorage({
idToken,
accessToken: JSON.stringify({
[buildAccessTokenKey()]: {
token1: accessToken,
scope: '',
expiresAt: Date.now() + 1000,
},
}),
}),
true
);

await expect(logtoClient.getAccessToken()).rejects.toThrow();
});

it('should not save and reload access token during sign in flow', async () => {
const storage = new MockedStorage();

requester.mockClear().mockImplementation(async () => ({
accessToken,
refreshToken,
idToken,
scope: 'read register manage',
expiresIn: 3600,
}));
const logtoClient = createClient(undefined, storage, true);
await logtoClient.signIn(redirectUri);
const code = `code_value`;
const callbackUri = `${redirectUri}?code=${code}&state=${mockedState}&codeVerifier=${mockedCodeVerifier}`;
await logtoClient.handleSignInCallback(callbackUri);

storage.removeItem('refreshToken');
const anotherClient = createClient(undefined, storage, true);

await expect(anotherClient.getAccessToken()).resolves.not.toThrow();
});
});
});
72 changes: 48 additions & 24 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,40 +16,25 @@ import {
import { Nullable } from '@silverhand/essentials';
import { createRemoteJWKSet } from 'jose';
import once from 'lodash.once';
import { assert, Infer, string, type } from 'superstruct';
import { assert } from 'superstruct';

import { ClientAdapter } from './adapter';
import { LogtoClientError } from './errors';
import {
AccessToken,
LogtoAccessTokenMapSchema,
LogtoConfig,
LogtoSignInSessionItem,
LogtoSignInSessionItemSchema,
} from './types';
import { buildAccessTokenKey, getDiscoveryEndpoint } from './utils';

export type { IdTokenClaims, LogtoErrorCode } from '@logto/js';
export { LogtoError, OidcError, Prompt, LogtoRequestError } from '@logto/js';
export * from './errors';
export type { Storage, StorageKey, ClientAdapter } from './adapter';
export { createRequester } from './utils';

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<typeof LogtoSignInSessionItemSchema>;
export * from './types';

export default class LogtoClient {
protected readonly logtoConfig: LogtoConfig;
Expand All @@ -71,6 +56,10 @@ export default class LogtoClient {
};
this.adapter = adapter;
this._idToken = this.adapter.storage.getItem('idToken');

if (this.logtoConfig.persistAccessToken) {
this.loadAccessTokenMap();
}
}

public get isAuthenticated() {
Expand Down Expand Up @@ -307,6 +296,7 @@ export default class LogtoClient {
scope,
expiresAt: Math.round(Date.now() / 1000) + expiresIn,
});
this.saveAccessTokenMap();

this.refreshToken = refreshToken;

Expand Down Expand Up @@ -360,5 +350,39 @@ export default class LogtoClient {
const accessTokenKey = buildAccessTokenKey();
const expiresAt = Date.now() / 1000 + expiresIn;
this.accessTokenMap.set(accessTokenKey, { token: accessToken, scope, expiresAt });
this.saveAccessTokenMap();
}

private saveAccessTokenMap() {
if (!this.logtoConfig.persistAccessToken) {
return;
}

const data: Record<string, AccessToken> = {};

for (const [key, accessToken] of this.accessTokenMap.entries()) {
// eslint-disable-next-line @silverhand/fp/no-mutation
data[key] = accessToken;
}

this.adapter.storage.setItem('accessToken', JSON.stringify(data));
}

private loadAccessTokenMap() {
const raw = this.adapter.storage.getItem('accessToken');

if (!raw) {
return;
}

try {
const json: unknown = JSON.parse(raw);
assert(json, LogtoAccessTokenMapSchema);
this.accessTokenMap.clear();

for (const [key, accessToken] of Object.entries(json)) {
this.accessTokenMap.set(key, accessToken);
}
} catch {}
}
}
8 changes: 6 additions & 2 deletions packages/client/src/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,13 @@ export const createAdapters = () => ({
generateState,
});

export const createClient = (prompt?: Prompt, storage = new MockedStorage()) =>
export const createClient = (
prompt?: Prompt,
storage = new MockedStorage(),
persistAccessToken?: boolean
) =>
new LogtoClient(
{ endpoint, appId, prompt },
{ endpoint, appId, prompt, persistAccessToken },
{
...createAdapters(),
storage,
Expand Down
29 changes: 29 additions & 0 deletions packages/client/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Prompt } from '@logto/js';
import { Infer, number, record, string, type } from 'superstruct';

export type LogtoConfig = {
endpoint: string;
appId: string;
scopes?: string[];
resources?: string[];
prompt?: Prompt;
persistAccessToken?: boolean;
};

export const AccessTokenSchema = type({
token: string(),
scope: string(),
expiresAt: number(),
});

export type AccessToken = Infer<typeof AccessTokenSchema>;

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

export const LogtoAccessTokenMapSchema = record(string(), AccessTokenSchema);

export type LogtoSignInSessionItem = Infer<typeof LogtoSignInSessionItemSchema>;
16 changes: 11 additions & 5 deletions packages/next/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,18 @@ export default class LogtoClient {
private createNodeClient(request: NextApiRequest) {
this.storage = new NextStorage(request);

return new NodeClient(this.config, {
storage: this.storage,
navigate: (url) => {
this.navigateUrl = url;
return new NodeClient(
{
...this.config,
persistAccessToken: this.config.persistAccessToken ?? true,
},
});
{
storage: this.storage,
navigate: (url) => {
this.navigateUrl = url;
},
}
);
}

private withIronSession(handler: NextApiHandler) {
Expand Down

0 comments on commit 10fb181

Please sign in to comment.