diff --git a/packages/better-auth/src/api/routes/callback.ts b/packages/better-auth/src/api/routes/callback.ts index 84e7b34151..0340583be9 100644 --- a/packages/better-auth/src/api/routes/callback.ts +++ b/packages/better-auth/src/api/routes/callback.ts @@ -68,7 +68,14 @@ export const callbackOAuth = createAuthEndpoint( throw c.redirect(`${defaultErrorURL}?error=invalid_callback_request`); } - const { code, error, state, error_description, device_id } = queryOrBody; + const { + code, + error, + state, + error_description, + device_id, + user: userData, + } = queryOrBody; if (!state) { c.context.logger.error("State not found", error); @@ -131,10 +138,24 @@ export const callbackOAuth = createAuthEndpoint( c.context.logger.error("", e); throw redirectOnError("invalid_code"); } + const parsedUserData = userData + ? safeJSONParse<{ + name?: { + firstName?: string; + lastName?: string; + }; + email?: string; + }>(userData) + : null; + const userInfo = await provider .getUserInfo({ ...tokens, - user: c.body?.user ? safeJSONParse(c.body.user) : undefined, + /** + * The user object from the provider + * This is only available for some providers like Apple + */ + user: parsedUserData ?? undefined, }) .then((res) => res?.user); diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts index e82a008945..7d191396b3 100644 --- a/packages/better-auth/src/social.test.ts +++ b/packages/better-auth/src/social.test.ts @@ -954,6 +954,178 @@ describe("updateAccountOnSignIn", async () => { }); }); +describe("Apple Provider", async () => { + it("should not use email as fallback for name when name is not provided", async () => { + const appleProfile = { + sub: "001341.example.1128", + email: "user@privaterelay.appleid.com", + email_verified: true, + is_private_email: true, + real_user_status: 2, + // No name field + }; + + mswServer.use( + http.post("https://appleid.apple.com/auth/token", async () => { + const idToken = await signJWT(appleProfile, DEFAULT_SECRET); + return HttpResponse.json({ + access_token: "apple_access_token", + id_token: idToken, + token_type: "Bearer", + expires_in: 3600, + }); + }), + ); + + const { client, cookieSetter } = await getTestInstance( + { + socialProviders: { + apple: { + clientId: "test-apple-client", + clientSecret: "test-apple-secret", + // Disable ID token verification for testing + verifyIdToken: async () => true, + }, + }, + }, + { + disableTestUser: true, + }, + ); + + const headers = new Headers(); + const signInRes = await client.signIn.social({ + provider: "apple", + callbackURL: "/callback", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + + await client.$fetch("/callback/apple", { + query: { + state, + code: "apple_test_code", + }, + headers, + method: "GET", + onError(context) { + cookieSetter(headers)(context as any); + }, + }); + + const session = await client.getSession({ + fetchOptions: { + headers, + }, + }); + + // Name should NOT be the email address + expect(session.data?.user.name).not.toBe("user@privaterelay.appleid.com"); + // Name should be undefined, null, or space when not provided + expect( + session.data?.user.name === undefined || + session.data?.user.name === null || + session.data?.user.name === " ", + ).toBe(true); + }); + + it("should use firstName and lastName when provided in token.user", async () => { + const appleProfile = { + sub: "001341.example.1129", + email: "user2@privaterelay.appleid.com", + email_verified: true, + is_private_email: true, + real_user_status: 2, + }; + + mswServer.use( + http.post("https://appleid.apple.com/auth/token", async () => { + const idToken = await signJWT(appleProfile, DEFAULT_SECRET); + return HttpResponse.json({ + access_token: "apple_access_token", + id_token: idToken, + token_type: "Bearer", + expires_in: 3600, + }); + }), + ); + + const { client, cookieSetter } = await getTestInstance( + { + socialProviders: { + apple: { + clientId: "test-apple-client", + clientSecret: "test-apple-secret", + verifyIdToken: async () => true, + }, + }, + }, + { + disableTestUser: true, + }, + ); + + const headers = new Headers(); + const signInRes = await client.signIn.social({ + provider: "apple", + callbackURL: "/callback", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + const userData = JSON.stringify({ + name: { + firstName: "Better", + lastName: "Auth", + }, + email: "user2@privaterelay.appleid.com", + }); + + let redirectLocation: string | null = null; + + await client.$fetch("/callback/apple", { + body: { + state, + code: "apple_test_code", + user: userData, + }, + headers, + method: "POST", + onError(context) { + // Expecting 302 redirect + expect(context.response.status).toBe(302); + redirectLocation = context.response.headers.get("location"); + expect(redirectLocation).toBeDefined(); + expect(redirectLocation).toContain("/callback/apple"); + expect(redirectLocation).toContain("user="); + }, + }); + + const redirectUrl = new URL(redirectLocation!); + await client.$fetch("/callback/apple", { + query: Object.fromEntries(redirectUrl.searchParams), + headers, + method: "GET", + onError(context) { + cookieSetter(headers)(context as any); + }, + }); + + const session = await client.getSession({ + fetchOptions: { + headers, + }, + }); + + expect(session.data?.user.name).toBe("Better Auth"); + }); +}); + describe("Vercel Provider", async () => { beforeAll(async () => { mswServer.use( diff --git a/packages/core/src/social-providers/apple.ts b/packages/core/src/social-providers/apple.ts index 5bfc285b2f..050d0ac78b 100644 --- a/packages/core/src/social-providers/apple.ts +++ b/packages/core/src/social-providers/apple.ts @@ -161,9 +161,18 @@ export const apple = (options: AppleOptions) => { if (!profile) { return null; } - const name = token.user - ? `${token.user.name?.firstName} ${token.user.name?.lastName}` - : profile.name || profile.email; + + // TODO: " " masking will be removed when the name field is made optional + let name: string; + if (token.user?.name) { + const firstName = token.user.name.firstName || ""; + const lastName = token.user.name.lastName || ""; + const fullName = `${firstName} ${lastName}`.trim(); + name = fullName || " "; + } else { + name = profile.name || " "; + } + const emailVerified = typeof profile.email_verified === "boolean" ? profile.email_verified