From d2b59874d08dcd950458742fc6739ec827846197 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 12:35:38 +0000 Subject: [PATCH] Honor Apple's email_verified claim before linking accounts (OY-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifyAppleIdToken returned { sub, email } unconditionally, ignoring the email_verified claim. That email flows into tryLinkOAuthUserByEmail, which adopts any existing account holding it — so an unverified Apple email could claim another user's account. Mirror the Google path: read email_verified, treat it as verified only when it is boolean true or the string "true", and return the email only when verified (undefined otherwise). This affects both the redirect callback and the native Apple endpoint, which is correct. - worker/routes/oauth.ts: add email_verified to the decoded payload type and gate the returned email on it in verifyAppleIdToken. - tests/worker/oauth.test.ts: createAppleIdToken gains an emailVerified option; regression tests assert an unverified email takes the new-user path without adopting a pre-seeded account (no session), and a verified email links to and adopts it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015EkRWLSbdRZqE9uCeuuQce Co-authored-by: jackowayed <18899+jackowayed@users.noreply.github.com> --- tests/worker/oauth.test.ts | 120 +++++++++++++++++++++++++++++++++++++ worker/routes/oauth.ts | 8 ++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/tests/worker/oauth.test.ts b/tests/worker/oauth.test.ts index 3fad494..bc00ef5 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -17,12 +17,14 @@ async function createAppleIdToken({ privateKey, sub, email, + emailVerified, aud, kid, }: { privateKey: CryptoKey; sub: string; email?: string; + emailVerified?: boolean | string; aud: string; kid: string; }): Promise { @@ -34,6 +36,7 @@ async function createAppleIdToken({ aud, sub, email, + email_verified: emailVerified, exp: now + 3600, iat: now, }), @@ -548,4 +551,121 @@ describe("oauth", () => { assert.equal(user.oauth_sub, null); assert.equal(db.sessions.length, 0); }); + + it("does not link an existing account when apple email is unverified", async (t) => { + const { env, db } = createTestEnv(); + const existing = seedUser(db, { + username: "AppleEmailVictim", + email: "shared-apple@example.com", + }); + + const { publicKey, privateKey } = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const jwk = (await crypto.subtle.exportKey("jwk", publicKey)) as JsonWebKey; + jwk.kid = "apple-unverified-kid"; + const token = await createAppleIdToken({ + privateKey, + sub: "apple-unverified-sub", + email: "shared-apple@example.com", + emailVerified: false, + aud: env.APPLE_NATIVE_CLIENT_ID ?? env.APPLE_CLIENT_ID, + kid: String(jwk.kid), + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = typeof input === "string" ? input : input.url; + if (url === "https://appleid.apple.com/auth/keys") { + return { + ok: true, + json: async () => ({ keys: [jwk] }), + } as Response; + } + throw new Error(`Unexpected fetch: ${url}`); + }; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const res = await request(env, "/api/auth/oauth/apple/native", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idToken: token }), + }); + + assert.equal(res.status, 200); + const body = (await res.json()) as { needsUsername?: boolean }; + // Unverified email must go down the new-user path, not adopt the account. + assert.equal(body.needsUsername, true); + assert.equal(existing.oauth_provider, null); + assert.equal(existing.oauth_sub, null); + assert.equal(db.sessions.length, 0); + }); + + it("links an existing account when apple email is verified", async (t) => { + const { env, db } = createTestEnv(); + const existing = seedUser(db, { + username: "AppleEmailOwner", + email: "verified-apple@example.com", + }); + + const { publicKey, privateKey } = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const jwk = (await crypto.subtle.exportKey("jwk", publicKey)) as JsonWebKey; + jwk.kid = "apple-verified-kid"; + const token = await createAppleIdToken({ + privateKey, + sub: "apple-verified-sub", + email: "verified-apple@example.com", + emailVerified: true, + aud: env.APPLE_NATIVE_CLIENT_ID ?? env.APPLE_CLIENT_ID, + kid: String(jwk.kid), + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = typeof input === "string" ? input : input.url; + if (url === "https://appleid.apple.com/auth/keys") { + return { + ok: true, + json: async () => ({ keys: [jwk] }), + } as Response; + } + throw new Error(`Unexpected fetch: ${url}`); + }; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const res = await request(env, "/api/auth/oauth/apple/native", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idToken: token }), + }); + + assert.equal(res.status, 200); + const body = (await res.json()) as { needsPasskeySetup?: boolean }; + // Verified email links to and adopts the existing account. + assert.equal(body.needsPasskeySetup, true); + assert.equal(existing.oauth_provider, "apple"); + assert.equal(existing.oauth_sub, "apple-verified-sub"); + assert.equal(db.sessions.length, 1); + assert.equal(db.sessions[0].user_id, existing.id); + }); }); diff --git a/worker/routes/oauth.ts b/worker/routes/oauth.ts index 2c2b53d..9818a66 100644 --- a/worker/routes/oauth.ts +++ b/worker/routes/oauth.ts @@ -126,6 +126,7 @@ async function verifyAppleIdToken( exp?: number; sub?: string; email?: string; + email_verified?: boolean | string; aud?: string | string[]; }; @@ -213,7 +214,12 @@ async function verifyAppleIdToken( return null; } - return { sub: payload.sub, email: payload.email }; + const emailVerified = + payload.email_verified === true || payload.email_verified === "true"; + return { + sub: payload.sub, + email: emailVerified ? payload.email : undefined, + }; } catch { return null; }