fix(oauth): create new oauth account in transaction (#10125)

This commit is contained in:
Gustavo Valverde
2026-06-17 22:58:04 -07:00
committed by GitHub
parent 06daf7011e
commit a83152e2e8
3 changed files with 125 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Create new OAuth accounts in the user creation transaction so failed account writes roll back the user row.
@@ -1,7 +1,10 @@
import { DatabaseSync } from "node:sqlite";
import type {
DiscordProfile,
GoogleProfile,
} from "@better-auth/core/social-providers";
import { NodeSqliteDialect } from "@better-auth/kysely-adapter/node-sqlite-dialect";
import { Kysely } from "kysely";
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import {
@@ -13,8 +16,11 @@ import {
it,
vi,
} from "vitest";
import { parseSetCookieHeader } from "../cookies";
import { betterAuth } from "../auth/full";
import { createAuthClient } from "../client";
import { parseSetCookieHeader, setCookieToHeader } from "../cookies";
import { signJWT } from "../crypto";
import { getMigrations } from "../db/get-migration";
import { getTestInstance } from "../test-utils/test-instance";
import type { User } from "../types";
import { DEFAULT_SECRET } from "../utils/constants";
@@ -1338,6 +1344,103 @@ describe("oauth2 - override user info on sign-in", async () => {
});
});
describe("oauth2 - sign-up account creation rollback", async () => {
const sqlite = new DatabaseSync(":memory:");
const database = new Kysely({
dialect: new NodeSqliteDialect({ database: sqlite }),
});
const auth = betterAuth({
baseURL: "http://localhost:3000",
database: {
db: database,
type: "sqlite",
transaction: true,
},
account: {
additionalFields: {
requiredAccountField: {
type: "string",
required: true,
},
},
},
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
enabled: true,
},
},
rateLimit: {
enabled: false,
},
});
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
const client = createAuthClient({
baseURL: "http://localhost:3000/api/auth",
fetchOptions: {
customFetchImpl: (url, init) => auth.handler(new Request(url, init)),
},
});
const ctx = await auth.$context;
afterAll(async () => {
await database.destroy();
});
it("rolls back the user row when the first OAuth account cannot be created", async () => {
const testEmail = "oauth-rollback@example.com";
server.use(
http.post("https://oauth2.googleapis.com/token", async () => {
const profile: GoogleProfile = {
sub: "google_rollback",
email: testEmail,
email_verified: true,
name: "Rollback User",
} as GoogleProfile;
const idToken = await signJWT(profile, DEFAULT_SECRET);
return HttpResponse.json({
access_token: "test_token",
id_token: idToken,
});
}),
);
const oAuthHeaders = new Headers();
const signInRes = await client.signIn.social({
provider: "google",
callbackURL: "/",
fetchOptions: {
onSuccess: setCookieToHeader(oAuthHeaders),
},
});
const state = new URL(signInRes.data!.url!).searchParams.get("state") || "";
let redirectLocation = "";
await client.$fetch("/callback/google", {
query: { state, code: "test_code" },
method: "GET",
headers: oAuthHeaders,
onError(context) {
expect(context.response.status).toBe(302);
redirectLocation = context.response.headers.get("location") || "";
setCookieToHeader(oAuthHeaders)(context);
},
});
expect(redirectLocation).toContain("error=unable_to_create_user");
const user = await ctx.adapter.findOne<User>({
model: "user",
where: [{ field: "email", value: testEmail }],
});
expect(user).toBeNull();
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/8906
*
@@ -2,6 +2,7 @@ import type {
GenericEndpointContext,
UserProvisioningSource,
} from "@better-auth/core";
import { runWithTransaction } from "@better-auth/core/context";
import { isDevelopment, logger } from "@better-auth/core/env";
import { createEmailVerificationToken } from "../api";
import { setAccountCookie } from "../cookies/session-store";
@@ -225,17 +226,23 @@ export async function handleOAuthUserInfo(
providerId: account.providerId,
accountId: userInfo.id.toString(),
};
const createdUser = await c.context.internalAdapter.createUser(
{
...restUserInfo,
email: userInfo.email.toLowerCase(),
const { createdUser, createdAccount } = await runWithTransaction(
c.context.adapter,
async () => {
const createdUser = await c.context.internalAdapter.createUser(
{
...restUserInfo,
email: userInfo.email.toLowerCase(),
},
source,
);
const createdAccount = await c.context.internalAdapter.createAccount({
...accountData,
userId: createdUser.id,
});
return { createdUser, createdAccount };
},
source,
);
const createdAccount = await c.context.internalAdapter.createAccount({
...accountData,
userId: createdUser.id,
});
user = createdUser;
if (c.context.options.account?.storeAccountCookie) {
await setAccountCookie(c, createdAccount);