supabase / supabase-flutter

Flutter integration for Supabase. This package makes it simple for developers to build secure and scalable products.
https://supabase.com/
MIT License
656 stars 154 forks source link

onAuthStateChange not triggered when JWT expired #452

Open KirioXX opened 1 year ago

KirioXX commented 1 year ago

Describe the bug We had this week quite a lot of users where the JWT expired. There users where not logged out instead they had to close and reopen the app to trigger the logout.

In our logs we have seen a lot of these errors:

data:text/text;charset=utf-8,
PostgrestException(message: JWT expired, code: PGRST301, details: Unauthorized, hint: null)

When the exception was thrown, this was the stack:
0       PostgrestBuilder._parseResponse (package:postgrest/src/postgrest_builder.dart:323:0)
1       PostgrestBuilder._execute (package:postgrest/src/postgrest_builder.dart:198:0)
2       PostgrestBuilder.then (package:postgrest/src/postgrest_builder.dart:400:0)

To Reproduce Steps to reproduce the behavior:

  1. Setup a listener to listen for onAuthStateChange (we use a bloc cubit for that )
  2. Login
  3. Let the JWT expire
  4. Try to make a request

Expected behavior User is getting logged out either when the JWT expires or when the try to make a request.

Screenshots

Version (please complete the following information): On Linux/macOS Please run dart pub deps | grep -E "supabase|gotrue|postgrest|storage_client|realtime_client|functions_client" in your project directory and paste the output here.

│   └── supabase_flutter...
│   └── supabase_flutter...
│   └── supabase_flutter...
│   ├── supabase_flutter...
│   ├── supabase_flutter...
│   └── supabase_flutter...
│   ├── supabase_flutter...
│   └── supabase_flutter...
│   ├── supabase_flutter...
│   └── supabase_flutter...
│   └── supabase_flutter...
│   └── supabase_flutter...
├── supabase_flutter 1.7.0
│   ├── supabase 1.6.3
│   │   ├── functions_client 1.1.1
│   │   ├── gotrue 1.6.0
│   │   ├── postgrest 1.2.3
│   │   ├── realtime_client 1.0.3
│   │   ├── storage_client 1.3.0

On Windows Please run dart pub deps | findstr "supabase gotrue postgrest storage_client realtime_client functions_client" in your project directory and paste the output here.

Additional context Add any other context about the problem here.

Vinzent03 commented 1 year ago

gotrue 1.6.0 got just a few minutes ago released. Do you really still experience this issue? What version of gotrue where the production app using? What jwt expiry time have you set?

KirioXX commented 1 year ago

Thanks for the quick response @Vinzent03. That is a very good point the current production version is still on gotrue 1.4.2:

├── supabase_flutter 1.4.0
│   ├── supabase 1.5.1
│   │   ├── functions_client 1.0.2
│   │   ├── gotrue 1.4.2
│   │   ├── postgrest 1.2.2
│   │   ├── realtime_client 1.0.2
│   │   ├── storage_client 1.2.2

Could that be the issue?

Vinzent03 commented 1 year ago

There was a fix in 1.5.6 about jwt expiry margins. This may be the cause.

KirioXX commented 1 year ago

Thank you very much Vinzent03. We actually have a new release going out to the first users today, which uses 1.5.6. I will keep you posted if the new version resolves it.

Vinzent03 commented 1 year ago

It might be important to update to 1.5.7 though, depending on how important the session emitted by the inAuthStateChange stream is.

KirioXX commented 1 year ago

Sorry I meant we are on 1.5.7 😅

KirioXX commented 1 year ago

@Vinzent03 we rolled out our new app version to some test device. So far everything looks good. But we got this error yesterday and it seems to be stuck in a loop:

data:text/text;charset=utf-8,
AuthException(message: Invalid Refresh Token: Refresh Token Not Found, statusCode: 400)

