From cbbf6843bf334a6aaac06fa972b9451925ef52ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paola=20Estefan=C3=ADa=20de=20Campos?= Date: Mon, 22 Jun 2026 17:15:22 -0700 Subject: [PATCH] feat(auth): respect input:false user fields on OAuth/OIDC provisioning --- .changeset/oauth-provisioning-input-fields.md | 5 + packages/better-auth/src/db/schema.ts | 17 ++ .../src/oauth2/link-account.test.ts | 172 +++++++++++++++++- .../better-auth/src/oauth2/link-account.ts | 10 +- 4 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 .changeset/oauth-provisioning-input-fields.md diff --git a/.changeset/oauth-provisioning-input-fields.md b/.changeset/oauth-provisioning-input-fields.md new file mode 100644 index 0000000000..1f9ea56f85 --- /dev/null +++ b/.changeset/oauth-provisioning-input-fields.md @@ -0,0 +1,5 @@ +--- +"better-auth": minor +--- + +Extend the user model's field-input rules to OAuth/OIDC user provisioning. Fields marked `input: false` that arrive from a provider profile (including via `mapProfileToUser`) are now silently ignored when a user is created or updated through sign-up and account linking, keeping these server-owned fields under your application's control. Apps that previously relied on a provider to set an `input: false` field will need to populate it server-side instead. diff --git a/packages/better-auth/src/db/schema.ts b/packages/better-auth/src/db/schema.ts index d4772d0a8a..9d32d9840b 100644 --- a/packages/better-auth/src/db/schema.ts +++ b/packages/better-auth/src/db/schema.ts @@ -222,6 +222,23 @@ export function parseUserInput( return parseInputData(user, { fields: schema, action }); } +/** + * Returns a shallow copy of `user` with any field the user model marks as + * non-input (`input: false`) removed. + */ +export function stripNonInputUserFields>( + options: BetterAuthOptions, + user: T, +): T { + const fields = getFields(options, "user", "input"); + const result: Record = Object.create(null); + for (const key in user) { + if (fields[key]?.input === false) continue; + result[key] = user[key]; + } + return result as T; +} + export function parseAdditionalUserInput( options: BetterAuthOptions, user?: Record | undefined, diff --git a/packages/better-auth/src/oauth2/link-account.test.ts b/packages/better-auth/src/oauth2/link-account.test.ts index 10f016537d..40c8124467 100644 --- a/packages/better-auth/src/oauth2/link-account.test.ts +++ b/packages/better-auth/src/oauth2/link-account.test.ts @@ -1084,6 +1084,11 @@ describe("oauth2 - updateUserInfoOnLink on implicit sign-in link", async () => { user: { additionalFields: { googleSub: { type: "string", required: false }, + serverManagedField: { + type: "string", + required: false, + input: false, + }, }, }, socialProviders: { @@ -1092,7 +1097,7 @@ describe("oauth2 - updateUserInfoOnLink on implicit sign-in link", async () => { clientSecret: "test", enabled: true, mapProfileToUser(profile: GoogleProfile) { - return { googleSub: profile.sub }; + return { googleSub: profile.sub, serverManagedField: "elevated" }; }, }, }, @@ -1181,16 +1186,122 @@ describe("oauth2 - updateUserInfoOnLink on implicit sign-in link", async () => { }); expect(user?.googleSub).toBe("google_implicit_mapped"); }); + + it("does not copy fields marked input: false from the provider on link", async () => { + const testEmail = "implicit-link-input-false@example.com"; + await ctx.adapter.create({ + model: "user", + data: { email: testEmail, name: "Original Name", emailVerified: true }, + }); + + await signInAndLink(testEmail, "google_link_input_false"); + + const user = await ctx.adapter.findOne< + User & { googleSub?: string; serverManagedField?: string | null } + >({ + model: "user", + where: [{ field: "email", value: testEmail }], + }); + expect(user?.googleSub).toBe("google_link_input_false"); + expect(user?.serverManagedField ?? null).toBeNull(); + }); +}); + +describe("oauth2 - first sign-in provisioning ignores input: false fields", async () => { + const { auth, client, cookieSetter } = await getTestInstance({ + user: { + additionalFields: { + googleSub: { type: "string", required: false }, + serverManagedField: { + type: "string", + required: false, + input: false, + }, + }, + }, + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + enabled: true, + mapProfileToUser(profile: GoogleProfile) { + return { googleSub: profile.sub, serverManagedField: "elevated" }; + }, + }, + }, + }); + + const ctx = await auth.$context; + + it("does not copy fields marked input: false from the provider on first sign-in", async () => { + const testEmail = "implicit-create-input-false@example.com"; + + server.use( + http.post("https://oauth2.googleapis.com/token", async () => { + const profile = { + sub: "google_create_input_false", + email: testEmail, + email_verified: true, + name: "Created From Google", + } 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: cookieSetter(oAuthHeaders) }, + }); + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + await client.$fetch("/callback/google", { + query: { state, code: "test_code" }, + method: "GET", + headers: oAuthHeaders, + onError(context) { + expect(context.response.status).toBe(302); + cookieSetter(oAuthHeaders)(context as any); + }, + }); + + const user = await ctx.adapter.findOne< + User & { googleSub?: string; serverManagedField?: string | null } + >({ + model: "user", + where: [{ field: "email", value: testEmail }], + }); + // The mapped, input-enabled field is still written... + expect(user?.googleSub).toBe("google_create_input_false"); + // ...while the input: false field is ignored. + expect(user?.serverManagedField ?? null).toBeNull(); + }); }); describe("oauth2 - override user info on sign-in", async () => { const { auth, client, cookieSetter } = await getTestInstance({ + user: { + additionalFields: { + serverManagedField: { + type: "string", + required: false, + input: false, + }, + }, + }, socialProviders: { google: { clientId: "test", clientSecret: "test", enabled: true, overrideUserInfoOnSignIn: true, + mapProfileToUser() { + return { serverManagedField: "elevated" }; + }, }, }, account: { @@ -1281,6 +1392,65 @@ describe("oauth2 - override user info on sign-in", async () => { expect(session.data?.user.name).toBe("Updated Name"); }); + it("does not copy fields marked input: false from the provider when overriding", async () => { + const testEmail = "override-input-false@example.com"; + + await ctx.adapter.create({ + model: "user", + data: { + email: testEmail, + name: "Initial Name", + emailVerified: true, + }, + }); + + server.use( + http.post("https://oauth2.googleapis.com/token", async () => { + const profile: GoogleProfile = { + sub: "google_override_input_false", + email: testEmail, + email_verified: true, + name: "Updated Name", + } 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: cookieSetter(oAuthHeaders), + }, + }); + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + await client.$fetch("/callback/google", { + query: { state, code: "test_code" }, + method: "GET", + headers: oAuthHeaders, + onError(context) { + expect(context.response.status).toBe(302); + cookieSetter(oAuthHeaders)(context as any); + }, + }); + + const user = await ctx.adapter.findOne< + User & { serverManagedField?: string | null } + >({ + model: "user", + where: [{ field: "email", value: testEmail }], + }); + // overrideUserInfo still applied the provider's name... + expect(user?.name).toBe("Updated Name"); + // ...but the input: false field was ignored. + expect(user?.serverManagedField ?? null).toBeNull(); + }); + it("should preserve the resolved user when overrideUserInfo update returns null", async () => { const testEmail = "override-null@example.com"; diff --git a/packages/better-auth/src/oauth2/link-account.ts b/packages/better-auth/src/oauth2/link-account.ts index 174d45d555..c63793a2a2 100644 --- a/packages/better-auth/src/oauth2/link-account.ts +++ b/packages/better-auth/src/oauth2/link-account.ts @@ -6,6 +6,7 @@ import { runWithTransaction } from "@better-auth/core/context"; import { isDevelopment } from "@better-auth/core/env"; import { createEmailVerificationToken } from "../api"; import { setAccountCookie } from "../cookies/session-store"; +import { stripNonInputUserFields } from "../db"; import type { Account, User } from "../types"; import { isAPIError } from "../utils/is-api-error"; import { assertValidUserInfo } from "../utils/validate-user-info"; @@ -190,7 +191,8 @@ export async function handleOAuthUserInfo( } } if (overrideUserInfo) { - const { id: _, ...restUserInfo } = userInfo; + const { id: _, ...rest } = userInfo; + const restUserInfo = stripNonInputUserFields(c.context.options, rest); // update user info from the provider if overrideUserInfo is true const updatedUser = await c.context.internalAdapter.updateUser( dbUser.user.id, @@ -219,7 +221,8 @@ export async function handleOAuthUserInfo( }; } try { - const { id: _, ...restUserInfo } = userInfo; + const { id: _, ...rest } = userInfo; + const restUserInfo = stripNonInputUserFields(c.context.options, rest); const accountData = { accessToken: await setTokenUtil(account.accessToken, c.context), refreshToken: await setTokenUtil(account.refreshToken, c.context), @@ -389,8 +392,9 @@ export async function applyUpdateUserInfoOnLink( id: _id, email: _email, emailVerified: _emailVerified, - ...profile + ...rawProfile } = userInfo; + const profile = stripNonInputUserFields(c.context.options, rawProfile); try { return await c.context.internalAdapter.updateUser(userId, profile); } catch (e) {