Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions packages/core/realtime-js/src/RealtimeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,13 @@ export default class RealtimeClient {
}

this._setConnectionState('connecting')
this._setAuthSafely('connect')

// Trigger auth if needed and not already in progress
// This ensures auth is called for standalone RealtimeClient usage
// while avoiding race conditions with SupabaseClient's immediate setAuth call
if (this.accessToken && !this._authPromise) {
this._setAuthSafely('connect')
}

// Establish WebSocket connection
if (this.transport) {
Expand Down Expand Up @@ -253,11 +259,13 @@ export default class RealtimeClient {
this._setConnectionState('disconnected')
}

// Close the WebSocket connection
if (code) {
this.conn.close(code, reason ?? '')
} else {
this.conn.close()
// Close the WebSocket connection if close method exists
if (typeof this.conn.close === 'function') {
if (code) {
this.conn.close(code, reason ?? '')
} else {
this.conn.close()
}
}

this._teardownConnection()
Expand Down Expand Up @@ -605,7 +613,23 @@ export default class RealtimeClient {
private _onConnOpen() {
this._setConnectionState('connected')
this.log('transport', `connected to ${this.endpointURL()}`)
this.flushSendBuffer()

// Wait for any pending auth operations before flushing send buffer
// This ensures channel join messages include the correct access token
const authPromise =
this._authPromise ||
(this.accessToken && !this.accessTokenValue ? this.setAuth() : Promise.resolve())

authPromise
.then(() => {
this.flushSendBuffer()
})
.catch((e) => {
this.log('error', 'error waiting for auth on connect', e)
// Proceed anyway to avoid hanging connections
this.flushSendBuffer()
})

this._clearTimer('reconnect')

if (!this.worker) {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/supabase-js/src/SupabaseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ export default class SupabaseClient<
accessToken: this._getAccessToken.bind(this),
...settings.realtime,
})
if (this.accessToken) {
// Start auth immediately to avoid race condition with channel subscriptions
this.accessToken()
.then((token) => this.realtime.setAuth(token))
.catch((e) => console.warn('Failed to set initial Realtime auth token:', e))
}

this.rest = new PostgrestClient(new URL('rest/v1', baseUrl).href, {
headers: this.headers,
schema: settings.db.schema,
Expand Down
53 changes: 50 additions & 3 deletions packages/core/supabase-js/test/integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { assert } from 'console'
import { createClient, RealtimeChannel, SupabaseClient } from '../src/index'

import { sign } from 'jsonwebtoken'
// These tests assume that a local Supabase server is already running
// Start a local Supabase instance with 'supabase start' before running these tests
// Default local dev credentials from Supabase CLI
const SUPABASE_URL = 'http://127.0.0.1:54321'
const ANON_KEY =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'

const JWT_SECRET = 'super-secret-jwt-token-with-at-least-32-characters-long'
// For Node.js < 22, we need to provide a WebSocket implementation
// Node.js 22+ has native WebSocket support
let wsTransport: any = undefined
Expand Down Expand Up @@ -292,7 +293,7 @@ describe('Supabase Integration Tests', () => {

channel
.on('broadcast', { event: '*' }, (payload) => (receivedMessage = payload))
.subscribe((status) => {
.subscribe((status, err) => {
if (status == 'SUBSCRIBED') subscribed = true
})

Expand Down Expand Up @@ -358,3 +359,49 @@ describe('Storage API', () => {
expect(deleteError).toBeNull()
})
})

describe('Custom JWT', () => {
describe('Realtime', () => {
test('will connect with a properly signed jwt token', async () => {
const jwtToken = sign(
{
sub: '1234567890',
role: 'anon',
iss: 'supabase-demo',
},
JWT_SECRET,
{ expiresIn: '1h' }
)
const supabaseWithCustomJwt = createClient(SUPABASE_URL, ANON_KEY, {
accessToken: () => Promise.resolve(jwtToken),
realtime: {
...(wsTransport && { transport: wsTransport }),
},
})

try {
// Wait for subscription using Promise to avoid polling
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for subscription'))
}, 4000)

supabaseWithCustomJwt.channel('test-channel').subscribe((status, err) => {
if (status === 'SUBSCRIBED') {
clearTimeout(timeout)
// Verify token was set
expect(supabaseWithCustomJwt.realtime.accessTokenValue).toBe(jwtToken)
resolve()
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
clearTimeout(timeout)
reject(err || new Error(`Subscription failed with status: ${status}`))
}
})
})
} finally {
// Always cleanup channels and connection, even if test fails
await supabaseWithCustomJwt.removeAllChannels()
}
}, 5000)
})
})
69 changes: 69 additions & 0 deletions packages/core/supabase-js/test/unit/SupabaseClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,75 @@ describe('SupabaseClient', () => {
})

describe('Realtime Authentication', () => {
afterEach(() => {
jest.clearAllMocks()
})

test('should automatically call setAuth() when accessToken option is provided', async () => {
const customToken = 'custom-jwt-token'
const customAccessTokenFn = jest.fn().mockResolvedValue(customToken)

const client = createClient(URL, KEY, { accessToken: customAccessTokenFn })
const setAuthSpy = jest.spyOn(client.realtime, 'setAuth')

// Wait for the constructor's async operation to complete
await Promise.resolve()

expect(setAuthSpy).toHaveBeenCalledWith(customToken)
expect(customAccessTokenFn).toHaveBeenCalled()

// Clean up
setAuthSpy.mockRestore()
client.realtime.disconnect()
})

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

// The token should be available through the accessToken function
const realtimeToken = await client.realtime.accessToken!()
expect(realtimeToken).toBe(customToken)
expect(customAccessTokenFn).toHaveBeenCalled()

// Clean up
client.realtime.disconnect()
})

test('should handle errors gracefully when accessToken callback fails', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
const error = new Error('Token fetch failed')
const failingAccessTokenFn = jest.fn().mockRejectedValue(error)

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

// Wait for the promise to reject and warning to be logged
await Promise.resolve()
await Promise.resolve()

expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to set initial Realtime auth token:',
error
)
expect(client).toBeDefined()
expect(client.realtime).toBeDefined()

consoleWarnSpy.mockRestore()
client.realtime.disconnect()
})

test('should not call setAuth() automatically in normal mode', () => {
const client = createClient(URL, KEY)
const setAuthSpy = jest.spyOn(client.realtime, 'setAuth')

// In normal mode (no accessToken option), setAuth should not be called immediately
expect(setAuthSpy).not.toHaveBeenCalled()

setAuthSpy.mockRestore()
client.realtime.disconnect()
})

test('should provide access token to realtime client', async () => {
const expectedToken = 'test-jwt-token'
const client = createClient(URL, KEY)
Expand Down
1 change: 1 addition & 0 deletions supabase/.branches/_current_branch
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
main
1 change: 1 addition & 0 deletions supabase/.temp/cli-latest
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v2.54.11