Skip to content

Commit 9870fa5

Browse files
authored
Merge pull request #285 from supabase/feat/async-getSession
feat: added async getSession/getUser method
2 parents 5a3a039 + d32ae77 commit 9870fa5

File tree

3 files changed

+195
-2
lines changed

3 files changed

+195
-2
lines changed

src/GoTrueClient.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
removeItemAsync,
88
getItemSynchronously,
99
getItemAsync,
10+
Deferred,
1011
} from './lib/helpers'
1112
import {
1213
GOTRUE_URL,
@@ -65,7 +66,11 @@ export default class GoTrueClient {
6566
protected multiTab: boolean
6667
protected stateChangeEmitters: Map<string, Subscription> = new Map()
6768
protected refreshTokenTimer?: ReturnType<typeof setTimeout>
68-
protected networkRetries: number = 0
69+
protected networkRetries = 0
70+
protected refreshingDeferred: Deferred<{
71+
data: Session
72+
error: null
73+
}> | null = null
6974

7075
/**
7176
* Create a new client for use in the browser.
@@ -318,18 +323,85 @@ export default class GoTrueClient {
318323
* Inside a browser context, `user()` will return the user data, if there is a logged in user.
319324
*
320325
* For server-side management, you can get a user through `auth.api.getUserByCookie()`
326+
* @deprecated use `getUser()` instead
321327
*/
322328
user(): User | null {
323329
return this.currentUser
324330
}
325331

326332
/**
327333
* Returns the session data, if there is an active session.
334+
* @deprecated use `getSession()` instead
328335
*/
329336
session(): Session | null {
330337
return this.currentSession
331338
}
332339

340+
/**
341+
* Returns the session data, refreshing it if necessary.
342+
*/
343+
async getSession(): Promise<
344+
| {
345+
session: Session
346+
error: null
347+
}
348+
| {
349+
session: null
350+
error: ApiError
351+
}
352+
| {
353+
session: null
354+
error: null
355+
}
356+
> {
357+
if (!this.currentSession) {
358+
return { session: null, error: null }
359+
}
360+
361+
const hasExpired = this.currentSession.expires_at
362+
? this.currentSession.expires_at <= Date.now() / 1000
363+
: false
364+
if (!hasExpired) {
365+
return { session: this.currentSession, error: null }
366+
}
367+
368+
const { data: session, error } = await this.refreshSession()
369+
if (error) {
370+
return { session: null, error }
371+
}
372+
373+
return { session, error: null }
374+
}
375+
376+
/**
377+
* Returns the user data, refreshing the session if necessary.
378+
*/
379+
async getUser(): Promise<
380+
| {
381+
user: User
382+
error: null
383+
}
384+
| {
385+
user: null
386+
error: ApiError
387+
}
388+
| {
389+
user: null
390+
error: null
391+
}
392+
> {
393+
const { session, error } = await this.getSession()
394+
if (error) {
395+
return { user: null, error }
396+
}
397+
398+
if (!session) {
399+
return { user: null, error: null }
400+
}
401+
402+
return { user: session.user, error: null }
403+
}
404+
333405
/**
334406
* Force refreshes the session including the user data in case it was updated in a different session.
335407
*/
@@ -681,6 +753,16 @@ export default class GoTrueClient {
681753

682754
private async _callRefreshToken(refresh_token = this.currentSession?.refresh_token) {
683755
try {
756+
// refreshing is already in progress
757+
if (this.refreshingDeferred) {
758+
return await this.refreshingDeferred.promise
759+
}
760+
761+
this.refreshingDeferred = new Deferred<{
762+
data: Session
763+
error: null
764+
}>()
765+
684766
if (!refresh_token) {
685767
throw new Error('No current session.')
686768
}
@@ -692,7 +774,12 @@ export default class GoTrueClient {
692774
this._notifyAllSubscribers('TOKEN_REFRESHED')
693775
this._notifyAllSubscribers('SIGNED_IN')
694776

695-
return { data, error: null }
777+
const result = { data, error: null }
778+
779+
this.refreshingDeferred.resolve(result)
780+
this.refreshingDeferred = null
781+
782+
return result
696783
} catch (e) {
697784
return { data: null, error: e as ApiError }
698785
}

src/lib/helpers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,25 @@ export const getItemSynchronously = (storage: SupportedStorage, key: string): an
7575
export const removeItemAsync = async (storage: SupportedStorage, key: string): Promise<void> => {
7676
isBrowser() && (await storage?.removeItem(key))
7777
}
78+
79+
/**
80+
* A deferred represents some asynchronous work that is not yet finished, which
81+
* may or may not culminate in a value.
82+
* Taken from: https://github.com/mike-north/types/blob/master/src/async.ts
83+
*/
84+
export class Deferred<T = any> {
85+
public static promiseConstructor: PromiseConstructor = Promise
86+
87+
public readonly promise!: PromiseLike<T>
88+
89+
public readonly resolve!: (value?: T | PromiseLike<T>) => void
90+
91+
public readonly reject!: (reason?: any) => any
92+
93+
public constructor() {
94+
(this as any).promise = new Deferred.promiseConstructor((res, rej) => {
95+
(this as any).resolve = res
96+
;(this as any).reject = rej
97+
})
98+
}
99+
}

test/GoTrueClient.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import {
1010
import { mockUserCredentials } from './lib/utils'
1111

1212
describe('GoTrueClient', () => {
13+
const refreshAccessTokenSpy = jest.spyOn(authWithSession.api, 'refreshAccessToken')
14+
1315
afterEach(async () => {
1416
await auth.signOut()
1517
await authWithSession.signOut()
18+
refreshAccessTokenSpy.mockClear()
1619
})
1720

1821
describe('Sessions', () => {
@@ -51,6 +54,87 @@ describe('GoTrueClient', () => {
5154
expect(userSession).toHaveProperty('user')
5255
})
5356

57+
test('getSession() should return the currentUser session', async () => {
58+
const { email, password } = mockUserCredentials()
59+
60+
const { error, session } = await authWithSession.signUp({
61+
email,
62+
password,
63+
})
64+
65+
expect(error).toBeNull()
66+
expect(session).not.toBeNull()
67+
68+
const { session: userSession, error: userError } = await authWithSession.getSession()
69+
70+
expect(userError).toBeNull()
71+
expect(userSession).not.toBeNull()
72+
expect(userSession).toHaveProperty('access_token')
73+
})
74+
75+
test('getSession() should refresh the session', async () => {
76+
const { email, password } = mockUserCredentials()
77+
78+
const { error, session } = await authWithSession.signUp({
79+
email,
80+
password,
81+
})
82+
83+
expect(error).toBeNull()
84+
expect(session).not.toBeNull()
85+
86+
const expired = new Date()
87+
expired.setMinutes(expired.getMinutes() - 1)
88+
const expiredSeconds = Math.floor(expired.getTime() / 1000)
89+
90+
// @ts-expect-error 'Allow access to protected currentSession'
91+
authWithSession.currentSession = {
92+
// @ts-expect-error 'Allow access to protected currentSession'
93+
...authWithSession.currentSession,
94+
expires_at: expiredSeconds,
95+
}
96+
97+
const { session: userSession, error: userError } = await authWithSession.getSession()
98+
99+
expect(userError).toBeNull()
100+
expect(userSession).not.toBeNull()
101+
expect(userSession).toHaveProperty('access_token')
102+
expect(refreshAccessTokenSpy).toBeCalledTimes(1)
103+
104+
// @kangmingtay Looks like this fails due to the 10 second reuse interval
105+
// returning back the same session. It works with a long timeout before getSession().
106+
// Do we want the reuse interval to apply for the initial login session?
107+
// expect(session!.access_token).not.toEqual(userSession!.access_token)
108+
})
109+
110+
test('refresh should only happen once', async () => {
111+
const { email, password } = mockUserCredentials()
112+
113+
const { error, session } = await authWithSession.signUp({
114+
email,
115+
password,
116+
})
117+
118+
expect(error).toBeNull()
119+
expect(session).not.toBeNull()
120+
121+
const [{ data: data1, error: error1 }, { data: data2, error: error2 }] = await Promise.all([
122+
authWithSession.refreshSession(),
123+
authWithSession.refreshSession(),
124+
])
125+
126+
expect(error1).toBeNull()
127+
expect(error2).toBeNull()
128+
expect(data1).toHaveProperty('access_token')
129+
expect(data2).toHaveProperty('access_token')
130+
131+
// if both have the same access token, we can assume that they are
132+
// the result of the same refresh
133+
expect(data1!.access_token).toEqual(data2!.access_token)
134+
135+
expect(refreshAccessTokenSpy).toBeCalledTimes(1)
136+
})
137+
54138
test('getSessionFromUrl() can only be called from a browser', async () => {
55139
const { error, data } = await authWithSession.getSessionFromUrl()
56140

0 commit comments

Comments
 (0)