When the exception was thrown, this was the stack:
0       GotrueFetch.request (package:gotrue/src/fetch.dart:99:0)
1       GoTrueClient._callRefreshToken (package:gotrue/src/gotrue_client.dart:669:0)
2       GoTrueClient.recoverSession (package:gotrue/src/gotrue_client.dart:549:0)
3       SupabaseAuth.initialize (package:supabase_flutter/src/supabase_auth.dart:104:0)
4       Supabase.initialize (package:supabase_flutter/src/supabase.dart:91:0)
5       _initSupabase (package:tusks/main.dart:67:0)
6       main (package:tusks/main.dart:24:0)
7       main (package:tusks/main_production.dart:18:0)

Can that be related to the JWT issue?

Vinzent03 commented 1 year ago

You should get logged out when this error comes up. You get this error for example when you sign out on another device.

KirioXX commented 1 year ago

Hi @Vinzent03, we had yesterday again a user that didn't get log out even though the request returned 401 codes. Is there anything that we could miss when we listen to onAuthStateChange?

Vinzent03 commented 1 year ago

We currently only sign out on the specific error message Invalid Refresh Token: Refresh Token Not Found. I don't know what the reason for a 401 response is. We emit the error via addError to the onAuthDtateChange stream, where you could react to that.

KirioXX commented 1 year ago

Thanks for the quick response @Vinzent03. If it helps, this is full response:

Response
  Headers:
   access-control-allow-origin: "*"
   content-type: "application/json; charset=utf-8"
   x-kong-upstream-latency: "0"
   alt-svc: "h3=":443"; ma=86400, h3-29=":443"; ma=86400"
   via: "kong/2.8.1"
   server: "cloudflare"
   sb-gateway-version: "1"
   transfer-encoding: "chunked"
   cf-cache-status: "DYNAMIC"
   date: "Thu, 11 May 2023 21:51:43 GMT"
   x-kong-proxy-latency: "1"
   strict-transport-security: "max-age=2592000; includeSubDomains"
   connection: "keep-alive"
   cf-ray: "7c5da514af510fd3-LAX"
   vary: "Accept-Encoding"
   www-authenticate: "Bearer error="invalid_token", error_description="JWT expired""
  Body:
   code: "PGRST301"
   message: "JWT expired"
   details: null
   hint: null

The logs also say that there was 1 slow request before the failing once, could it be that the JWT was invalidated because the refresh request was to slow? Sadly I miss a couple network request logs.

Vinzent03 commented 1 year ago

That seems to be the response of a call to postgrest which failed, because the call to refresh the jwt didn't work.

could it be that the JWT was invalidated because the refresh request was to slow? Definitely not, because jwts can't be manually invalidated. Only after the expiry time.

Can you see how slow exactly the previous request was and where it went? Maybe the call to the token endpoint to refresh the jwt hasn't finished yet?

KirioXX commented 1 year ago

I can see the 100 failed requests and they all tried to fetch the same resource:

{
  "status": 401,
  "response_time": 198.431,
  "method": "GET",
  "response_headers": {
    "access-control-allow-origin": "*",
    "content-type": "application/json; charset=utf-8",
    "x-kong-upstream-latency": "0",
    "alt-svc": "h3=\":443\"; ma=86400, h3-29=\":443\"; ma=86400",
    "via": "kong/2.8.1",
    "server": "cloudflare",
    "sb-gateway-version": "1",
    "transfer-encoding": "chunked",
    "cf-cache-status": "DYNAMIC",
    "date": "Thu, 11 May 2023 21:49:44 GMT",
    "x-kong-proxy-latency": "1",
    "strict-transport-security": "max-age=2592000; includeSubDomains",
    "connection": "keep-alive",
    "cf-ray": "7c5da231ee9d0fd3-LAX",
    "vary": "Accept-Encoding",
    "www-authenticate": "Bearer error=\"invalid_token\", error_description=\"JWT expired\""
  },
  "date": 1683841784800,
  "request": "",
  "headers": {
    "Accept-Profile": "public",
    "apikey": "XXXX",
    "X-Client-Info": "supabase-flutter/1.7.0",
    "Authorization": "Bearer XXXX"
  },
  "response": {
    "code": "PGRST301",
    "message": "JWT expired",
    "details": null,
    "hint": null
  },
  "url": "https://XXXX/rest/v1/job_cards?select=%2A&job_card_id=eq.ae44c164-a38b-413a-9e61-ab89512a4bb7&limit=1"
}
KirioXX commented 1 year ago

