Skip to content

Commit c5dcf61

Browse files
committed
fix(realtime): eliminate race condition in custom JWT token authentication
1 parent 26a134d commit c5dcf61

File tree

6 files changed

+96
-43
lines changed

6 files changed

+96
-43
lines changed

packages/core/realtime-js/src/RealtimeClient.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,13 @@ export default class RealtimeClient {
190190
}
191191

192192
this._setConnectionState('connecting')
193-
this._setAuthSafely('connect')
193+
194+
// Trigger auth if needed and not already in progress
195+
// This ensures auth is called for standalone RealtimeClient usage
196+
// while avoiding race conditions with SupabaseClient's immediate setAuth call
197+
if (this.accessToken && !this._authPromise) {
198+
this._setAuthSafely('connect')
199+
}
194200

195201
// Establish WebSocket connection
196202
if (this.transport) {
@@ -257,11 +263,13 @@ export default class RealtimeClient {
257263
this._setConnectionState('disconnected')
258264
}
259265

260-
// Close the WebSocket connection
261-
if (code) {
262-
this.conn.close(code, reason ?? '')
263-
} else {
264-
this.conn.close()
266+
// Close the WebSocket connection if close method exists
267+
if (typeof this.conn.close === 'function') {
268+
if (code) {
269+
this.conn.close(code, reason ?? '')
270+
} else {
271+
this.conn.close()
272+
}
265273
}
266274

267275
this._teardownConnection()
@@ -620,7 +628,23 @@ export default class RealtimeClient {
620628
private _onConnOpen() {
621629
this._setConnectionState('connected')
622630
this.log('transport', `connected to ${this.endpointURL()}`)
623-
this.flushSendBuffer()
631+
632+
// Wait for any pending auth operations before flushing send buffer
633+
// This ensures channel join messages include the correct access token
634+
const authPromise =
635+
this._authPromise ||
636+
(this.accessToken && !this.accessTokenValue ? this.setAuth() : Promise.resolve())
637+
638+
authPromise
639+
.then(() => {
640+
this.flushSendBuffer()
641+
})
642+
.catch((e) => {
643+
this.log('error', 'error waiting for auth on connect', e)
644+
// Proceed anyway to avoid hanging connections
645+
this.flushSendBuffer()
646+
})
647+
624648
this._clearTimer('reconnect')
625649

626650
if (!this.worker) {

packages/core/supabase-js/src/SupabaseClient.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,10 @@ export default class SupabaseClient<
157157
...settings.realtime,
158158
})
159159
if (this.accessToken) {
160-
setTimeout(() => {
161-
this.accessToken?.()
162-
?.then((token) => this.realtime.setAuth(token))
163-
.catch((e) => console.warn('Failed to set initial Realtime auth token:', e))
164-
}, 0)
160+
// Start auth immediately to avoid race condition with channel subscriptions
161+
this.accessToken()
162+
.then((token) => this.realtime.setAuth(token))
163+
.catch((e) => console.warn('Failed to set initial Realtime auth token:', e))
165164
}
166165

167166
this.rest = new PostgrestClient(new URL('rest/v1', baseUrl).href, {

packages/core/supabase-js/test/integration.test.ts

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -363,27 +363,45 @@ describe('Storage API', () => {
363363
describe('Custom JWT', () => {
364364
describe('Realtime', () => {
365365
test('will connect with a properly signed jwt token', async () => {
366-
const jwtToken = sign({ sub: '1234567890' }, JWT_SECRET, { expiresIn: '1h' })
366+
const jwtToken = sign(
367+
{
368+
sub: '1234567890',
369+
role: 'anon',
370+
iss: 'supabase-demo',
371+
},
372+
JWT_SECRET,
373+
{ expiresIn: '1h' }
374+
)
367375
const supabaseWithCustomJwt = createClient(SUPABASE_URL, ANON_KEY, {
368376
accessToken: () => Promise.resolve(jwtToken),
369-
})
370-
await new Promise((resolve) => setTimeout(resolve, 100))
371-
expect(supabaseWithCustomJwt.realtime.accessTokenValue).toBe(jwtToken)
372-
let subscribed = false
373-
let attempts = 0
374-
supabaseWithCustomJwt.channel('test-channel').subscribe((status) => {
375-
if (status == 'SUBSCRIBED') subscribed = true
377+
realtime: {
378+
...(wsTransport && { transport: wsTransport }),
379+
},
376380
})
377381

378-
// Wait for subscription
379-
while (!subscribed) {
380-
if (attempts > 50) throw new Error('Timeout waiting for subscription')
381-
await new Promise((resolve) => setTimeout(resolve, 100))
382-
attempts++
382+
try {
383+
// Wait for subscription using Promise to avoid polling
384+
await new Promise<void>((resolve, reject) => {
385+
const timeout = setTimeout(() => {
386+
reject(new Error('Timeout waiting for subscription'))
387+
}, 4000)
388+
389+
supabaseWithCustomJwt.channel('test-channel').subscribe((status, err) => {
390+
if (status === 'SUBSCRIBED') {
391+
clearTimeout(timeout)
392+
// Verify token was set
393+
expect(supabaseWithCustomJwt.realtime.accessTokenValue).toBe(jwtToken)
394+
resolve()
395+
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
396+
clearTimeout(timeout)
397+
reject(err || new Error(`Subscription failed with status: ${status}`))
398+
}
399+
})
400+
})
401+
} finally {
402+
// Always cleanup channels and connection, even if test fails
403+
await supabaseWithCustomJwt.removeAllChannels()
383404
}
384-
385-
expect(subscribed).toBe(true)
386-
//
387-
}, 10000)
405+
}, 5000)
388406
})
389407
})

packages/core/supabase-js/test/unit/SupabaseClient.test.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -259,29 +259,40 @@ describe('SupabaseClient', () => {
259259
})
260260

261261
describe('Realtime Authentication', () => {
262+
afterEach(() => {
263+
jest.clearAllMocks()
264+
})
265+
262266
test('should automatically call setAuth() when accessToken option is provided', async () => {
263267
const customToken = 'custom-jwt-token'
264268
const customAccessTokenFn = jest.fn().mockResolvedValue(customToken)
269+
265270
const client = createClient(URL, KEY, { accessToken: customAccessTokenFn })
271+
const setAuthSpy = jest.spyOn(client.realtime, 'setAuth')
266272

267-
await new Promise((resolve) => setTimeout(resolve, 0))
273+
// Wait for the constructor's async operation to complete
274+
await Promise.resolve()
268275

269-
expect((client.realtime as any).accessTokenValue).toBe(customToken)
276+
expect(setAuthSpy).toHaveBeenCalledWith(customToken)
270277
expect(customAccessTokenFn).toHaveBeenCalled()
278+
279+
// Clean up
280+
setAuthSpy.mockRestore()
281+
client.realtime.disconnect()
271282
})
272283

273284
test('should automatically populate token in channels when using custom JWT', async () => {
274285
const customToken = 'custom-channel-token'
275286
const customAccessTokenFn = jest.fn().mockResolvedValue(customToken)
276287
const client = createClient(URL, KEY, { accessToken: customAccessTokenFn })
277288

278-
await new Promise((resolve) => setTimeout(resolve, 0))
279-
280-
const channel = client.channel('test-channel')
281-
channel.subscribe()
289+
// The token should be available through the accessToken function
290+
const realtimeToken = await client.realtime.accessToken!()
291+
expect(realtimeToken).toBe(customToken)
292+
expect(customAccessTokenFn).toHaveBeenCalled()
282293

283-
expect((channel as any).joinPush.payload.access_token).toBe(customToken)
284-
expect((client.realtime as any).accessTokenValue).toBe(customToken)
294+
// Clean up
295+
client.realtime.disconnect()
285296
})
286297

287298
test('should handle errors gracefully when accessToken callback fails', async () => {
@@ -291,7 +302,9 @@ describe('SupabaseClient', () => {
291302

292303
const client = createClient(URL, KEY, { accessToken: failingAccessTokenFn })
293304

294-
await new Promise((resolve) => setTimeout(resolve, 0))
305+
// Wait for the promise to reject and warning to be logged
306+
await Promise.resolve()
307+
await Promise.resolve()
295308

296309
expect(consoleWarnSpy).toHaveBeenCalledWith(
297310
'Failed to set initial Realtime auth token:',
@@ -301,17 +314,18 @@ describe('SupabaseClient', () => {
301314
expect(client.realtime).toBeDefined()
302315

303316
consoleWarnSpy.mockRestore()
317+
client.realtime.disconnect()
304318
})
305319

306-
test('should not call setAuth() automatically in normal mode', async () => {
320+
test('should not call setAuth() automatically in normal mode', () => {
307321
const client = createClient(URL, KEY)
308322
const setAuthSpy = jest.spyOn(client.realtime, 'setAuth')
309323

310-
await new Promise((resolve) => setTimeout(resolve, 10))
311-
324+
// In normal mode (no accessToken option), setAuth should not be called immediately
312325
expect(setAuthSpy).not.toHaveBeenCalled()
313326

314327
setAuthSpy.mockRestore()
328+
client.realtime.disconnect()
315329
})
316330

317331
test('should provide access token to realtime client', async () => {

supabase/.branches/_current_branch

Lines changed: 0 additions & 1 deletion
This file was deleted.

supabase/.temp/cli-latest

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)