fix: correctly handle OAuth callback and Apple email field (#7181)

This commit is contained in:
Taesu
2026-01-15 01:09:54 +09:00
committed by GitHub
parent 34c8a4bd2a
commit f875a491e6
3 changed files with 207 additions and 5 deletions

View File

@@ -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<any>(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);

View File

@@ -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(

View File

@@ -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