Hi @Vinzent03, I just wanted to check if there are any updates on this issue? If that is nothing new could you advice use how we could get around this issue? Thank you

KirioXX commented 1 year ago

Hey, we are still experience that the JWT expires and the auth state is not changing what causes our automated login flow not to be called. We also got now a stack trace for a failed request if that is of any help:

data:text/text;charset=utf-8,
{message: PostgrestException(message: JWT expired, code: PGRST301, details: Unauthorized, hint: null)}

When the exception was thrown, this was the stack:
0       DeviceCubit._mapFailureToMessage.<fn> (package:xxx/application/device/device_cubit.dart:224:0)
1       _$Unexpected.mapOrNull (package:xxx/domain/work_order/work_order_failure.freezed.dart:457:0)
2       DeviceCubit._mapFailureToMessage (package:xxx/application/device/device_cubit.dart:220:0)
3       DeviceCubit.handleUnsetDeviceJob.<fn> (package:xxx/application/device/device_cubit.dart:186:0)
4       Left.fold (package:dartz/src/either.dart:191:0)
5       DeviceCubit.handleUnsetDeviceJob (package:xxx/application/device/device_cubit.dart:183:0)

About how the JWT could have expired, we have quite a lot of users with a low attention span because of there work environment. Can it be that when one request takes to long and the user triggers more and more requests that the SDK or the server invalidates the JWT to prevent any kind of attack? Thank you!

Vinzent03 commented 1 year ago

It's technically not possible to invalidate a jwt. It's only invalid after its expiry time. Your error shows again that the refreshing of the jwt somehow didn't work. What is your JWT expiry limit in the supabase dashboard?

KirioXX commented 1 year ago

Thanks for the response Vinzent03. At the moment the expiry limit is 1 week but the device was definitly active in that week.

Vinzent03 commented 1 year ago

Hmm 1 week is definitely long enough. We currently try to refresh the jwt 60 seconds before expiry. I'm wondering why that sometimes doesn't work in your case.

KirioXX commented 1 year ago

Could it be a timezone issue? Because it is pretty consistent for our users in the US and our instance is in the UK.

dshukertjr commented 1 year ago

It seems like there are two separate issues here

The cause of the Gotrue one is unknown. Thanks for the info on the timezone, but nothing pops up immediately as a possible issue.

For Postgrest making request with an invalid JWT, we can probably do what supabase-js is doing. Here is a brief steps of how supabase-js is making API requests to Postgrest

  1. When postgrest method is called, call auth.getSession()
  2. getSession() first checks if the current session is valid, and if it's not, it refreshes the session
  3. after the new session is obtained, an API request to postgrest is made.

With this method, we should at least get rid of the first problem.

KirioXX commented 1 year ago

Thanks @dshukertjr for the update. I just have one question what will happen when it can't refresh the session will this log the user out?

Is there maybe something that we can do to get around the Gotrue error for now? Thank you.

dshukertjr commented 1 year ago

I just have one question what will happen when it can't refresh the session will this log the user out?

If the SDK did attempt to refresh the access token, but failed due to invalid refresh token, the user will be signed out. Otherwise onAuthStateChanged will throw an error, but the user will not be signed out, and they in theory should be able to resume their session if they close and reopen their app.

In your case through, it seems like the SDK for some reason didn't attempt to refresh the access token. In this case, I would guess nothing happens, but can't say for sure since the root cause is still uncertain.

