From 2977e5393c9483f038e0831fab20a8ce7b17613b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 27 Oct 2026 03:32:57 +0000 Subject: [PATCH] fix(auth): bind CoinPay sign-in to app_metadata.coinpay_sub, not user-writable user_metadata user_metadata can be set by any signed-in user via auth.updateUser({ data }), so a user who learned a victim's CoinPay sub could stamp it on their own account and receive the victim's next CoinPay login (GoTrue lists newest first). The binding now lives in app_metadata (service role only). The two existing CoinPay accounts were backfilled on prod before deploy. Co-Authored-By: Claude Opus 5.5 --- src/app/api/auth/coinpay/callback/route.js | 2 ++ src/lib/auth/coinpay-identity.js | 8 +++++++- tests/auth/passkey.test.js | 18 +++++++++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/app/api/auth/coinpay/callback/route.js b/src/app/api/auth/coinpay/callback/route.js index 4deb904f..7dd302c5 100644 --- a/src/app/api/auth/coinpay/callback/route.js +++ b/src/app/api/auth/coinpay/callback/route.js @@ -133,6 +133,8 @@ export async function GET(request) { const { data: created, error: createAuthError } = await service.auth.admin.createUser({ email: coinpayEmail(coinpaySub), email_confirm: true, + // The binding is server-only (app_metadata); user_metadata is user-writable. + app_metadata: { coinpay_sub: coinpaySub }, user_metadata: { coinpay_sub: coinpaySub, coinpay_email: claimedEmail || null, provider: 'coinpay' } }); if (createAuthError || !created?.user) { diff --git a/src/lib/auth/coinpay-identity.js b/src/lib/auth/coinpay-identity.js index b8f88e33..637010c6 100644 --- a/src/lib/auth/coinpay-identity.js +++ b/src/lib/auth/coinpay-identity.js @@ -23,6 +23,12 @@ export function sessionMessage(session, user) { /** * Find the auth user that carries this CoinPay subject (paged admin scan). + * + * The binding lives in `app_metadata`, which only the service role can write. + * `user_metadata` is NOT trusted: any signed-in user can set it with + * `auth.updateUser({ data })`, so a binding read from there would let someone + * who learned a victim's CoinPay sub stamp it on their own account and have the + * victim's next CoinPay login land in it (GoTrue lists newest users first). * @param {import('@supabase/supabase-js').SupabaseClient} service * @param {string} sub */ @@ -32,7 +38,7 @@ export async function findAuthUserByCoinpaySub(service, sub) { // eslint-disable-next-line no-await-in-loop const { data, error } = await service.auth.admin.listUsers({ page, perPage }); if (error) throw error; - const match = (data?.users || []).find((u) => u.user_metadata?.coinpay_sub === sub); + const match = (data?.users || []).find((u) => u.app_metadata?.coinpay_sub === sub); if (match) return match; if (!data?.users || data.users.length < perPage) break; } diff --git a/tests/auth/passkey.test.js b/tests/auth/passkey.test.js index 6fea45df..e8d1d672 100644 --- a/tests/auth/passkey.test.js +++ b/tests/auth/passkey.test.js @@ -152,16 +152,28 @@ describe('CoinPay identity', () => { expect(coinpayEmail('a b/c')).toBe('a-b-c@coinpay.qrypt.chat'); }); - it('finds the account by coinpay_sub metadata, ignoring a matching email', async () => { + it('finds the account by coinpay_sub app_metadata, ignoring a matching email', async () => { const users = [ - { id: 'victim', email: 'anthony@example.com', user_metadata: {} }, - { id: 'cp', email: 'cp@coinpay.qrypt.chat', user_metadata: { coinpay_sub: 'sub-1' } }, + { id: 'victim', email: 'anthony@example.com', user_metadata: {}, app_metadata: {} }, + { id: 'cp', email: 'cp@coinpay.qrypt.chat', user_metadata: { coinpay_sub: 'sub-1' }, app_metadata: { coinpay_sub: 'sub-1' } }, ]; const service = { auth: { admin: { listUsers: async () => ({ data: { users } }) } } }; expect((await findAuthUserByCoinpaySub(service, 'sub-1')).id).toBe('cp'); expect(await findAuthUserByCoinpaySub(service, 'sub-2')).toBeNull(); }); + it('ignores a coinpay_sub a user wrote into their own user_metadata', async () => { + // GoTrue lists newest first: the attacker's account would be found first. + const users = [ + { id: 'attacker', email: 'attacker@example.com', user_metadata: { coinpay_sub: 'sub-1' }, app_metadata: { provider: 'email' } }, + { id: 'cp', email: 'cp@coinpay.qrypt.chat', user_metadata: { coinpay_sub: 'sub-1' }, app_metadata: { coinpay_sub: 'sub-1' } }, + ]; + const service = { auth: { admin: { listUsers: async () => ({ data: { users } }) } } }; + expect((await findAuthUserByCoinpaySub(service, 'sub-1')).id).toBe('cp'); + const onlyForged = { auth: { admin: { listUsers: async () => ({ data: { users: [users[0]] } }) } } }; + expect(await findAuthUserByCoinpaySub(onlyForged, 'sub-1')).toBeNull(); + }); + it('carries expires_at, which the auth store requires', () => { const m = sessionMessage({ access_token: 'a', refresh_token: 'r', expires_at: 9, expires_in: 1, token_type: 'bearer' }, { id: 'u' }); expect(m).toMatchObject({ type: 'coinpay-session', expires_at: 9, user: { id: 'u' } });