erquhart
erquhart5mo ago

Convex Auth signaling unauthenticated in RN after inactive

I haven't been able to capture much on this, but I've seen this consistently since migrating to Convex Auth: - After opening the iOS app when it's been in the background, we're redirecting to sign in - The logic currently in place will only do this if both isAuthenticated and isLoading from useConvexAuth() are false. - I've added logic to listen for further changes to these values, and also added a user query that waits for an authenticated user to come back, none of this is working - Closing the app completely and reopening causes the currently authenticated user to be recognized I'm trying to get more insight to share, but wanted to bring this here in case anything jumps out at anyone. It's becoming a bit of a pain that the app effectively redirects to sign on most occasions, even though the user is authenticated. I'm not building for web, and am only assuming this is RN specific. Maybe something to do with secure storage?
41 Replies
Michal Srb
Michal Srb5mo ago
How are you doing the redirecting? Can you share your code?
erquhart
erquhartOP5mo ago
const Layout = () => {
const { isLoading, isAuthenticated } = useConvexAuth()

if (isLoading) {
return null
}

if (!isAuthenticated) {
console.log('not authenticated, redirecting')
return <Redirect href={signInRoute()} />
}

return (
<Stack ... />
)
}
const Layout = () => {
const { isLoading, isAuthenticated } = useConvexAuth()

if (isLoading) {
return null
}

if (!isAuthenticated) {
console.log('not authenticated, redirecting')
return <Redirect href={signInRoute()} />
}

return (
<Stack ... />
)
}
It's a layout file that's close to root, wraps the app, and is wrapped by the provider tree. My naive take is isLoading should always be true if auth state hasn't been ascertained. I don't know how it's flipping to unauthenticated when the auth state hasn't changed. Somehow it's possible for isLoading and isAuthenticated to both be false while the user is still authenticated.
Michal Srb
Michal Srb5mo ago
Sounds like a bug Do you have a repo I can repro it with?
erquhart
erquhartOP5mo ago
I need to put one together, I'll do that
Michal Srb
Michal Srb5mo ago
@erquhart please upgrade convex and @convex-dev/auth to latest
erquhart
erquhartOP5mo ago
Done. Find something possibly related to this?
Michal Srb
Michal Srb5mo ago
Possibly, yeah See if you can still reproduce your issue
erquhart
erquhartOP5mo ago
Still happening it seems. I'll let you know when I have a repro case. It's a pain because it requires a running app on iOS. Might work in simulator, but still a pain to repro in a shareable fashion. Will be easier once I'm on test flight.
Michal Srb
Michal Srb5mo ago
I'd try logging from secure storage to make sure it's working as expected
erquhart
erquhartOP5mo ago
@Michal Srb I have logging in the secure storage object now, just caught it in action:
LOG removeItem args ["__convexAuthRefreshToken_<redacted>"]
LOG removeItem result undefined
LOG WebSocket reconnected
LOG getItem args ["__convexAuthRefreshToken_<redacted>"]
LOG getItem result null
LOG appState active
LOG isLoading false
LOG isAuthenticated false
LOG removeItem args ["__convexAuthRefreshToken_<redacted>"]
LOG removeItem result undefined
LOG WebSocket reconnected
LOG getItem args ["__convexAuthRefreshToken_<redacted>"]
LOG getItem result null
LOG appState active
LOG isLoading false
LOG isAuthenticated false
As far as I can tell, when I opened the app (which was in the background for some time), removeItem ran immediately. Then getItem ran, and of course, found no token. Then you can see logged updates there from useConvexAuth(), at which point I was redirected to sign in. Did not sign in, closed the app and reopened, here are the logs that followed (duplicates removed):
LOG appState active
LOG isLoading true
LOG isAuthenticated false
LOG getItem args ["__convexAuthJWT_<redacted>"]
LOG getItem result <redactedJWT>
8/17/2024, 2:38:37 PM [CONVEX H(GET /.well-known/openid-configuration)] Function executed in 1134 ms
8/17/2024, 2:38:38 PM [CONVEX H(GET /.well-known/jwks.json)] Function executed in 482 ms
LOG appState active
LOG isLoading false
LOG isAuthenticated true
LOG getItem args ["__convexAuthRefreshToken_<redacted>"]
LOG getItem result <redactedToken>
LOG removeItem args ["__convexAuthRefreshToken_<redacted>"]
LOG removeItem result undefined
8/17/2024, 2:38:38 PM [CONVEX M(auth:store)] Function executed in 312 ms
LOG setItem args ["__convexAuthJWT_<redacted>", "<redactedJWT>"]
LOG setItem result undefined
LOG setItem args ["__convexAuthRefreshToken_<redacted>", "<redactedToken>"]
LOG setItem result undefined
LOG appState active
LOG isLoading true
LOG isAuthenticated false
LOG getItem args ["__convexAuthJWT_<redacted>"]
LOG getItem result <redactedJWT>
8/17/2024, 2:38:37 PM [CONVEX H(GET /.well-known/openid-configuration)] Function executed in 1134 ms
8/17/2024, 2:38:38 PM [CONVEX H(GET /.well-known/jwks.json)] Function executed in 482 ms
LOG appState active
LOG isLoading false
LOG isAuthenticated true
LOG getItem args ["__convexAuthRefreshToken_<redacted>"]
LOG getItem result <redactedToken>
LOG removeItem args ["__convexAuthRefreshToken_<redacted>"]
LOG removeItem result undefined
8/17/2024, 2:38:38 PM [CONVEX M(auth:store)] Function executed in 312 ms
LOG setItem args ["__convexAuthJWT_<redacted>", "<redactedJWT>"]
LOG setItem result undefined
LOG setItem args ["__convexAuthRefreshToken_<redacted>", "<redactedToken>"]
LOG setItem result undefined
So ended up authenticated without having to sign in again. Naive observations: - the initial removeItem on the refresh token seems unexpected - after getItem for refresh token failed, __convexAuthJWT_<> wasn't accessed before isAuthenticated was set to false. I would have expected an attempt to get a new token before marking the user unauthenticated appState is using react native's AppState.current, which I don't believe triggers rerenders. I've updated to using an event listener, so should capture any changes in app state that might be affecting things moving forward, will update if so.
Riki
Riki5mo ago
@erquhart I believe I'm encountering the same issue with React Native, and it's becoming a significant problem for us. In our case, we're using Firebase Auth instead of Convex Auth. Scenario: Users are logged in, and after some time, when the app returns from the background, the Convex auth hook returns isLoading: false, isAuthenticated: false, even though they were authenticated before the app went into the background. When logging these requests on the backend, the userIdentity?.subject is null. I suspect this might be related to a race condition between auth tokens expiring and not being refreshed in time while trying to reconnect to Convex. What leads me to this conclusion is that if I put the app in the background for 5 minutes, I don't encounter any issues. However, if I leave the app in the background for 2 days, I am guaranteed to face the issue, suggesting that the problem is related to the expiration of ID tokens. That could also be related that the iOS app state becomes "suspended" after some time. I haven't checked if the issue occurs on Android
erquhart
erquhartOP5mo ago
Thanks for sharing this, interesting that it's happening without using Convex Auth. I narrowed it down to Convex Auth because I used Convex with Clerk for about a year and never encountered the issue. I'll need to take a look at my implementation from before and see if maybe something about the approach was somehow getting around the issue successfully. cc/ @Michal Srb just wanted to make sure you saw all of the latest info here. Signs seem to be pointing in the general direction of token refresh (as far as one can tell). @Riki does closing and reopening your app work when the bug occurs for you or does the user still have to reauthenticate?
Riki
Riki5mo ago
Closing and reopening the app works for me, but that's because I use a different approach than you. Instead of using authentication flags/boolean from Convex hook, I use the the ones from Firebase, so basically we have something similar to this:
const Layout = () => {
// pseudocode. Firebase auth always persist sessions
const { isLoading, isAuthenticated } = useFirebaseAuth()

if (isLoading) {
return null
}


if (!isAuthenticated) {
console.log('not authenticated, redirecting')
return <Redirect href={signInRoute()} />
}

return (
<Stack ... />
)
}
const Layout = () => {
// pseudocode. Firebase auth always persist sessions
const { isLoading, isAuthenticated } = useFirebaseAuth()

if (isLoading) {
return null
}


if (!isAuthenticated) {
console.log('not authenticated, redirecting')
return <Redirect href={signInRoute()} />
}

return (
<Stack ... />
)
}
I am still having the same issue as you as, some of my queries require authentication, and if the user is not authenticated, I have decided to make them throw a ConvexError. TLDR: after some time the user loses its auth status on Convex => queries are still running => they throw an error => it crashes the app => by closing and reopening the app, firebase auth still keeps the authentication and convex hook "reboots" with auth again. @erquhart It seems we have the same symptoms, but I am not 100% sure yet that is because of the same causes In case that helps, this is what we pass to the convex component:
import auth from '@react-native-firebase/auth';
import {useCallback, useEffect, useMemo, useState} from 'react';