Is there maybe something that we can do to get around the Gotrue error for now?

It might not be a pretty work around, but one thing that comes to my mind as a workaround is to manually call refreshSession periodically.

await supabase.auth.refreshSession();
KirioXX commented 1 year ago

Otherwise onAuthStateChanged will throw an error, but the user will not be signed out, and they in theory should be able to resume their session if they close and reopen their app.

Is there a way to refresh the session without closing and reopening the app? For example to refresh the client when onAuthStateChenged throws?

It might not be a pretty work around, but one thing that comes to my mind as a workaround is to manually call refreshSession periodically.

Thanks dshukertjr, that works for now. I'm not sure if this can be related but we have also seen a lot of these errors:

data:text/text;charset=utf-8,
AuthException(message: Invalid Refresh Token: Refresh Token Not Found, statusCode: 400)

When the exception was thrown, this was the stack:
0       GotrueFetch.request (package:gotrue/src/fetch.dart:99:0)
1       GoTrueClient._callRefreshToken (package:gotrue/src/gotrue_client.dart:669:0)
2       GoTrueClient.recoverSession (package:gotrue/src/gotrue_client.dart:549:0)
3       SupabaseAuth.initialize (package:supabase_flutter/src/supabase_auth.dart:104:0)
4       Supabase.initialize (package:supabase_flutter/src/supabase.dart:91:0)
5       _initSupabase (package:xxxx/main.dart:67:0)
6       main (package:xxxx/main.dart:24:0)
7       main (package:xxxx/main_production.dart:18:0)

For context _initSupabase is initialising the supabase client.

dshukertjr commented 1 year ago

Is there a way to refresh the session without closing and reopening the app? For example to refresh the client when onAuthStateChenged throws?

Again, it's not a pretty workaround, and this, in theory, should never happen, but you should be able to attach onError on onAuthStateChanged and call refreshSession like this to retry it.

supabase.auth.onAuthStateChanged
.listen((data) {})
.onError((error) {
  supabase.auth.refreshSession();  
});

I'm not sure if this can be related but we have also seen a lot of these errors:

Thanks for sharing this. This is very helpful. Interesting that the refresh token is missing.

I have been spending some time trying to find the cause of this, but haven't got anything yet. I will keep this issue posted if I do manage to find anything.

Vinzent03 commented 1 year ago

Invalid Refresh Token: Refresh Token Not Found

I encountered this, if I sign out on another device or session, because Supabase invalidates all refresh tokens on sign out call.

dshukertjr commented 1 year ago

@Vinzent03 Ah, nice catch! @KirioXX Currently are you sharing any login credentials between any of the devices that are experiencing this issue?

Vinzent03 commented 1 year ago

I reported that here long ago. From what I see, invalidating all refresh tokens in the only effect of calling the logout endpoint. I don't like that behavior, so @dshukertjr what do you think about adding an option to signOut to NOT revoke all refresh tokens? Another method would be possible as well.

dshukertjr commented 1 year ago

@Vinzent03 The auth team is aware of the request, and they are discussing a solution where the developer can choose to just revoke a single session or the entire session for the user. Until then, I would like to stay away from the Flutter library to deviate on a unique solution from js library on this one.

KirioXX commented 1 year ago

OK that makes sense. The number of those exceptions went down quite a bit down since we changed how we authenticate our operators devices. Each device has now it's own user and they should not be logged out any where else then on that one device. But we still have them from time to time.

dshukertjr commented 1 year ago

@KirioXX Thanks for confirming.

You have reported few different types of errors occurring within this issue, but did any of them stop occurring or start occurring after you migrated to using one user per device?

KirioXX commented 1 year ago

I actually was wrong with the assumption that the occurrences went down, we just don't have that many devices on the new app version that is using dedicated users per device. But I can see that the exception have not changed between the app version with dedicated device user and without dedicated device user.

