Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Access token not refreshed for realtime channels after being offline or in standby #274

Open
colin-chadwick opened this issue Jan 10, 2024 · 11 comments
Labels
bug Something isn't working

Comments

@colin-chadwick
Copy link

Bug report

When a user is offline or in standby mode, the access_token is not refreshed as it requires a POST request to auth/v1/token?grant_type=refresh_token (which obviously fails without a connection). When the user comes back online or is active again, Supabase doesn't automatically refetch the access_token and supply it to realtime channels. Instead, the realtime channels error and show the following message, because they try to connect with the old token:

Bildschirmfoto 2024-01-10 um 21 52 05

Even if the access_token is refreshed afterwards, the channels don't seem to pick up the change. The only way to get the channels to work again is by removing and re-initializing them.

To Reproduce

  1. Set the JWT expiry time of the access_token (e. g. 60 = 1 minute) to a low value, to be able to better reproduce the case where a access_token needs a refresh.
  2. Subscribe to a realtime channel.
  3. Turn off WIFI or go into standby mode.
  4. Wait for the current access_token to expire (in this case after 1 minute).
  5. Turn the WIFI on again or disable standby.

The realtime channel tries to reconnect with the old token (because Supabase wasn't able to refetch it without a connection) and errors out. It doesn't pick up any new tokens once Supabase refreshes them again in the usual interval.

Expected behavior

If any realtime channels are still active after re-enabling WIFI or disabling standby, Supabase should refetch the access_token right away and supply it to the channels. This way, they won't error out.

System information

  • OS: macOS
  • Version of supabase-js: 2.39.1

Additional context

I've built a temporary workaround to the fix the issue, maybe it can help:


import { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js'

export const realtime = (client: SupabaseClient) => {
  let channel: RealtimeChannel | null = null
  let subscribed = false
  let reconnect = true
  let reconnecting = false

  const _subscribe = ({ table }) => {

    if (channel) client.removeChannel(channel)

    reconnecting = true

    return (
      callback: (payload: any) => void,
    ) => {
      channel = client
        .channel('realtime')
        .on(
          'postgres_changes',
          {
            event: '*',
            schema: 'public',
            table,
          },
          (payload) =>  {
            callback(payload)
          },
        )
        .subscribe()
        // @ts-expect-error
        .on('phx_reply', {}, async (payload) => {
          if (
            payload.status === 'error' &&
            payload.response.reason.includes('Invalid token')
          ) {
            await client.removeChannel(channel)

            await client.auth.refreshSession()

            subscribed = false

            reconnecting = false
          }
        })
        // @ts-expect-error
        .on('system', {}, async (payload) => {
          if (
            payload.extension === 'postgres_changes' &&
            payload.status === 'ok'
          ) {
            subscribed = true

            reconnecting = false

          }
        })

    }
  }

  return {
    from: (table: string) => {
      return {
        subscribe: (
          callback: (payload: any) => void,
        ) => {
          let timer: NodeJS.Timeout

          const reconnectSubscription = () => {
            clearTimeout(timer)

            timer = setTimeout(() => {
              reconnectSubscription()

              if (
                reconnect &&
                !subscribed &&
                !reconnecting &&
                document.visibilityState === 'visible'
              ) {
                return _subscribe({ table })(callback)
              }
            }, 1000)
          }

          reconnectSubscription()

          return _subscribe({ table })(callback)
        },
        unsubscribe: async () => {
          if (!channel) return

          await client.removeChannel(channel)
          channel = null
          reconnect = false
        },
      }
    },
  }
}
@colin-chadwick colin-chadwick added the bug Something isn't working label Jan 10, 2024
@ghost
Copy link

ghost commented Jan 27, 2024

I can confirm that I'm experiencing this also.

@colin-chadwick
Copy link
Author

Could you look into this? 👋 @w3b6x9

@ghost
Copy link

ghost commented Feb 1, 2024

Is anyone else experiencing obscenely long cookies (such as using Keycloak as an auth provider)? I am noticing 2 or more cookies for a single login, causing a ton of errors and seemingly this one too. I documented it here supabase/supabase-js#963 but does anyone else experiencing this issue specifically (realtime refresh failing) also have multiple auth cookies?

@erdemdev
Copy link

erdemdev commented Mar 7, 2024

Thıs needs to be resolved asap.

@create-signal
Copy link

This is still broken. Here's a simpler version of the temporary workaround that seems to work ok.

Essentially, what is does is watch the channel for a "Access token has expired" response, sends a "auth.refreshSession()" request, then discards the open channel and creates a new one.

I've created a module - e.g. supabase-realtime-bug.ts

import { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js'

// Shares the refresh promise between all channels
let refreshPromise: Promise<void> | undefined

export function fixSupabaseRealtimeBug(
  supabase: SupabaseClient,
  newChannel: () => RealtimeChannel,
  onNewChannel?: (channel: RealtimeChannel) => void,
) {
  const currentChannel = newChannel()

  currentChannel
    //@ts-expect-error
    .on('system', {}, async ev => {
      if (ev.status == 'error' && ev.message.includes('Access token has expired')) {
        await currentChannel.unsubscribe()
        await supabase.removeChannel(currentChannel)
        if (!refreshPromise) {
          refreshPromise = supabase.auth.refreshSession().then(() => (refreshPromise = undefined))
        }
        await refreshPromise
        fixSupabaseRealtimeBug(supabase, newChannel)
      }
    })

  onNewChannel?.(currentChannel)
}

and then I can reuse it

let channel: RealtimeChannel | undefined = undefined

fixSupabaseRealtimeBug(
  supabase,
  () => {
    return supabase
      .channel('db-changes-test')
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'days',
        },
        payload => console.log(payload),
      )
      .subscribe()
  },
  // Use this callback if you need to keep a reference to the current channel
  newChannel => {
    channel = newChannel
  },
)

Looking forward to a fix for this!

@filipecabaco
Copy link
Member

@create-signal are you seeing this with supabase-js? if so which version?

I also think there's some element on supabase-js where we're not properly setting the token back and at first we thought it could be a supabase-js version specifically

@create-signal
Copy link

create-signal commented Sep 20, 2024

@create-signal are you seeing this with supabase-js? if so which version?

I also think there's some element on supabase-js where we're not properly setting the token back and at first we thought it could be a supabase-js version specifically

I'm testing with supabase-js version 2.45.4

The issue occurs when the auth module stops refreshing the access token because the device is asleep or idle. When the device resumes the asynchronous "refreshSession" method runs and the socket sends an "access_token" message simultaneously (with the old access token). So the logs will look like this:

push realtime:db-changes-test access_token (387) {access_token: 'OLD_TOKEN'}

receive error realtime:db-changes-test system  {message: 'Access token has expired: [message: "Invalid token", claim: "exp", claim_val: 1726823673]', status: 'error', extension: 'system', channel: 'db-changes-test'}

receive  realtime:db-changes-test phx_close (1) {}

channel close realtime:db-changes-test 1 undefined

AUTH EVENT -> TOKEN_REFRESHED (the event that contains the new token)

An easy way to test this without putting your computer to sleep is to

  • set the JWT expiry in supabase to a short value (60s)
  • use the method supabase.auth.stopAutoRefresh() to simulate the session not refreshing while the device is asleep

@filipecabaco
Copy link
Member

perfect!!! thank you for reporting this and a potential fix 🙏 I will check with the remainder of the team as this can also be the source of another issue we're seeing where it seems realtime just tries to connect with expired tokens.

@create-signal
Copy link

Just FYI, i think the push realtime:db-changes-test access_token (387) {access_token: 'OLD_TOKEN'} message in that log was a red herring and is completely unrelated

It looks like the backend server sends an 'error' and a 'phx_close' event for each open channel when the access token expires, which then triggers RealtimeChannel to remove itself from RealtimeClient.channels[] (L175 of RealtimeChannel.ts)

Using RealtimeClient.setAuth() (like supabase-js does) after the channel is removed from that array won't have any effect, when ideally it would be able to assign the new access token to the channel and "rejoin()"

@filipecabaco
Copy link
Member

@create-signal are you able to reliable replicate this error? if so could you provide a small repo or a gist with the code to do so?

I've been trying to replicate it with no success 😓

@sanedealer
Copy link

sanedealer commented Nov 15, 2024

facing a similar issue right now, users appear offline after token expires if they are inactive or in a different tab/window.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

5 participants