Skip to content

Commit 728889a

Browse files
ralyodioclaude
andcommitted
Sign in with CoinPay: never link an existing account by CoinPay's email
bridgeAccount links a first arrival to an existing board account by email when the provider says email_verified. CoinPay never verifies emails, so the CoinPay callback now always passes email_verified=false: a CoinPay login whose email is an existing account's address gets its own account, never the victim's (or its admin flag). Linking stays by the CoinPay sub/DID. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1aa8c6b commit 728889a

2 files changed

Lines changed: 24 additions & 1 deletion

File tree

‎apps/server/src/routes/auth.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ export function authRoutes(services: Services) {
8282
return c.redirect(`/login?via=${id}&bridge_error=${encodeURIComponent(result.error)}`, 302);
8383
}
8484

85-
const user = await bridgeAccount(source.provider, result.user);
85+
// CoinPay never verifies emails, so whatever its userinfo says about
86+
// email_verified, a CoinPay email never links to (or takes over) an existing
87+
// board account by address: only the CoinPay sub/DID links.
88+
const claims = id === 'coinpay' ? { ...result.user, email_verified: false } : result.user;
89+
const user = await bridgeAccount(source.provider, claims);
8690
if (user.isBanned) return c.redirect(`/login?via=${id}&bridge_error=banned`, 302);
8791
const session = await createSession(user.id, { userAgent: c.req.header('user-agent'), ip: clientIp(c) });
8892
setCookie(c, SESSION_COOKIE, session.id, cookieOptions(secure));

‎test/bridge.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,25 @@ describe('host accounts on the board (bridges)', () => {
134134
assert.equal(rows[0]?.user_id, viaHost?.user_id);
135135
});
136136

137+
it('a CoinPay email never reaches an existing account, even if CoinPay calls it verified', async () => {
138+
const victim = await core.createUser({ username: 'victim', email: 'victim@example.com', isAdmin: true });
139+
coinpayUser = { sub: 'merchant-x', did: 'did:key:mallory', name: 'Mallory', email: 'victim@example.com', email_verified: true };
140+
const start = await get('/auth/coinpay?return=%2Flatest');
141+
const authorize = new URL(start.headers.get('location') ?? '');
142+
coinpayCodes.set('cp-code-2', authorize.searchParams.get('code_challenge') ?? '');
143+
const callback = await get(
144+
`/auth/coinpay/callback?code=cp-code-2&state=${authorize.searchParams.get('state')}`,
145+
cookiesOf(start),
146+
);
147+
assert.ok(cookiesOf(callback).tsbb_session, 'signed in');
148+
const row = await db.one<{ user_id: number }>("SELECT user_id FROM user_identities WHERE subject = 'did:key:mallory'");
149+
assert.ok(row);
150+
assert.notEqual(row.user_id, victim.id, 'not the victim account');
151+
const mallory = await db.one<{ email: string; is_admin: number }>('SELECT email, is_admin FROM users WHERE id = ?', [row.user_id]);
152+
assert.notEqual(mallory?.email, 'victim@example.com');
153+
assert.ok(!mallory?.is_admin, 'no admin');
154+
});
155+
137156
it('sends a CLI sign-in straight to CoinPay', async () => {
138157
const response = await get('/link?code=ABCD-EFGH&via=coinpay');
139158
assert.equal(response.headers.get('location'), '/auth/coinpay?return=%2Flink%3Fcode%3DABCD-EFGH');

0 commit comments

Comments
 (0)