diff --git a/.changeset/oauth-signup-transaction.md b/.changeset/oauth-signup-transaction.md new file mode 100644 index 0000000000..6d955d0008 --- /dev/null +++ b/.changeset/oauth-signup-transaction.md @@ -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. diff --git a/packages/better-auth/src/oauth2/link-account.test.ts b/packages/better-auth/src/oauth2/link-account.test.ts index e273a883e2..3ee1da2c7a 100644 --- a/packages/better-auth/src/oauth2/link-account.test.ts +++ b/packages/better-auth/src/oauth2/link-account.test.ts @@ -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({ + model: "user", + where: [{ field: "email", value: testEmail }], + }); + expect(user).toBeNull(); + }); +}); + /** * @see https://github.com/better-auth/better-auth/issues/8906 * diff --git a/packages/better-auth/src/oauth2/link-account.ts b/packages/better-auth/src/oauth2/link-account.ts index 3fef30de27..8eb1197df7 100644 --- a/packages/better-auth/src/oauth2/link-account.ts +++ b/packages/better-auth/src/oauth2/link-account.ts @@ -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);