diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index ff47c98db..e128a6fb3 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -26,6 +26,7 @@ import { failingRequester, createAdapters, } from './mock'; +import { buildAccessTokenKey } from './utils'; jest.mock('@logto/js', () => ({ ...jest.requireActual('@logto/js'), @@ -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(); + }); + }); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 726ca967b..ea75d5722 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -16,10 +16,17 @@ 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'; @@ -27,29 +34,7 @@ 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; +export * from './types'; export default class LogtoClient { protected readonly logtoConfig: LogtoConfig; @@ -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() { @@ -307,6 +296,7 @@ export default class LogtoClient { scope, expiresAt: Math.round(Date.now() / 1000) + expiresIn, }); + this.saveAccessTokenMap(); this.refreshToken = refreshToken; @@ -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 = {}; + + 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 {} } } diff --git a/packages/client/src/mock.ts b/packages/client/src/mock.ts index f5255bd85..33981360f 100644 --- a/packages/client/src/mock.ts +++ b/packages/client/src/mock.ts @@ -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, diff --git a/packages/client/src/types/index.ts b/packages/client/src/types/index.ts new file mode 100644 index 000000000..d57cba8d5 --- /dev/null +++ b/packages/client/src/types/index.ts @@ -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; + +export const LogtoSignInSessionItemSchema = type({ + redirectUri: string(), + codeVerifier: string(), + state: string(), +}); + +export const LogtoAccessTokenMapSchema = record(string(), AccessTokenSchema); + +export type LogtoSignInSessionItem = Infer; diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 52aa6243e..a26e5be69 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -35,12 +35,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: true, }, - }); + { + storage: this.storage, + navigate: (url) => { + this.navigateUrl = url; + }, + } + ); } private withIronSession(handler: NextApiHandler) {