jmsandiegoo commented 11 months ago

@dshukertjr is there a good documentation on how we could handle Postgrest having the JWT expired error for flutter based applications? I can't seem to find a solution that would allow me to check if the jwt has expired and refresh it accordingly. Also, will handling jwt expiration for postgrest be applicable to other services such as storage & rpc calls? Thanks in advance !

dshukertjr commented 11 months ago

@KirioXX Okay, that is unfortunate, but thanks for letting us know.

I'm assuming no, but are there any chance that the system clock on the devices that are experiencing this issue are off by a minute or more?

@jmsandiegoo Are you also experiencing JWT expired error from supabase_flutter? The SDK is meant to handle access token refresh, but for some cases the refresh is not happening, and we haven't been able to reproduce it consistently. If you have any additional information that is not here in this issue that might help us reproduce this issue, it would be greatly appreciated!

I can't seem to find a solution that would allow me to check if the jwt has expired and refresh it accordingly.

You can call supabase.auth.refreshSession(); to refresh the session and get a new token this will refresh the token for all storage, rpc, and other services! To check if the access token has expired or not, you should be able to compare supabase.auth.currentSession.expiresAt with the current timestamp.

jmsandiegoo commented 11 months ago

@dshukertjr i see! and yes unfortunately the JWT expired error happens occasionally. I'll look into implementing refreshSession as suggested for now. Also, I was wondering are there documentation about the kind of Exceptions each services would throw when being called in flutter? So far i am aware that auth will throw an AuthException and storage would throw StorageException respectively.

dshukertjr commented 11 months ago

@jmsandiegoo Postgrest, our API features, will throw PostgrestException. More details about it can be found here. https://postgrest.org/en/stable/references/errors.html

KirioXX commented 11 months ago

@dshukertjr I checked with implementation and this is what they came back with:

The device time will be set automatically by Apple from an NTP server, so I'd say it's unlikely the time is off on the device. If the time was set manually on first starting up a device, I'd say there's a high chance of it being off by a minute or more, but we don't ever manually set or change the time.

dshukertjr commented 11 months ago

@KirioXX Thanks. That was my assumption, but good to confirm.

larsbloch commented 11 months ago

Im experiencing something similar, maybe. Using Supabase_Flutter We have switched to SupaBase from cognito (where we did not experience this issue). When we close our app and the jwt expires and open it again, it wont refresh the token. Closing the app fully and opening again does work without any new login.

We dont use any other parts og Supabase other than authentication. In our api we can see that it receives an expired token. Lifetimevalidation failed. Token expired at x, time is now y.

We have tried implementing something similar suggested in another comment where we try calling refreshsession when expiresAt is lower than datetime.now. As this is only happening on mobile im having a hard time testing my code. But the below code seems to work. But now i think it is calling refreshsession every time. As it only happens on mobile im having a hard time testing it.

    Future<String> getSignedInUserToken() async {
    if (Supabase.instance.client.auth.currentSession != null) {
      if (Supabase.instance.client.auth.currentSession!.expiresAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000) {
        var result = await Supabase.instance.client.auth.refreshSession();
        return result.session!.accessToken;
      }
      return Supabase.instance.client.auth.currentSession!.accessToken;
    }
    return "Error getting token";
  }

I will try an test some more. Maybe someone can use my fix or maybe improve on it.

Vinzent03 commented 11 months ago

@larsbloch I can't reproduce your issue. On android, it always refreshes the token correctly for me. Are you doing some calls immediately after reopening so that it maybe didn't have the time to refresh it? Are you using a custom local storage implementation?

larsbloch commented 11 months ago

Hello @Vinzent03, Thanks for responding. We are not using any custom local storage. It does not refresh the page immediately. But i do force it to reload some data after a second. When i fully close the app and open it again, then tokens are working.

i also need to mention that it is not an app through the appstore but saves as an app from an url. I am also using android.

