Skip to content

Commit 779fc70

Browse files
rorhugcursoragent
andcommitted
Pass authUserId to createOrUpdateUser
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent bd68f9b commit 779fc70

4 files changed

Lines changed: 155 additions & 1 deletion

File tree

docs/pages/advanced.mdx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ export const { auth, signIn, signOut, store, isAuthenticated } = {
8989
return args.existingUserId;
9090
}
9191

92+
if (args.authUserId) {
93+
// During OAuth account linking, attach the new account to the
94+
// currently authenticated user who started the flow.
95+
return args.authUserId;
96+
}
97+
9298
// Implement your own account linking logic:
9399
const existingUser = await findUserByEmail(ctx, args.profile.email);
94100
if (existingUser) return existingUser._id;
@@ -102,6 +108,10 @@ export const { auth, signIn, signOut, store, isAuthenticated } = {
102108
};
103109
```
104110

111+
`args.existingUserId` is the user already linked to the account being signed in,
112+
if one exists. `args.authUserId` identifies the currently authenticated user so
113+
you can link the new OAuth account to them. This is useful for OAuth flows started while signed in.
114+
105115
When you provide this callback, the library doesn't create or update users at
106116
all. It is up to you to implement all the necessary logic for all providers you
107117
use.
@@ -123,7 +133,7 @@ import { MutationCtx } from "./_generated/server";
123133
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
124134
providers: [GitHub, Password],
125135
callbacks: {
126-
// `args` are the same the as for `createOrUpdateUser` but include `userId`
136+
// `args` are the same as for `createOrUpdateUser` but include `userId`
127137
async afterUserCreatedOrUpdated(ctx: MutationCtx, { userId }) {
128138
await ctx.db.insert("someTable", { userId, data: "some data" });
129139
},

src/server/implementation/users.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,15 @@ async function defaultCreateOrUpdateUser(
5555
args,
5656
});
5757
const existingUserId = existingAccount?.userId ?? null;
58+
const authUserId =
59+
existingSessionId !== null
60+
? (await ctx.db.get(existingSessionId))?.userId ?? null
61+
: null;
5862
if (config.callbacks?.createOrUpdateUser !== undefined) {
5963
logWithLevel(LOG_LEVELS.DEBUG, "Using custom createOrUpdateUser callback");
6064
return await config.callbacks.createOrUpdateUser(ctx, {
6165
existingUserId,
66+
authUserId,
6267
...args,
6368
});
6469
}

src/server/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ export type ConvexAuthConfig = {
167167
* this is the existing user ID linked to that account.
168168
*/
169169
existingUserId: GenericId<"users"> | null;
170+
/**
171+
* If this OAuth flow was started while a user was already signed in,
172+
* this is that authenticated user ID recovered from the saved session.
173+
*/
174+
authUserId: GenericId<"users"> | null;
170175
/**
171176
* The provider type or "verification" if this callback is called
172177
* after an email or phone token verification.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/// <reference types="vite/client" />
2+
import GitHub from "@auth/core/providers/github";
3+
import { convexTest } from "convex-test";
4+
import { decodeJwt } from "jose";
5+
import { Password } from "../../src/providers/Password";
6+
import { convexAuth } from "../../src/server";
7+
import { expect, test } from "vitest";
8+
import { api } from "./_generated/api";
9+
import schema from "./schema";
10+
import {
11+
CONVEX_SITE_URL,
12+
JWKS,
13+
JWT_PRIVATE_KEY,
14+
signInViaGitHub,
15+
} from "./test.helpers";
16+
17+
const createOrUpdateUserAuth = convexAuth({
18+
callbacks: {
19+
async createOrUpdateUser(ctx, args) {
20+
if (args.existingUserId) {
21+
return args.existingUserId;
22+
}
23+
24+
if (args.authUserId) {
25+
await ctx.db.insert("messages", {
26+
userId: args.authUserId,
27+
body: JSON.stringify({
28+
type: args.type,
29+
authUserId: args.authUserId,
30+
existingUserId: args.existingUserId,
31+
}),
32+
});
33+
return args.authUserId;
34+
}
35+
36+
return await ctx.db.insert("users", {
37+
email: args.profile.email,
38+
name:
39+
typeof args.profile.name === "string" ? args.profile.name : undefined,
40+
});
41+
},
42+
},
43+
providers: [GitHub, Password],
44+
});
45+
46+
test("createOrUpdateUser links a new OAuth account to the signed-in user via authUserId", async () => {
47+
setupEnv();
48+
const modules = import.meta.glob("./**/*.*s");
49+
const overriddenModules = {
50+
...modules,
51+
"./auth.ts": async () => createOrUpdateUserAuth,
52+
};
53+
const t = convexTest(schema, overriddenModules);
54+
55+
const { tokens } = await t.action(api.auth.signIn, {
56+
provider: "password",
57+
params: { email: "sarah@gmail.com", password: "44448888", flow: "signUp" },
58+
});
59+
expect(tokens).not.toBeNull();
60+
61+
const claims = decodeJwt(tokens!.token);
62+
const asSarah = t.withIdentity({ subject: claims.sub });
63+
64+
const { tokens: oauthTokens } = await signInViaGitHub(asSarah, "github", {
65+
email: "github-only@example.com",
66+
name: "Sarah From GitHub",
67+
id: "someGitHubId",
68+
});
69+
expect(oauthTokens).not.toBeNull();
70+
71+
await t.run(async (ctx) => {
72+
const users = await ctx.db.query("users").collect();
73+
expect(users).toHaveLength(1);
74+
75+
const accounts = await ctx.db.query("authAccounts").collect();
76+
expect(accounts).toHaveLength(2);
77+
expect(accounts.find((account) => account.provider === "password")).toMatchObject({
78+
userId: users[0]._id,
79+
});
80+
expect(accounts.find((account) => account.provider === "github")).toMatchObject({
81+
userId: users[0]._id,
82+
});
83+
84+
const messages = await ctx.db.query("messages").collect();
85+
expect(messages).toHaveLength(1);
86+
expect(messages[0].userId).toEqual(users[0]._id);
87+
expect(JSON.parse(messages[0].body)).toEqual({
88+
type: "oauth",
89+
authUserId: users[0]._id,
90+
existingUserId: null,
91+
});
92+
});
93+
});
94+
95+
test("createOrUpdateUser creates a new user for unauthenticated OAuth sign-in", async () => {
96+
setupEnv();
97+
const modules = import.meta.glob("./**/*.*s");
98+
const overriddenModules = {
99+
...modules,
100+
"./auth.ts": async () => createOrUpdateUserAuth,
101+
};
102+
const t = convexTest(schema, overriddenModules);
103+
104+
const { tokens: oauthTokens } = await signInViaGitHub(t, "github", {
105+
email: "unauthenticated@example.com",
106+
name: "Unauthenticated User",
107+
id: "anotherGitHubId",
108+
});
109+
expect(oauthTokens).not.toBeNull();
110+
111+
await t.run(async (ctx) => {
112+
const users = await ctx.db.query("users").collect();
113+
expect(users).toHaveLength(1);
114+
115+
const accounts = await ctx.db.query("authAccounts").collect();
116+
expect(accounts).toHaveLength(1);
117+
expect(accounts.find((account) => account.provider === "github")).toMatchObject({
118+
userId: users[0]._id,
119+
});
120+
121+
const messages = await ctx.db.query("messages").collect();
122+
expect(messages).toEqual([]);
123+
});
124+
});
125+
126+
function setupEnv() {
127+
process.env.SITE_URL = "http://localhost:5173";
128+
process.env.CONVEX_SITE_URL = CONVEX_SITE_URL;
129+
process.env.JWT_PRIVATE_KEY = JWT_PRIVATE_KEY;
130+
process.env.JWKS = JWKS;
131+
process.env.AUTH_GITHUB_ID = "githubClientId";
132+
process.env.AUTH_GITHUB_SECRET = "githubClientSecret";
133+
process.env.AUTH_LOG_LEVEL = "ERROR";
134+
}

0 commit comments

Comments
 (0)