From 8580042c26957f67f38128f539a7a9b7511cf980 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 20 Jul 2026 17:04:59 +0800 Subject: [PATCH] fix(auth): remove Facebook email permission --- src/data/schemas/auth/facebook_user_data.ts | 1 - .../services/__tests__/facebook-graph.test.ts | 47 +++++++++++++++++++ src/data/services/facebook-graph.ts | 12 ++--- src/lib/auth/__tests__/auth_platform.test.ts | 30 ++++++++++++ .../auth/__tests__/facebook_provider.test.ts | 41 ++++++++++++++++ src/lib/auth/auth.ts | 8 ++-- src/lib/auth/auth_platform.ts | 6 ++- src/lib/auth/facebook_provider.ts | 32 +++++++++++++ 8 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 src/data/services/__tests__/facebook-graph.test.ts create mode 100644 src/lib/auth/__tests__/auth_platform.test.ts create mode 100644 src/lib/auth/__tests__/facebook_provider.test.ts create mode 100644 src/lib/auth/facebook_provider.ts diff --git a/src/data/schemas/auth/facebook_user_data.ts b/src/data/schemas/auth/facebook_user_data.ts index a5d2806f..3cd9045d 100644 --- a/src/data/schemas/auth/facebook_user_data.ts +++ b/src/data/schemas/auth/facebook_user_data.ts @@ -12,7 +12,6 @@ export const FacebookUserDataSchema = z .object({ id: stringOrNull, name: stringOrNull, - email: stringOrNull, pictureUrl: stringOrNull, }) .readonly(); diff --git a/src/data/services/__tests__/facebook-graph.test.ts b/src/data/services/__tests__/facebook-graph.test.ts new file mode 100644 index 00000000..7bfa8e71 --- /dev/null +++ b/src/data/services/__tests__/facebook-graph.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { fetchFacebookUserData } from "../facebook-graph"; + +describe("fetchFacebookUserData", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("requests profile data without requesting or returning email", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: "facebook-1", + name: "Elio", + email: "should-not-be-used@example.com", + picture: { + data: { url: "https://example.com/avatar.jpg" }, + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchFacebookUserData("access-token"); + const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0])); + + expect(requestUrl.searchParams.get("fields")).toBe( + "id,name,picture.type(large)", + ); + expect(requestUrl.searchParams.get("fields")).not.toContain("email"); + expect(requestUrl.searchParams.get("access_token")).toBe("access-token"); + expect(result).toEqual({ + success: true, + data: { + id: "facebook-1", + name: "Elio", + pictureUrl: "https://example.com/avatar.jpg", + }, + }); + if (result.success) expect(result.data).not.toHaveProperty("email"); + }); +}); diff --git a/src/data/services/facebook-graph.ts b/src/data/services/facebook-graph.ts index bde2c2d5..f5433aad 100644 --- a/src/data/services/facebook-graph.ts +++ b/src/data/services/facebook-graph.ts @@ -3,15 +3,16 @@ * * 在 OAuth callback 之后、auth state machine 的 `syncingFacebookBackend` actor * 成功换取业务 token 之后调用 —— 用 accessToken 调 `/me` 拉用户资料(id / name - * / email / picture),把 pictureUrl 写入 `UserStorage.setAvatarUrl()`、 + * / picture),把 pictureUrl 写入 `UserStorage.setAvatarUrl()`、 * id 写入 `AuthStorage.setAsid()`。 * - * 端点:`https://graph.facebook.com/me?fields=id,name,email,picture.type(large)&access_token=...` + * 端点:`https://graph.facebook.com/me?fields=id,name,picture.type(large)&access_token=...` * - `picture.type(large)` 返回 ~200px 头像 URL(默认 square 50px 太小) * - 不指定 version 走最新稳定版 * - 不需要 appId / appSecret(accessToken 自身就是凭证) */ import { FacebookUserData, FacebookUserDataSchema } from "@/data/schemas/auth"; +import { FACEBOOK_PROFILE_FIELDS } from "@/lib/auth/facebook_provider"; import { Logger } from "@/utils/logger"; import { Result, toError } from "@/utils/result"; @@ -22,10 +23,9 @@ const GRAPH_ME_URL = "https://graph.facebook.com/me"; /** * 拉取字段集合: - * - id / name / email —— 给本地 FacebookUserData 数据类 + * - id / name —— 给本地 FacebookUserData 数据类 * - picture.type(large) —— 头像 URL(默认 square 50px 太小) */ -const FIELDS = "id,name,email,picture.type(large)"; /** * 用 accessToken 调 Facebook Graph `/me` 拉用户资料。 @@ -41,7 +41,7 @@ export async function fetchFacebookUserData( return Result.err(new Error("fetchFacebookUserData: accessToken is empty")); } - const url = `${GRAPH_ME_URL}?fields=${encodeURIComponent(FIELDS)}&access_token=${encodeURIComponent(accessToken)}`; + const url = `${GRAPH_ME_URL}?fields=${encodeURIComponent(FACEBOOK_PROFILE_FIELDS)}&access_token=${encodeURIComponent(accessToken)}`; log.debug( { url: url.replace(accessToken, "") }, @@ -72,7 +72,6 @@ export async function fetchFacebookUserData( const data = FacebookUserDataSchema.parse({ id: typeof json.id === "string" ? json.id : null, name: typeof json.name === "string" ? json.name : null, - email: typeof json.email === "string" ? json.email : null, pictureUrl, }); @@ -80,7 +79,6 @@ export async function fetchFacebookUserData( { hasId: !!data.id, hasName: !!data.name, - hasEmail: !!data.email, hasPicture: !!data.pictureUrl, }, "fetchFacebookUserData SUCCESS", diff --git a/src/lib/auth/__tests__/auth_platform.test.ts b/src/lib/auth/__tests__/auth_platform.test.ts new file mode 100644 index 00000000..cd05aa64 --- /dev/null +++ b/src/lib/auth/__tests__/auth_platform.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const signInMock = vi.hoisted(() => vi.fn()); + +vi.mock("next-auth/react", () => ({ + signIn: signInMock, +})); + +import { AuthPlatform } from "../auth_platform"; + +describe("AuthPlatform", () => { + beforeEach(() => { + signInMock.mockReset(); + signInMock.mockResolvedValue(undefined); + }); + + it("requests only public profile for Facebook login", async () => { + await AuthPlatform.facebookSignIn(); + + expect(signInMock).toHaveBeenCalledWith("facebook", undefined, { + scope: "public_profile", + }); + }); + + it("keeps Google login unchanged", async () => { + await AuthPlatform.googleSignIn(); + + expect(signInMock).toHaveBeenCalledWith("google"); + }); +}); diff --git a/src/lib/auth/__tests__/facebook_provider.test.ts b/src/lib/auth/__tests__/facebook_provider.test.ts new file mode 100644 index 00000000..eb32049d --- /dev/null +++ b/src/lib/auth/__tests__/facebook_provider.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { + createFacebookProvider, + FACEBOOK_AUTHORIZATION_SCOPE, + FACEBOOK_PROFILE_FIELDS, + mapFacebookProfile, +} from "../facebook_provider"; + +describe("Facebook provider", () => { + it("requests public profile without email permission or field", () => { + const provider = createFacebookProvider(); + + expect(provider.options).toMatchObject({ + authorization: { + params: { scope: "public_profile" }, + }, + userinfo: { + params: { fields: "id,name,picture.type(large)" }, + }, + }); + expect(FACEBOOK_AUTHORIZATION_SCOPE).not.toContain("email"); + expect(FACEBOOK_PROFILE_FIELDS.split(",")).not.toContain("email"); + }); + + it("does not map an email even when Facebook unexpectedly returns one", () => { + const mapped = mapFacebookProfile({ + id: "facebook-1", + name: "Elio", + email: "should-not-be-used@example.com", + picture: { data: { url: "https://example.com/avatar.jpg" } }, + }); + + expect(mapped).toEqual({ + id: "facebook-1", + name: "Elio", + email: null, + image: "https://example.com/avatar.jpg", + }); + }); +}); diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index 5f3ab344..fad3a097 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -8,7 +8,8 @@ */ import NextAuth from "next-auth"; import Google from "next-auth/providers/google"; -import Facebook from "next-auth/providers/facebook"; + +import { createFacebookProvider } from "./facebook_provider"; export const handler = NextAuth({ providers: [ @@ -16,10 +17,7 @@ export const handler = NextAuth({ clientId: process.env.AUTH_GOOGLE_ID ?? "", clientSecret: process.env.AUTH_GOOGLE_SECRET ?? "", }), - Facebook({ - clientId: process.env.AUTH_FACEBOOK_ID ?? "", - clientSecret: process.env.AUTH_FACEBOOK_SECRET ?? "", - }), + createFacebookProvider(), ], // 把 OAuth provider 的 token 塞进 NextAuth session,供前端取用: // - Google: account.id_token → 调后端 /api/auth/google-login 换业务 token diff --git a/src/lib/auth/auth_platform.ts b/src/lib/auth/auth_platform.ts index f82e0ec8..24b0e043 100644 --- a/src/lib/auth/auth_platform.ts +++ b/src/lib/auth/auth_platform.ts @@ -17,6 +17,8 @@ */ import { signIn } from "next-auth/react"; +import { FACEBOOK_AUTHORIZATION_SCOPE } from "./facebook_provider"; + export type AuthProvider = "facebook" | "google"; export class AuthPlatform { @@ -27,6 +29,8 @@ export class AuthPlatform { /** 触发 Facebook OAuth 跳转 */ static async facebookSignIn(): Promise { - await signIn("facebook"); + await signIn("facebook", undefined, { + scope: FACEBOOK_AUTHORIZATION_SCOPE, + }); } } diff --git a/src/lib/auth/facebook_provider.ts b/src/lib/auth/facebook_provider.ts new file mode 100644 index 00000000..e7a617b8 --- /dev/null +++ b/src/lib/auth/facebook_provider.ts @@ -0,0 +1,32 @@ +import Facebook, { + type FacebookProfile, +} from "next-auth/providers/facebook"; + +export const FACEBOOK_AUTHORIZATION_SCOPE = "public_profile"; +export const FACEBOOK_PROFILE_FIELDS = "id,name,picture.type(large)"; + +export function createFacebookProvider() { + return Facebook({ + clientId: process.env.AUTH_FACEBOOK_ID ?? "", + clientSecret: process.env.AUTH_FACEBOOK_SECRET ?? "", + authorization: { + params: { scope: FACEBOOK_AUTHORIZATION_SCOPE }, + }, + userinfo: { + params: { fields: FACEBOOK_PROFILE_FIELDS }, + }, + profile: mapFacebookProfile, + }); +} + +export function mapFacebookProfile(profile: FacebookProfile) { + return { + id: profile.id, + name: typeof profile.name === "string" ? profile.name : null, + email: null, + image: + typeof profile.picture?.data?.url === "string" + ? profile.picture.data.url + : null, + }; +}