How does token refresh work. Does the supabase_flutter package check expiry each time i try to get the token using Supabase.instance.client.auth.currentSession!.accessToken

Or does it have some other internal logic?

I am using the token to query an api, so Supabase wont know if the token generates an error.

Vinzent03 commented 11 months ago

i also need to mention that it is not an app through the appstore but saves as an app from an url.

@larsbloch What exactly do you mean? Are you using a flutter webapp or you are just installing an android app not via the play store?

We are using didChangeAppLifecycleState() to check when the app gets to foreground again.

larsbloch commented 11 months ago

@Vinzent03 Yes it is a flutter web app. We open the app in chrome and choose to add it to the desktop. It is not a "real" app.

Googling didChangeAppLifecycleState(), then it looks like it does not work on flutter web. Since it is a "webapp" this might not be sufficient to refresh the token https://github.com/flutter/flutter/issues/53107

Some even has a workaround https://stackoverflow.com/questions/68367780/flutter-web-how-to-detect-applifecyclestate-changes

Could this be causing the issue we are seing ?

We will try to test it using debugging from pc on phone on monday to get som more info on how the tokens and refreshsession works for us.

Vinzent03 commented 11 months ago

That could indeed behave differently. Will try to test this today.

@KirioXX @jmsandiegoo Are you using webapps as well, or are you using native android/iOS applications?

KirioXX commented 11 months ago

Hey Vinzent03, we only have a native iOS app.

larsbloch commented 11 months ago

Would be awesome if we knew what caused it. I will await your test. Let me know if you need more information.

dshukertjr commented 10 months ago

@KirioXX @Vinzent03 has shipped an update that makes sure that the SDK always refreshes the access token before sending out the request if the token is expired, but would you be able to update supabase_flutter to the latest version and see if the error you are facing is mitigated?

larsbloch commented 10 months ago

@dshukertjr @Vinzent03 Im not sure if this would be something that would fix anything for the issue i am facing. But i have tried updating to 1.10.10 and i still get the same errors. Have you tested my issue ?

Vinzent03 commented 10 months ago

@larsbloch Hmm which exact errors do you get again?

larsbloch commented 10 months ago

@Vinzent03 Yes ofcource. Let me summarize the previous posts.

We have website in flutter web we want users to use as a flutter webapp. This works completely fine untill i close the app and open it up after the token has expired.

We use this piece of code to get the token

  Future<String> getSignedInUserToken() async {
    if (Supabase.instance.client.auth.currentSession != null) {

      return Supabase.instance.client.auth.currentSession!.accessToken;
    }
    return "Error getting token";
  }

But for some reason the web app keeps using the expired token (as per logs of the backend). The token does not get refreshed. If i keep making requests in my app it eventually refresh the token. Sometimes it works a couple of seconds after i get the error. Other times i have to completely logout. I have testet it throughout the day with a 1 minute expiration on tokens.

You told me you used something called didchangeapplifecyclestate Googling didChangeAppLifecycleState(), then it looks like it does not work on flutter web. Since it is a "webapp" this might not be sufficient to refresh the token https://github.com/flutter/flutter/issues/53107

Some even has a workaround https://stackoverflow.com/questions/68367780/flutter-web-how-to-detect-applifecyclestate-changes

I have also tried refreshing the session like this as a hack, but it doesnt seem to work

   Future<String> getSignedInUserToken() async {
    if (Supabase.instance.client.auth.currentSession != null) {
      if (Supabase.instance.client.auth.currentSession!.expiresAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000) {
        await Supabase.instance.client.auth.refreshSession();
      }
      return Supabase.instance.client.auth.currentSession!.accessToken;
    }
    return "Error getting token";
  }
Vinzent03 commented 10 months ago

So you are using the token for some custom api? The supabase client should take care of the refreshing for its own requests. When you use your own refreshing, what exactly doesn't work? Is the returned access token still expired, or does the token refresh fail.