Fix Apple OAuth behavior

This commit is contained in:
Konstantin Wohlwend 2026-06-15 17:59:16 -07:00
parent eabbc05a49
commit ef27c98492
4 changed files with 50 additions and 3 deletions

View File

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

View File

@ -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),
});
}

View File

@ -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);
});
});

View File

@ -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";
}