feat(auth): respect input:false user fields on OAuth/OIDC provisioning

This commit is contained in:
Paola Estefanía de Campos
2026-06-22 17:15:22 -07:00
parent 6ddb5554c0
commit cbbf6843bf
4 changed files with 200 additions and 4 deletions
@@ -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.
+17
View File
@@ -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<T extends Record<string, any>>(
options: BetterAuthOptions,
user: T,
): T {
const fields = getFields(options, "user", "input");
const result: Record<string, any> = 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<string, any> | undefined,
@@ -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";
@@ -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) {