export function useAuthFromFirebase() {
const [isAuthenticated, setIsAuthenticated] = useState<undefined | boolean>(
undefined,
);

useEffect(() => {
const subscription = auth().onAuthStateChanged(user => {
if (user) {
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
});
return subscription;
}, []);

const fetchAccessToken = useCallback(
async ({forceRefreshToken}: {forceRefreshToken: boolean}) => {
// Here you can do whatever transformation to get the ID Token
// or null
// Make sure to fetch a new token when `forceRefreshToken` is true
const idToken = await auth().currentUser?.getIdToken(forceRefreshToken);
return idToken ?? null;
},
// If `getToken` isn't correctly memoized
// remove it from this dependency array
[],
);
return useMemo(
() => ({
// Whether the auth provider is in a loading state
isLoading: isAuthenticated == null ? true : false,
// Whether the auth provider has the user signed in
isAuthenticated: isAuthenticated ?? false,
// The async function to fetch the ID token
fetchAccessToken,
}),
[isAuthenticated, fetchAccessToken],
);
}
import auth from '@react-native-firebase/auth';
import {useCallback, useEffect, useMemo, useState} from 'react';

export function useAuthFromFirebase() {
const [isAuthenticated, setIsAuthenticated] = useState<undefined | boolean>(
undefined,
);

useEffect(() => {
const subscription = auth().onAuthStateChanged(user => {
if (user) {
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
});
return subscription;
}, []);

const fetchAccessToken = useCallback(
async ({forceRefreshToken}: {forceRefreshToken: boolean}) => {
// Here you can do whatever transformation to get the ID Token
// or null
// Make sure to fetch a new token when `forceRefreshToken` is true
const idToken = await auth().currentUser?.getIdToken(forceRefreshToken);
return idToken ?? null;
},
// If `getToken` isn't correctly memoized
// remove it from this dependency array
[],
);
return useMemo(
() => ({
// Whether the auth provider is in a loading state
isLoading: isAuthenticated == null ? true : false,
// Whether the auth provider has the user signed in
isAuthenticated: isAuthenticated ?? false,
// The async function to fetch the ID token
fetchAccessToken,
}),
[isAuthenticated, fetchAccessToken],
);
}
erquhart
erquhartOP5mo ago
I've had an ongoing parallel issue where a user signs in and is never redirected. I'm now seeing that when this occurs, the user is being successfully signed via convex auth, but isAuthenticated and isLoading aren't changing, so there's no signal that the user is authenticated.
Riki
Riki4mo ago
@Michal Srb or @erquhart let me know if you prefer that I open another discussion, but I am pretty sure about what's happening right now on my side Firebase tokens have an expiration time of one hour. When I close the app for 1hour+, and I open it again with a bad network on my phone, the connection with convex servers seems to run before the token is refreshed by Firebase. Given I throw an error on Convex side if the user is not authenticated, the app crashes (I handle it with error boundaries)
erquhart
erquhartOP4mo ago
@Riki it does seem possible that yours is a separate issue. OP here has to do with a valid auth session not resulting in isAuthenticated being set to true. But I still don't understand the problem enough to be certain they're different.
erquhart
erquhartOP4mo ago
I'm verbose logging from convex auth now, and I see that a token is present and acknowledged, but isAuthenticated remains false. Again, closing and reopening the app fixes it.
No description
erquhart
erquhartOP4mo ago
There's a bug somewhere where convex auth client lib is not setting isAuthenticated in the main convex client.
Riki
Riki4mo ago
Ah yes indeed, it looks like it's a different issue
erquhart
erquhartOP4mo ago
cc/ @Michal Srb just want to make sure my breadcrumbs here haven't been overlooked, left some info here as well: https://discord.com/channels/1019350475847499849/1272981519115223120/1274436848810332210
sshader
sshader4mo ago
(FYI I do have this on my radar as something to look into, but haven't looked deeply yet)
erquhart
erquhartOP4mo ago
Thanks for the update!
adam
adam4mo ago
@sshader could you please look into this? I'm also experiencing this issue with my React Native app, and it's very frustrating for users to be unexpectedly logged out. I'm using custom auth, connected to AWS Cognito. The setup follows the approach described here: https://docs.convex.dev/auth/advanced/custom-auth
erquhart
erquhartOP4mo ago
It's been hard to capture any more than what I shared before in this thread, but my app generally appears logged out whenever opened from the background. Still working through launch and App Store review, but this will be top of list pretty soon.
adam
adam4mo ago
I suspect it is related to userIdentity being set to null under some conditions, when it should not be.
const userIdentity: UserIdentity | null = await ctx.auth.getUserIdentity();
const userIdentity: UserIdentity | null = await ctx.auth.getUserIdentity();
sshader
sshader4mo ago
Starting to look into this -- I understand it's frustrating and thanks for the patience so far (for context, I'm still in the process of ramping up on Convex Auth and trying to field all the incoming questions in this area, so my turnaround time is slower than I'd like)
erquhart
erquhartOP4mo ago
Definitely understand. Hope some of the info in the thread is helpful to narrow things down. Will add more if I find anything.
sshader
sshader4mo ago
Any tips for reproducing? Such as how consistently this happens when coming back from backgrounded + how long the app is backgrounded? I currently have an Expo app that I'm opening on my iPhone with all the same logging. I've been able to hit the sign in screen when backgrounding my app for longer than the JWT duration (which I set to 1 min for testing, but defaults to 1 hour). Does any of this seem consistent with what y'all have been seeing?
erquhart
erquhartOP4mo ago
I'm not totally sure on the timing, doesn't seem to take an hour for me but that is not based on any data. Could very well be an hour.
adam
adam4mo ago
Thanks sshader. Could it be related to web socket’s connection expiring? Is there an idle timeout or anything like this when using web sockets? I’ll try observing how long the app needs to be in the background for before it triggers this issue.
sshader
sshader4mo ago
Ok try upgrading Convex Auth (0.0.64). I'm hopeful that this is the fix (https://github.com/get-convex/convex-auth/pull/63)
GitHub
Add refresh token mutex + don't leak beforeunload listener by sshad...
I believe this fixes two bugs: In React Native (or any environment where window.navigator.locks is undefined), we can get re-entrant calls to fetchAccessToken where one call clears the old refresh...
erquhart
erquhartOP4mo ago
Upgrading now! The code change and assessment definitely feel spot on.
sshader
sshader4mo ago
If I'm right, I think this means that for web, we cutely avoided this problem of callbacks in the same tab stepping on each other by fixing a different problem of keeping different tabs from stepping on each other.
adam
adam4mo ago
I’m using custom auth though, not Convex Auth. It could be a seperate issue, I guess
sshader
sshader4mo ago
@adam -- if you could share more of your code or turn on verbose logs and share logs ({ verbose: true } when instantiating ConvexReactClient), I'm happy to dig more. The Convex Auth issue was an unexpected interleaving of fetchAccessToken calls resulting in one of them returning null incorrectly. Curious what your fetchAccessToken looks like and whether it could have a similar problem (also sorry -- missed that this was custom auth vs. a custom auth provider with convex auth)
erquhart
erquhartOP4mo ago
Still need to get a production release out to confirm whether this is fixed for me - I've seen it happen once since in dev, but that may have been a fluke. Will circle back asap. Thanks so much for digging into this @sshader!
adam
adam4mo ago
@sshader I've confirmed the issue I'm experiencing is related to the refresh token flow. Here are the details to reproduce: 1) Use Convex custom auth with AWS Cognito and Expo 2) Create a production build of the app 3) Set Cognito refresh token to min (60min) 4) Open the app and then send it to the background 5) Bring the app back to the foreground after 60min to observe the issue const userIdentity: UserIdentity | null = await ctx.auth.getUserIdentity(); I assume it's incorrectly being set to null here. I'm working around this issue for now by extending my refresh token expiry period. For reference here is my fetchAccessToken function:
/**
* Convex calls this function automatically with `forceRefreshToken` set to `true`.
* https://docs.convex.dev/auth/advanced/custom-auth
*/
const fetchAccessToken = useCallback(
async ({ forceRefreshToken }: { forceRefreshToken: boolean }) => {
try {
if (forceRefreshToken) {
const user: CognitoUser = await Auth.currentAuthenticatedUser({ bypassCache: true });
}
const session = await Auth.currentSession();
setIsAuthenticated(true);
return session.getIdToken().getJwtToken();
} catch (error) {
setIsAuthenticated(false);
return null;
}
},
[]
);
/**
* Convex calls this function automatically with `forceRefreshToken` set to `true`.
* https://docs.convex.dev/auth/advanced/custom-auth
*/
const fetchAccessToken = useCallback(
async ({ forceRefreshToken }: { forceRefreshToken: boolean }) => {
try {
if (forceRefreshToken) {
const user: CognitoUser = await Auth.currentAuthenticatedUser({ bypassCache: true });
}
const session = await Auth.currentSession();
setIsAuthenticated(true);
return session.getIdToken().getJwtToken();
} catch (error) {
setIsAuthenticated(false);
return null;
}
},
[]
);
erquhart
erquhartOP4mo ago
@sshader I'm still a bit away from my next production release, but I'm pretty confident this is fixed. Used to happen many times a day in dev and has not happened since this fix. Marking resolved. @adam I know both of our issues seem to overlap around token refresh, but my issue and the fix were Convex Auth specific - it didn't happen when we were on clerk. I'd recommend opening a separate post focused on what you're experiencing with custom auth. Your issue may be the same as @Riki's based on his comments, but it doesn't look like he made a separate post either.
sshader
sshader4mo ago
Thanks for the added info -- what is Auth here? (my guess is https://www.npmjs.com/package/amazon-cognito-auth-js but I want to check)
npm
amazon-cognito-auth-js
Amazon Cognito Auth JavaScript SDK. Latest version: 1.3.3, last published: 4 years ago. Start using amazon-cognito-auth-js in your project by running npm i amazon-cognito-auth-js. There are 25 other projects in the npm registry using amazon-cognito-auth-js.
adam
adam4mo ago
import { Amplify, Auth, Hub } from 'aws-amplify';
Riki
Riki4mo ago
@erquhart is right, for a better history search and further comments, it may be better to not mix the topics. If you don't mind, let's discuss to this new topic dedicated to this JTW expiration issue: https://discord.com/channels/1019350475847499849/1283351896765235230