Waffleophagus
Waffleophagus4mo ago

"Upgrading" Anonymous accounts to "real" accounts with email

Out of the gate when you log in to my react native application, I give you an anonymous account. My goal is to push the user into signing up and at that point we "upgrade" the account from an anon to a "proper" account. I know that this will require some custom account linking outlined briefly here: https://labs.convex.dev/auth/advanced#account-linking But from where I'm sitting I'm trying to nail down the best pattern that makes it such that the user does not lose any data. The goal being give them access to the app with as little barrier as possible and make them want to sign up. The thing that is a sticking point is right now I'm using Resend OTP codes, and I don't see a clean way to just give the built in Resend/Email functionality an existing userID, and the built in flow generates a new UserID when the flow begins. Is there a way to better call these internal things or should I just kinda... re-implement the OTP functionality? Will I have to do the same with the OAuth providers when I get to that? (Was planning on adding them in addition to OTP when the app is closer to production) Is there an example somewhere of this upgrade path? I'll happily share anything I have so far.
Advanced: Details - Convex Auth
Authentication library for your Convex backend
35 Replies
Waffleophagus
WaffleophagusOP4mo ago
Ok, I just had an idea and wanted to know how dumb it is: 1. Call hook that takes in email + anon user id 2. Check if email is in use, if so, normal auth flow. If not in use, store in an anon userID+email table 3. Pass off to normal OTP flow 4. When OTP is complete, if email is in pivot table, replace userID. Reference: https://labs.convex.dev/auth/api_reference/server#callbacksafterusercreatedorupdated
server - Convex Auth
Authentication library for your Convex backend
Waffleophagus
WaffleophagusOP4mo ago
Or, as an alternative, if swapping out the raw _id is a bad idea (it’s probably a real bad idea isn’t it?) Changing the userID on the authAccount table Update the anon user table to change the email, email verification time, and isAnon field Delete the NEW account
erquhart
erquhart4mo ago
Hmm yeah the catch with account linking seems to be point 3:
If an untrusted method is used, the new account will not be linked to any existing one.
I'm curious how this is supposed to work for Auth.js, like I would assume this is the primary use case for the Anonymous provider.
erquhart
erquhart4mo ago
Found this docs page about Anonymous users, I don't know if it's excluded from the nav because it's somehow incorrect, but it's here: https://labs.convex.dev/auth/config/anonymous
Anonymous Users - Convex Auth
Authentication library for your Convex backend
erquhart
erquhart4mo ago
It also cites implementing custom account linking with a createOrUpdateUser function, so there should be a way to do it from there
erquhart
erquhart4mo ago
Here's a test marked todo for converting a user, so you can at least see the intent: https://github.com/get-convex/convex-auth/blob/aa27918cdf69ceced04651350be77c36a179cbec/test/convex/anonymous.test.ts#L20-L37
GitHub
convex-auth/test/convex/anonymous.test.ts at aa27918cdf69ceced04651...
Library for built-in auth. Contribute to get-convex/convex-auth development by creating an account on GitHub.
Waffleophagus
WaffleophagusOP4mo ago
Thanks for the response! Instead of replying to every one and @ing you like 4 times I’ll just do it in one go. I found that doc, and saw the vague upgrade path, and hand waves “do it custom” I guess my question at this point is what is the cleanest way to do that? That said, this specific callout I totally missed and makes a ton of sense. The “won’t be linked if insecure” thing. That explains a lot. I think, for now, especially if the test for how to do this is a “todo” state is make a second call to associate the anon userID in an unofficial manner to the email, so I can go through and move all the user data from the old to the new, cause where I’m sitting, once you start the auth process any link to the anon account kinda dies? So like 1. Make a call to an email/userID pivot table, (add logic if the email already exists in the system but let’s assume not for now) 2. Normal auth flow 3. Move user data from ID stored in 1 to the newly formed user. 4. Be really careful to not give a user the ability to hijack an account this way (oh buddy this one will be a doozy) I’m half tempted to not use anon accounts for now sadly
erquhart
erquhart4mo ago
Yeah I'm not sure what a reliable path would look like here, seems like maybe one never quite landed
Waffleophagus
WaffleophagusOP4mo ago
Ok, would love a sanity check, cause I think this is the path forward: 1. Before login, client calls the back end with their current anon userID and email they intend to link 2. That endpoint checks if the email exists in the system, if it doesn’t it creates the pivot table as mentioned. If it does it just returns doing nothing (since this is a user logging in, not signing up) 3. Using the above mentioned afterUserCreated call, query the pivot table for the freshly added email, if it exists in the table, move all user data from that anonymous account to the newly created one and delete the entry in the table. That’s…. Safe right? I don’t see a clear way to exploit this
erquhart
erquhart4mo ago
Yeah, especially considering an anonymous account really shouldn't be allowed to have any security concern worthy data, should be fine So if there's anything they can do in the app that would result in sensitive data existing in the db, good spot to require a full sign in
Waffleophagus
WaffleophagusOP4mo ago
Fwiw, my app isn’t really one where security is a huge concern to begin with, it’s gonna be like a miles per gallon tracker on steroids My main concern is that some how in this process you make an anon account and do this process and hijack my account somehow But I don’t see a way to do that
erquhart
erquhart4mo ago
Anon accounts can't be re-logged into I don't think, unless you're doing something custom. So no concern there.
Waffleophagus
WaffleophagusOP4mo ago
It’s a react native app, I actually have it refreshing the token on app re-launch, so you can effectively use the app for a while without “signing up” the idea here would be to let them figure out how to use the app and get a good taste before requiring it.
erquhart
erquhart4mo ago
Right, but there's no way for anyone to log in and get an existing anon account. Even that anon user, if they signed out, wouldn't have a way to sign back in to that same account. (So no way to hijack) Again, that's how anon accounts work out of the box, haven't looked at whether configuration can change this.
Waffleophagus
WaffleophagusOP4mo ago
Ohhhh I see what you’re saying. That’s a really good point. You’ve been wildly helpful @erquhart! thank you so much. I have a good path forward now I think
dent_arthur_42
dent_arthur_423mo ago
Hi @Waffleophagus ! FWIW, I had a few observations from my own explorations - 1. Inside the afterUserCreatedOrUpdated hook, we can use ctx.auth.getUserIdentity() which returns the old token. We can extract the old userId from there and send over their files to the new userId from args. 2. The hook useAuthState always reads the updated value of token. This can be used a key on AuthContextProvider (after memoisations) to reload the entire app without needing to restart.
Waffleophagus
WaffleophagusOP3mo ago
1. Awesome name sir. 2. I think you might've just fixed one of my bugs. I wildly appreciate it. I'll dig into this! Wait, I posted a bunch of code on how I did this... possibly in my other thread? ohhh I had to re-read your post like 3 times
dent_arthur_42
dent_arthur_423mo ago
The only problem pending is that when a user "signs in" after doing some work on auth, then their old session details get lost. I think there is a need of afterUserCreatedOrUpdated analogue in login also
Waffleophagus
WaffleophagusOP3mo ago
Its been a long day 😛 Either way I do think you will make my auth flow way simplier
dent_arthur_42
dent_arthur_423mo ago
Yes, that is what I am also doing (since any of the above do not handle account upgradation on signIn anyway) ! Thanks for that! The solution seems to be a callback like https://www.better-auth.com/docs/plugins/anonymous#link-account because we want to fire it both on sign up and sign in (for upgradation)
Waffleophagus
WaffleophagusOP3mo ago
Yea, I'm holding off on betterAuth for now till its more fully baked https://discord.com/channels/1019350475847499849/1367292670837264594/1367699076723904573 Here's roughly what I have now I think it might've evolved a little bit? But not much Either way, I think you may be improving upon it With the getUserIdentity returning the old token, I may be able to kill an entire table I'll be the first to admit that I am still actively learning a lot of React, and React Native, so I actually do have the "I need to reboot the app to get the new login state" issue at the moment I was pushing that down the road, given that I'm trying to clean it up to show to some friends who will be OK with just killing and restarting the app exactly once to be logged in forever 😛 Buuuttt now I think I have a good path forward!
dent_arthur_42
dent_arthur_423mo ago
Something like this should help with that at least. - It uses the sessionId extracted from token as a key forcing reload when key changes
"use client";

