Skip to content

Commit 1371057

Browse files
fix(realtime): setAuth not required on custom jwt token (#1826)
Co-authored-by: Katerina Skroumpelou <sk.katherine@gmail.com>
1 parent 01e1948 commit 1371057

File tree

4 files changed

+157
-10
lines changed

4 files changed

+157
-10
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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ export default class SupabaseClient<
156156
accessToken: this._getAccessToken.bind(this),
157157
...settings.realtime,
158158
})
159+
if (this.accessToken) {
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))
164+
}
165+
159166
this.rest = new PostgrestClient(new URL('rest/v1', baseUrl).href, {
160167
headers: this.headers,
161168
schema: settings.db.schema,

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

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1+
import { assert } from 'console'
12
import { createClient, RealtimeChannel, SupabaseClient } from '../src/index'
2-
3+
import { sign } from 'jsonwebtoken'
34
// These tests assume that a local Supabase server is already running
45
// Start a local Supabase instance with 'supabase start' before running these tests
56
// Default local dev credentials from Supabase CLI
67
const SUPABASE_URL = 'http://127.0.0.1:54321'
78
const ANON_KEY =
89
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'
9-
10+
const JWT_SECRET = 'super-secret-jwt-token-with-at-least-32-characters-long'
1011
// For Node.js < 22, we need to provide a WebSocket implementation
1112
// Node.js 22+ has native WebSocket support
1213
let wsTransport: any = undefined
@@ -292,7 +293,7 @@ describe('Supabase Integration Tests', () => {
292293

293294
channel
294295
.on('broadcast', { event: '*' }, (payload) => (receivedMessage = payload))
295-
.subscribe((status) => {
296+
.subscribe((status, err) => {
296297
if (status == 'SUBSCRIBED') subscribed = true
297298
})
298299

@@ -358,3 +359,49 @@ describe('Storage API', () => {
358359
expect(deleteError).toBeNull()
359360
})
360361
})
362+
363+
describe('Custom JWT', () => {
364+
describe('Realtime', () => {
365+
test('will connect with a properly signed jwt token', async () => {
366+
const jwtToken = sign(
367+
{
368+
sub: '1234567890',
369+
role: 'anon',
370+
iss: 'supabase-demo',
371+
},
372+
JWT_SECRET,
373+
{ expiresIn: '1h' }
374+
)
375+
const supabaseWithCustomJwt = createClient(SUPABASE_URL, ANON_KEY, {
376+
accessToken: () => Promise.resolve(jwtToken),
377+
realtime: {
378+
...(wsTransport && { transport: wsTransport }),
379+
},
380+
})
381+
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()
404+
}
405+
}, 5000)
406+
})
407+
})

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,75 @@ describe('SupabaseClient', () => {
259259
})
260260

261261
describe('Realtime Authentication', () => {
262+
afterEach(() => {
263+
jest.clearAllMocks()
264+
})
265+
266+
test('should automatically call setAuth() when accessToken option is provided', async () => {
267+
const customToken = 'custom-jwt-token'
268+
const customAccessTokenFn = jest.fn().mockResolvedValue(customToken)
269+
270+
const client = createClient(URL, KEY, { accessToken: customAccessTokenFn })
271+
const setAuthSpy = jest.spyOn(client.realtime, 'setAuth')
272+
273+
// Wait for the constructor's async operation to complete
274+
await Promise.resolve()
275+
276+
expect(setAuthSpy).toHaveBeenCalledWith(customToken)
277+
expect(customAccessTokenFn).toHaveBeenCalled()
278+
279+
// Clean up
280+
setAuthSpy.mockRestore()
281+
client.realtime.disconnect()
282+
})
283+
284+
test('should automatically populate token in channels when using custom JWT', async () => {
285+
const customToken = 'custom-channel-token'
286+
const customAccessTokenFn = jest.fn().mockResolvedValue(customToken)
287+
const client = createClient(URL, KEY, { accessToken: customAccessTokenFn })
288+
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()
293+
294+
// Clean up
295+
client.realtime.disconnect()
296+
})
297+
298+
test('should handle errors gracefully when accessToken callback fails', async () => {
299+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
300+
const error = new Error('Token fetch failed')
301+
const failingAccessTokenFn = jest.fn().mockRejectedValue(error)
302+
303+
const client = createClient(URL, KEY, { accessToken: failingAccessTokenFn })
304+
305+
// Wait for the promise to reject and warning to be logged
306+
await Promise.resolve()
307+
await Promise.resolve()
308+
309+
expect(consoleWarnSpy).toHaveBeenCalledWith(
310+
'Failed to set initial Realtime auth token:',
311+
error
312+
)
313+
expect(client).toBeDefined()
314+
expect(client.realtime).toBeDefined()
315+
316+
consoleWarnSpy.mockRestore()
317+
client.realtime.disconnect()
318+
})
319+
320+
test('should not call setAuth() automatically in normal mode', () => {
321+
const client = createClient(URL, KEY)
322+
const setAuthSpy = jest.spyOn(client.realtime, 'setAuth')
323+
324+
// In normal mode (no accessToken option), setAuth should not be called immediately
325+
expect(setAuthSpy).not.toHaveBeenCalled()
326+
327+
setAuthSpy.mockRestore()
328+
client.realtime.disconnect()
329+
})
330+
262331
test('should provide access token to realtime client', async () => {
263332
const expectedToken = 'test-jwt-token'
264333
const client = createClient(URL, KEY)

0 commit comments

Comments
 (0)