diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index bc35122..4af0571 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -82,7 +82,11 @@ export function authRoutes(services: Services) { return c.redirect(`/login?via=${id}&bridge_error=${encodeURIComponent(result.error)}`, 302); } - const user = await bridgeAccount(source.provider, result.user); + // CoinPay never verifies emails, so whatever its userinfo says about + // email_verified, a CoinPay email never links to (or takes over) an existing + // board account by address: only the CoinPay sub/DID links. + const claims = id === 'coinpay' ? { ...result.user, email_verified: false } : result.user; + const user = await bridgeAccount(source.provider, claims); if (user.isBanned) return c.redirect(`/login?via=${id}&bridge_error=banned`, 302); const session = await createSession(user.id, { userAgent: c.req.header('user-agent'), ip: clientIp(c) }); setCookie(c, SESSION_COOKIE, session.id, cookieOptions(secure)); diff --git a/test/bridge.test.ts b/test/bridge.test.ts index bb64cd5..1675293 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -134,6 +134,25 @@ describe('host accounts on the board (bridges)', () => { assert.equal(rows[0]?.user_id, viaHost?.user_id); }); + it('a CoinPay email never reaches an existing account, even if CoinPay calls it verified', async () => { + const victim = await core.createUser({ username: 'victim', email: 'victim@example.com', isAdmin: true }); + coinpayUser = { sub: 'merchant-x', did: 'did:key:mallory', name: 'Mallory', email: 'victim@example.com', email_verified: true }; + const start = await get('/auth/coinpay?return=%2Flatest'); + const authorize = new URL(start.headers.get('location') ?? ''); + coinpayCodes.set('cp-code-2', authorize.searchParams.get('code_challenge') ?? ''); + const callback = await get( + `/auth/coinpay/callback?code=cp-code-2&state=${authorize.searchParams.get('state')}`, + cookiesOf(start), + ); + assert.ok(cookiesOf(callback).tsbb_session, 'signed in'); + const row = await db.one<{ user_id: number }>("SELECT user_id FROM user_identities WHERE subject = 'did:key:mallory'"); + assert.ok(row); + assert.notEqual(row.user_id, victim.id, 'not the victim account'); + const mallory = await db.one<{ email: string; is_admin: number }>('SELECT email, is_admin FROM users WHERE id = ?', [row.user_id]); + assert.notEqual(mallory?.email, 'victim@example.com'); + assert.ok(!mallory?.is_admin, 'no admin'); + }); + it('sends a CLI sign-in straight to CoinPay', async () => { const response = await get('/link?code=ABCD-EFGH&via=coinpay'); assert.equal(response.headers.get('location'), '/auth/coinpay?return=%2Flink%3Fcode%3DABCD-EFGH');