import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs";
import { useAuthToken } from "@convex-dev/auth/react";
import { ConvexReactClient } from "convex/react";
import { ReactNode, useMemo } from "react";

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!, {
verbose: true,
});

export default function ConvexClientProvider({
children,
}: {
children: ReactNode;
}) {
const token = useAuthToken();

const sessionId = useMemo(() => {
console.log("token", token);
if (token) {
const [, payload] = token.split(".");
const decodedPayload = JSON.parse(
atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
)["sub"].split("|")[1];
console.log("decoded token payload:", decodedPayload);
return decodedPayload;
}
}, [token]);

return (
<ConvexAuthNextjsProvider key={sessionId} client={convex}>
{children}
</ConvexAuthNextjsProvider>
);
}
"use client";

import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs";
import { useAuthToken } from "@convex-dev/auth/react";
import { ConvexReactClient } from "convex/react";
import { ReactNode, useMemo } from "react";

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!, {
verbose: true,
});

export default function ConvexClientProvider({
children,
}: {
children: ReactNode;
}) {
const token = useAuthToken();

const sessionId = useMemo(() => {
console.log("token", token);
if (token) {
const [, payload] = token.split(".");
const decodedPayload = JSON.parse(
atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
)["sub"].split("|")[1];
console.log("decoded token payload:", decodedPayload);
return decodedPayload;
}
}, [token]);

return (
<ConvexAuthNextjsProvider key={sessionId} client={convex}>
{children}
</ConvexAuthNextjsProvider>
);
}
Waffleophagus
WaffleophagusOP3mo ago
Wait... the context auth provider... I don't think takes a key? Am I going crazy?
Waffleophagus
WaffleophagusOP3mo ago
https://labs.convex.dev/auth/setup#add-authentication-tables-to-your-schema Yea, for react native, I'm pretty much doing exactly what is laid out here
Set Up Convex Auth - Convex Auth
Authentication library for your Convex backend
Waffleophagus
WaffleophagusOP3mo ago
for react native
dent_arthur_42
dent_arthur_423mo ago
No it doesn't.. It's an extra prop
Waffleophagus
WaffleophagusOP3mo ago
Oh! Ok. Didn't have my IDE open... lets take a look Oh actually...
Waffleophagus
WaffleophagusOP3mo ago
<SafeAreaProvider>
<ExpoStatusBar style="auto" />
<ConvexProvider client={convex}>
<ConvexAuthProvider
client={convex}
storage={Platform.OS === "android" || Platform.OS === "ios" ? secureStorage : undefined}
>
<KeyboardProvider>
<ThemeProvider value={{ themeScheme, setThemeContextOverride }}>
<AnonymousSignIn />
<SyncEngine store={rootStore} />
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
{/* <Slot /> */}
</Stack>
</ThemeProvider>
</KeyboardProvider>
</ConvexAuthProvider>
</ConvexProvider>
</SafeAreaProvider>
<SafeAreaProvider>
<ExpoStatusBar style="auto" />
<ConvexProvider client={convex}>
<ConvexAuthProvider
client={convex}
storage={Platform.OS === "android" || Platform.OS === "ios" ? secureStorage : undefined}
>
<KeyboardProvider>
<ThemeProvider value={{ themeScheme, setThemeContextOverride }}>
<AnonymousSignIn />
<SyncEngine store={rootStore} />
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
{/* <Slot /> */}
</Stack>
</ThemeProvider>
</KeyboardProvider>
</ConvexAuthProvider>
</ConvexProvider>
</SafeAreaProvider>
I'm doubling up on Convex providers wonder if that's also causing like... a lot of issues I didn't realize till this conversation that its supposed to replace your convex provider. turns out reading the documentation/comments in the code explains the code!
dent_arthur_42
dent_arthur_423mo ago
Haha maybe... Also, it the useAuthToken doesn't work, make a new state for key and increment it to force re-render.
const [key, setKey] = useState(0);
const incrementKey = () => setKey(key+1);
const [key, setKey] = useState(0);
const incrementKey = () => setKey(key+1);
Pass it to your <ConvexAuthProvider
<ConvexAuthProvider
key={key}
client={convex}
storage={Platform.OS === "android" || Platform.OS === "ios" ? secureStorage : undefined}
>
....
<ConvexAuthProvider
key={key}
client={convex}
storage={Platform.OS === "android" || Platform.OS === "ios" ? secureStorage : undefined}
>
....
Pass and use setKey to trigger re-render so that updated value of token is used.
Waffleophagus
WaffleophagusOP3mo ago
Oh, yea, ok, you're definitely using a different auth provider. I'm a bit confused still cause digging in, I don't see where key is a param passed in? That said, this sounds like a pretty good idea regardless.
dent_arthur_42
dent_arthur_423mo ago
key prop isn't required or specific to this. It can be added to any component / context provider (AFAIK). Just a pattern to force reload. You should add it to ConvexAuthProvider so that it and all its children reload when key is changed
Waffleophagus
WaffleophagusOP3mo ago
Got it, so I can arbitrarily just pass things to force reloads. I see Like I said, learning as I go 🙂 I'm too used to compiled languages, and I've been doing server side stuff for like 13 years
dent_arthur_42
dent_arthur_423mo ago
I'm learning too.. just sharing what worked for me (only halfway though)... still working on this.
dent_arthur_42
dent_arthur_423mo ago
You can read more about key here - https://react.dev/learn/rendering-lists
Rendering Lists – React
The library for web and native user interfaces

Did you find this page helpful?