mirror of
https://github.com/better-auth/better-auth.git
synced 2026-08-22 16:42:53 -05:00
feat(auth): respect input:false user fields on OAuth/OIDC provisioning
This commit is contained in:
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user