Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/api/auth/coinpay/callback/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion src/lib/auth/coinpay-identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
}
Expand Down
18 changes: 15 additions & 3 deletions tests/auth/passkey.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } });
Expand Down
Loading