Skip to content

Commit 51467dc

Browse files
ralyodioclaude
andauthored
fix(auth): bind CoinPay sign-in to app_metadata.coinpay_sub, not user-writable user_metadata (#343)
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 <noreply@anthropic.com>
1 parent 3d4bbdc commit 51467dc

3 files changed

Lines changed: 24 additions & 4 deletions

File tree

‎src/app/api/auth/coinpay/callback/route.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ export async function GET(request) {
133133
const { data: created, error: createAuthError } = await service.auth.admin.createUser({
134134
email: coinpayEmail(coinpaySub),
135135
email_confirm: true,
136+
// The binding is server-only (app_metadata); user_metadata is user-writable.
137+
app_metadata: { coinpay_sub: coinpaySub },
136138
user_metadata: { coinpay_sub: coinpaySub, coinpay_email: claimedEmail || null, provider: 'coinpay' }
137139
});
138140
if (createAuthError || !created?.user) {

‎src/lib/auth/coinpay-identity.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export function sessionMessage(session, user) {
2323

2424
/**
2525
* Find the auth user that carries this CoinPay subject (paged admin scan).
26+
*
27+
* The binding lives in `app_metadata`, which only the service role can write.
28+
* `user_metadata` is NOT trusted: any signed-in user can set it with
29+
* `auth.updateUser({ data })`, so a binding read from there would let someone
30+
* who learned a victim's CoinPay sub stamp it on their own account and have the
31+
* victim's next CoinPay login land in it (GoTrue lists newest users first).
2632
* @param {import('@supabase/supabase-js').SupabaseClient} service
2733
* @param {string} sub
2834
*/
@@ -32,7 +38,7 @@ export async function findAuthUserByCoinpaySub(service, sub) {
3238
// eslint-disable-next-line no-await-in-loop
3339
const { data, error } = await service.auth.admin.listUsers({ page, perPage });
3440
if (error) throw error;
35-
const match = (data?.users || []).find((u) => u.user_metadata?.coinpay_sub === sub);
41+
const match = (data?.users || []).find((u) => u.app_metadata?.coinpay_sub === sub);
3642
if (match) return match;
3743
if (!data?.users || data.users.length < perPage) break;
3844
}

‎tests/auth/passkey.test.js‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,16 +152,28 @@ describe('CoinPay identity', () => {
152152
expect(coinpayEmail('a b/c')).toBe('a-b-c@coinpay.qrypt.chat');
153153
});
154154

155-
it('finds the account by coinpay_sub metadata, ignoring a matching email', async () => {
155+
it('finds the account by coinpay_sub app_metadata, ignoring a matching email', async () => {
156156
const users = [
157-
{ id: 'victim', email: 'anthony@example.com', user_metadata: {} },
158-
{ id: 'cp', email: 'cp@coinpay.qrypt.chat', user_metadata: { coinpay_sub: 'sub-1' } },
157+
{ id: 'victim', email: 'anthony@example.com', user_metadata: {}, app_metadata: {} },
158+
{ id: 'cp', email: 'cp@coinpay.qrypt.chat', user_metadata: { coinpay_sub: 'sub-1' }, app_metadata: { coinpay_sub: 'sub-1' } },
159159
];
160160
const service = { auth: { admin: { listUsers: async () => ({ data: { users } }) } } };
161161
expect((await findAuthUserByCoinpaySub(service, 'sub-1')).id).toBe('cp');
162162
expect(await findAuthUserByCoinpaySub(service, 'sub-2')).toBeNull();
163163
});
164164

165+
it('ignores a coinpay_sub a user wrote into their own user_metadata', async () => {
166+
// GoTrue lists newest first: the attacker's account would be found first.
167+
const users = [
168+
{ id: 'attacker', email: 'attacker@example.com', user_metadata: { coinpay_sub: 'sub-1' }, app_metadata: { provider: 'email' } },
169+
{ id: 'cp', email: 'cp@coinpay.qrypt.chat', user_metadata: { coinpay_sub: 'sub-1' }, app_metadata: { coinpay_sub: 'sub-1' } },
170+
];
171+
const service = { auth: { admin: { listUsers: async () => ({ data: { users } }) } } };
172+
expect((await findAuthUserByCoinpaySub(service, 'sub-1')).id).toBe('cp');
173+
const onlyForged = { auth: { admin: { listUsers: async () => ({ data: { users: [users[0]] } }) } } };
174+
expect(await findAuthUserByCoinpaySub(onlyForged, 'sub-1')).toBeNull();
175+
});
176+
165177
it('carries expires_at, which the auth store requires', () => {
166178
const m = sessionMessage({ access_token: 'a', refresh_token: 'r', expires_at: 9, expires_in: 1, token_type: 'bearer' }, { id: 'u' });
167179
expect(m).toMatchObject({ type: 'coinpay-session', expires_at: 9, user: { id: 'u' } });

0 commit comments

Comments
 (0)