diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx index d42007a4a..938e551a6 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -1,4 +1,5 @@ import { createOAuthUserAndAccount, findExistingOAuthAccount, getProjectUserIdFromOAuthAccount, handleOAuthEmailMergeStrategy, linkOAuthAccountToUser } from "@/lib/oauth"; +import { isAppleEmailVerified } from "@/oauth/utils"; import { getBestEffortEndUserRequestContext } from "@/lib/end-users"; import { buildSignUpRuleOptions } from "@/lib/sign-up-context"; import { getDisabledBotChallengeAssessment, isBotChallengeDisabled } from "@/lib/turnstile"; @@ -34,7 +35,7 @@ async function verifyAppleIdToken(idToken: string, allowedBundleIds: string[]): return { sub: payload.sub ?? throwErr("No sub claim in Apple ID token"), email: typeof payload.email === "string" ? payload.email : null, - emailVerified: payload.email_verified === true || payload.email_verified === "true", + emailVerified: isAppleEmailVerified(payload.email_verified), }; } catch (error) { captureError("apple-native-sign-in-token-verification-failed", error); diff --git a/apps/backend/src/oauth/providers/apple.tsx b/apps/backend/src/oauth/providers/apple.tsx index 29801f962..a2104c2ca 100644 --- a/apps/backend/src/oauth/providers/apple.tsx +++ b/apps/backend/src/oauth/providers/apple.tsx @@ -1,6 +1,6 @@ import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors"; import { decodeJwt } from 'jose'; -import { OAuthUserInfo, validateUserInfo } from "../utils"; +import { OAuthUserInfo, isAppleEmailVerified, validateUserInfo } from "../utils"; import { OAuthBaseProvider, TokenSet } from "./base"; export class AppleProvider extends OAuthBaseProvider { @@ -41,7 +41,7 @@ export class AppleProvider extends OAuthBaseProvider { return validateUserInfo({ accountId: payload.sub, email: payload.email, - emailVerified: !!payload.email_verified, + emailVerified: isAppleEmailVerified(payload.email_verified), }); } diff --git a/apps/backend/src/oauth/utils.test.ts b/apps/backend/src/oauth/utils.test.ts new file mode 100644 index 000000000..7cd1f7c5e --- /dev/null +++ b/apps/backend/src/oauth/utils.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { isAppleEmailVerified } from "./utils"; + +describe("isAppleEmailVerified", () => { + it("treats the boolean true as verified", () => { + expect(isAppleEmailVerified(true)).toBe(true); + }); + + it("treats the string \"true\" as verified", () => { + expect(isAppleEmailVerified("true")).toBe(true); + }); + + it("treats the boolean false as unverified", () => { + expect(isAppleEmailVerified(false)).toBe(false); + }); + + // Regression: a naive `!!value` coerces the string "false" to `true`, which + // would let an unverified Apple email pass the account-merge verification gate. + it("treats the string \"false\" as unverified", () => { + expect(isAppleEmailVerified("false")).toBe(false); + }); + + it("treats missing/empty/other values as unverified", () => { + expect(isAppleEmailVerified(undefined)).toBe(false); + expect(isAppleEmailVerified(null)).toBe(false); + expect(isAppleEmailVerified("")).toBe(false); + expect(isAppleEmailVerified("True")).toBe(false); + expect(isAppleEmailVerified("1")).toBe(false); + expect(isAppleEmailVerified(1)).toBe(false); + }); +}); diff --git a/apps/backend/src/oauth/utils.tsx b/apps/backend/src/oauth/utils.tsx index ac090ab62..6ecebe639 100644 --- a/apps/backend/src/oauth/utils.tsx +++ b/apps/backend/src/oauth/utils.tsx @@ -16,3 +16,18 @@ export function validateUserInfo( ): OAuthUserInfo { return OAuthUserInfoSchema.validateSync(userInfo); } + +/** + * Apple emits the `email_verified` claim as either a boolean or its string + * representation ("true"/"false"). A naive `!!value` coerces the string "false" + * into `true`, which would let an UNVERIFIED Apple email satisfy the account-merge + * verification gate in `handleOAuthEmailMergeStrategy` and silently link into an + * existing account (account takeover). Treat only a real `true` or the exact + * string "true" as verified; anything else (including "false") is unverified. + * + * Shared between the web provider (`providers/apple.tsx`) and the native sign-in + * route so the two can never drift apart again. + */ +export function isAppleEmailVerified(value: unknown): boolean { + return value === true || value === "true"; +}