From a722840c59638c159a911f1fd82c8638ef94d9eb Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 17 May 2022 18:50:05 +1000 Subject: [PATCH 1/3] feat: added async getSession method --- src/GoTrueClient.ts | 57 +++++++++++++++++++++++++- src/lib/helpers.ts | 22 ++++++++++ test/GoTrueClient.test.ts | 84 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index bdb1c3c8a..b2c89eb01 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -7,6 +7,7 @@ import { removeItemAsync, getItemSynchronously, getItemAsync, + Deferred, } from './lib/helpers' import { GOTRUE_URL, @@ -65,7 +66,11 @@ export default class GoTrueClient { protected multiTab: boolean protected stateChangeEmitters: Map = new Map() protected refreshTokenTimer?: ReturnType - protected networkRetries: number = 0 + protected networkRetries = 0 + protected refreshingDeferred: Deferred<{ + data: Session + error: null + }> | null = null /** * Create a new client for use in the browser. @@ -330,6 +335,39 @@ export default class GoTrueClient { return this.currentSession } + async getSession(): Promise< + | { + session: Session + error: null + } + | { + session: null + error: ApiError + } + | { + session: null + error: null + } + > { + if (!this.currentSession) { + return { session: null, error: null } + } + + const hasExpired = this.currentSession.expires_at + ? this.currentSession.expires_at <= Date.now() / 1000 + : false + if (!hasExpired) { + return { session: this.currentSession, error: null } + } + + const { data: session, error } = await this.refreshSession() + if (error) { + return { session: null, error } + } + + return { session, error: null } + } + /** * Force refreshes the session including the user data in case it was updated in a different session. */ @@ -681,6 +719,16 @@ export default class GoTrueClient { private async _callRefreshToken(refresh_token = this.currentSession?.refresh_token) { try { + // refreshing is already in progress + if (this.refreshingDeferred) { + return await this.refreshingDeferred.promise + } + + this.refreshingDeferred = new Deferred<{ + data: Session + error: null + }>() + if (!refresh_token) { throw new Error('No current session.') } @@ -692,7 +740,12 @@ export default class GoTrueClient { this._notifyAllSubscribers('TOKEN_REFRESHED') this._notifyAllSubscribers('SIGNED_IN') - return { data, error: null } + const result = { data, error: null } + + this.refreshingDeferred.resolve(result) + this.refreshingDeferred = null + + return result } catch (e) { return { data: null, error: e as ApiError } } diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 85c7486b8..856caf5c2 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -75,3 +75,25 @@ export const getItemSynchronously = (storage: SupportedStorage, key: string): an export const removeItemAsync = async (storage: SupportedStorage, key: string): Promise => { isBrowser() && (await storage?.removeItem(key)) } + +/** + * A deferred represents some asynchronous work that is not yet finished, which + * may or may not culminate in a value. + * Taken from: https://github.com/mike-north/types/blob/master/src/async.ts + */ +export class Deferred { + public static promiseConstructor: PromiseConstructor = Promise + + public readonly promise!: PromiseLike + + public readonly resolve!: (value?: T | PromiseLike) => void + + public readonly reject!: (reason?: any) => any + + public constructor() { + (this as any).promise = new Deferred.promiseConstructor((res, rej) => { + (this as any).resolve = res + ;(this as any).reject = rej + }) + } +} diff --git a/test/GoTrueClient.test.ts b/test/GoTrueClient.test.ts index b7fd7c723..745f41b63 100644 --- a/test/GoTrueClient.test.ts +++ b/test/GoTrueClient.test.ts @@ -10,9 +10,12 @@ import { import { mockUserCredentials } from './lib/utils' describe('GoTrueClient', () => { + const refreshAccessTokenSpy = jest.spyOn(authWithSession.api, 'refreshAccessToken') + afterEach(async () => { await auth.signOut() await authWithSession.signOut() + refreshAccessTokenSpy.mockClear() }) describe('Sessions', () => { @@ -51,6 +54,87 @@ describe('GoTrueClient', () => { expect(userSession).toHaveProperty('user') }) + test('getSession() should return the currentUser session', async () => { + const { email, password } = mockUserCredentials() + + const { error, session } = await authWithSession.signUp({ + email, + password, + }) + + expect(error).toBeNull() + expect(session).not.toBeNull() + + const { session: userSession, error: userError } = await authWithSession.getSession() + + expect(userError).toBeNull() + expect(userSession).not.toBeNull() + expect(userSession).toHaveProperty('access_token') + }) + + test('getSession() should refresh the session', async () => { + const { email, password } = mockUserCredentials() + + const { error, session } = await authWithSession.signUp({ + email, + password, + }) + + expect(error).toBeNull() + expect(session).not.toBeNull() + + const expired = new Date() + expired.setMinutes(expired.getMinutes() - 1) + const expiredSeconds = Math.floor(expired.getTime() / 1000) + + // @ts-expect-error 'Allow access to protected currentSession' + authWithSession.currentSession = { + // @ts-expect-error 'Allow access to protected currentSession' + ...authWithSession.currentSession, + expires_at: expiredSeconds, + } + + const { session: userSession, error: userError } = await authWithSession.getSession() + + expect(userError).toBeNull() + expect(userSession).not.toBeNull() + expect(userSession).toHaveProperty('access_token') + expect(refreshAccessTokenSpy).toBeCalledTimes(1) + + // @kangmingtay Looks like this fails due to the 10 second reuse interval + // returning back the same session. It works with a long timeout before getSession(). + // Do we want the reuse interval to apply for the initial login session? + // expect(session!.access_token).not.toEqual(userSession!.access_token) + }) + + test('refresh should only happen once', async () => { + const { email, password } = mockUserCredentials() + + const { error, session } = await authWithSession.signUp({ + email, + password, + }) + + expect(error).toBeNull() + expect(session).not.toBeNull() + + const [{ data: data1, error: error1 }, { data: data2, error: error2 }] = await Promise.all([ + authWithSession.refreshSession(), + authWithSession.refreshSession(), + ]) + + expect(error1).toBeNull() + expect(error2).toBeNull() + expect(data1).toHaveProperty('access_token') + expect(data2).toHaveProperty('access_token') + + // if both have the same access token, we can assume that they are + // the result of the same refresh + expect(data1!.access_token).toEqual(data2!.access_token) + + expect(refreshAccessTokenSpy).toBeCalledTimes(1) + }) + test('getSessionFromUrl() can only be called from a browser', async () => { const { error, data } = await authWithSession.getSessionFromUrl() From 6ed6fa967a59852ff5e0229caa1ad9b84808c718 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 31 May 2022 22:44:31 +1000 Subject: [PATCH 2/3] chore: document getSession method --- src/GoTrueClient.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index b2c89eb01..0b2a6f38a 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -335,6 +335,9 @@ export default class GoTrueClient { return this.currentSession } + /** + * Returns the session data, refreshing it if necessary. + */ async getSession(): Promise< | { session: Session From d32ae77401998c95e1dd5ec139161a0b5417e43a Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 1 Jun 2022 15:10:03 +1000 Subject: [PATCH 3/3] feat: add getUser method --- src/GoTrueClient.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index 0b2a6f38a..584d73640 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -323,6 +323,7 @@ export default class GoTrueClient { * Inside a browser context, `user()` will return the user data, if there is a logged in user. * * For server-side management, you can get a user through `auth.api.getUserByCookie()` + * @deprecated use `getUser()` instead */ user(): User | null { return this.currentUser @@ -330,6 +331,7 @@ export default class GoTrueClient { /** * Returns the session data, if there is an active session. + * @deprecated use `getSession()` instead */ session(): Session | null { return this.currentSession @@ -371,6 +373,35 @@ export default class GoTrueClient { return { session, error: null } } + /** + * Returns the user data, refreshing the session if necessary. + */ + async getUser(): Promise< + | { + user: User + error: null + } + | { + user: null + error: ApiError + } + | { + user: null + error: null + } + > { + const { session, error } = await this.getSession() + if (error) { + return { user: null, error } + } + + if (!session) { + return { user: null, error: null } + } + + return { user: session.user, error: null } + } + /** * Force refreshes the session including the user data in case it was updated in a different session. */