From e0b14261b26505dbd8b8f3c3f22b53c87ab17a6e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 29 Oct 2026 06:47:34 +0000 Subject: [PATCH] fix(api): Sign in with CoinPay sends PKCE + nonce; Connect/Disconnect in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CoinPay client (cp_56ff186e…) never sent PKCE, so it had to stay in CoinPay's legacy mode. Now, via @profullstack/coinpay-onboard/oauth 0.3.1: - start: PKCE S256 + state + nonce, kept in one httpOnly cookie scoped to /api/auth/coinpay; scopes exactly openid profile email (the old request asked for did + wallet:read, which the client was never registered for, so CoinPay dropped them; sign-in only uses the sub). - callback: constant-time state check; code exchange with the verifier and the client secret (client_secret_post only, not both methods); id_token iss/aud/exp/nonce checked and the userinfo sub matched. - Sub binding unchanged (#171): a CoinPay login never claims an account by email. - New: Settings -> Connect CoinPay (signed in only) / Disconnect (refused when the account has no password). /api/auth/me reports both. Co-Authored-By: Claude Opus 5.5 --- apps/web/public/settings.html | 8 ++ apps/web/public/settings.js | 26 +++++ pnpm-lock.yaml | 119 ++++++++++------------ services/api/package.json | 2 +- services/api/src/coinpay-login.test.ts | 82 ++++++++++++++- services/api/src/coinpay-login.ts | 59 +++++++++++ services/api/src/db.ts | 29 ++++++ services/api/src/index.ts | 133 +++++++++++++++---------- 8 files changed, 335 insertions(+), 123 deletions(-) diff --git a/apps/web/public/settings.html b/apps/web/public/settings.html index 00ebf85e..5e3837d1 100644 --- a/apps/web/public/settings.html +++ b/apps/web/public/settings.html @@ -46,6 +46,14 @@ +

CoinPay account

+

Sign in to TronBrowser with your CoinPay account. One CoinPay account works across every Profullstack app.

+
+ + + +
+

Default AI provider

Used by the AI sidebar. Pick a provider and model below; keys sync to your account (cloud).

diff --git a/apps/web/public/settings.js b/apps/web/public/settings.js index f9055029..d0f94555 100644 --- a/apps/web/public/settings.js +++ b/apps/web/public/settings.js @@ -63,9 +63,35 @@ async function requireAuth() { const me = await api('/api/auth/me').then((r) => r.json()).catch(() => ({})); if (!me.signedIn) { location.href = '/login'; return null; } $('who').textContent = me.email || (me.id ? me.id.slice(0, 8) : 'signed in'); + showCoinpay(me); return me; } +// Settings -> CoinPay: Connect (signed in only) or Disconnect (refused when +// CoinPay is the only way into the account). +const COINPAY_MSG = { + connected: 'CoinPay connected.', + linked_elsewhere: 'That CoinPay account is already connected to another TronBrowser account.', + already_connected: 'This account is already connected to a different CoinPay account. Disconnect it first.', + not_signed_in: 'Sign in first, then connect CoinPay.', +}; +function showCoinpay(me) { + const flash = COINPAY_MSG[new URLSearchParams(location.search).get('coinpay')] || ''; + $('coinpayConnect').hidden = !!me.coinpayConnected; + $('coinpayDisconnect').hidden = !me.canDisconnectCoinpay; + $('coinpayState').textContent = (flash ? flash + ' ' : '') + (me.coinpayConnected + ? (me.canDisconnectCoinpay ? 'Connected.' : 'Connected. This account was made with CoinPay, so it stays connected.') + : 'Not connected.'); +} +$('coinpayDisconnect').addEventListener('click', async () => { + if (!confirm('Disconnect CoinPay from this account?')) return; + const r = await api('/api/auth/coinpay/disconnect', { method: 'POST' }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { $('coinpayState').textContent = d.error || 'Could not disconnect.'; return; } + const me = await api('/api/auth/me').then((x) => x.json()).catch(() => ({})); + showCoinpay(me); +}); + // A provider counts as "configured" once it has a key (cloud) or base URL (local). function isConfigured(p, cur) { return p.keyless ? !!(cur && cur.baseUrl) : !!(cur && cur.apiKey); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba015851..706a9534 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,19 +290,19 @@ importers: version: 1.3.1(@hono/node-server@1.19.17(hono@4.13.14))(hono@4.13.14) '@langchain/anthropic': specifier: ^1.6.0 - version: 1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)) + version: 1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)) '@langchain/langgraph': specifier: ^1.5.2 - version: 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76) + version: 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7) '@langchain/openai': specifier: ^1.7.0 - version: 1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0) + version: 1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0) '@logicsrc/agentswarm': specifier: ^0.1.0 - version: 0.1.0(l473pxqvlkadjwuwp52lekqhce) + version: 0.1.0(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(deepagents@1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))) '@profullstack/coinpay-onboard': - specifier: 0.2.0 - version: 0.2.0(@cfworker/json-schema@4.1.1) + specifier: 0.3.1 + version: 0.3.1(@cfworker/json-schema@4.1.1) '@profullstack/libsql-pg': specifier: ^0.1.4 version: 0.1.4 @@ -317,7 +317,7 @@ importers: version: link:../../packages/sdk deepagents: specifier: ^1.15.1 - version: 1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)) + version: 1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)) fflate: specifier: ^0.8.3 version: 0.8.3 @@ -1306,8 +1306,8 @@ packages: '@oxc-project/types@0.155.0': resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} - '@profullstack/coinpay-onboard@0.2.0': - resolution: {integrity: sha512-FxIiTefcAswyCyEapEcC+vSai3IZcOPPxaBGnT7L/VPGfZJYjH1kHoN6DCRMBQx0l2IbJ2aDLqrYUhqRUaI2cA==} + '@profullstack/coinpay-onboard@0.3.1': + resolution: {integrity: sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ==} engines: {node: '>=20'} hasBin: true @@ -2884,6 +2884,7 @@ packages: libsql@0.5.29: resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] lighthouse-logger@1.4.2: @@ -5242,18 +5243,18 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))': + '@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))': dependencies: '@anthropic-ai/sdk': 0.129.0(zod@4.6.7) - '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) + '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0) zod: 4.6.7 - '@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)': + '@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)': dependencies: '@cfworker/json-schema': 4.1.1 '@standard-schema/spec': 1.1.0 js-tiktoken: 1.0.21 - langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) + langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0) mustache: 4.2.0 p-queue: 6.6.2 zod: 4.6.7 @@ -5264,13 +5265,13 @@ snapshots: - openai - ws - '@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))': + '@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))': dependencies: - '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) + '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0) - '@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)': + '@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)': dependencies: - '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) + '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0) '@langchain/protocol': 0.0.20 '@types/json-schema': 7.0.15 p-queue: 9.3.5 @@ -5278,23 +5279,11 @@ snapshots: optionalDependencies: react: 19.2.3 - '@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76)': + '@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7)': dependencies: - '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) - '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)) - '@langchain/langgraph-sdk': 1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3) - '@langchain/protocol': 0.0.20 - '@standard-schema/spec': 1.1.0 - zod: 3.25.76 - transitivePeerDependencies: - - react - - react-dom - - '@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@4.6.7)': - dependencies: - '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) - '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)) - '@langchain/langgraph-sdk': 1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3) + '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0) + '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)) + '@langchain/langgraph-sdk': 1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3) '@langchain/protocol': 0.0.20 '@standard-schema/spec': 1.1.0 zod: 4.6.7 @@ -5302,9 +5291,9 @@ snapshots: - react - react-dom - '@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0)': + '@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0)': dependencies: - '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) + '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0) js-tiktoken: 1.0.21 openai: 7.43.0(ws@8.22.0)(zod@4.6.7) zod: 4.6.7 @@ -5407,12 +5396,12 @@ snapshots: '@libsql/win32-x64-msvc@0.5.29': optional: true - '@logicsrc/agentswarm@0.1.0(l473pxqvlkadjwuwp52lekqhce)': + '@logicsrc/agentswarm@0.1.0(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(deepagents@1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))': optionalDependencies: - '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76) - deepagents: 1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)) + '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7) + deepagents: 1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)) - '@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(zod@4.6.7)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.14) ajv: 8.20.0 @@ -5429,8 +5418,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) optionalDependencies: '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: @@ -5455,10 +5444,10 @@ snapshots: '@oxc-project/types@0.155.0': {} - '@profullstack/coinpay-onboard@0.2.0(@cfworker/json-schema@4.1.1)': + '@profullstack/coinpay-onboard@0.3.1(@cfworker/json-schema@4.1.1)': dependencies: - '@modelcontextprotocol/sdk': 1.32.2(@cfworker/json-schema@4.1.1)(zod@3.25.76) - zod: 3.25.76 + '@modelcontextprotocol/sdk': 1.32.2(@cfworker/json-schema@4.1.1)(zod@4.6.7) + zod: 4.6.7 transitivePeerDependencies: - '@cfworker/json-schema' - supports-color @@ -6348,21 +6337,21 @@ snapshots: deep-is@0.1.4: {} - deepagents@1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)): + deepagents@1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)): dependencies: - '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) - '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)) - '@langchain/langgraph-sdk': 1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3) + '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0) + '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7) + '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)) + '@langchain/langgraph-sdk': 1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3) fast-glob: 3.3.3 - langchain: 1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(react@19.2.3)(ws@8.22.0) - langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) + langchain: 1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0) + langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0) micromatch: 4.0.8 yaml: 2.9.1 zod: 4.6.7 optionalDependencies: - '@langchain/anthropic': 1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)) - '@langchain/openai': 1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0) + '@langchain/anthropic': 1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)) + '@langchain/openai': 1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0) deepmerge@4.3.1: {} @@ -7019,12 +7008,12 @@ snapshots: lan-network@0.2.1: {} - langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(react@19.2.3)(ws@8.22.0): + langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0): dependencies: - '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) - '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@4.6.7) - '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)) - langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0) + '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0) + '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7) + '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)) + langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0) zod: 4.6.7 transitivePeerDependencies: - '@opentelemetry/api' @@ -7035,11 +7024,11 @@ snapshots: - react-dom - ws - langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0): + langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0): dependencies: p-queue: 6.6.2 optionalDependencies: - openai: 7.43.0(ws@8.22.0)(zod@3.25.76) + openai: 7.43.0(ws@8.22.0)(zod@4.6.7) ws: 8.22.0 leven@3.1.0: {} @@ -7629,12 +7618,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@7.43.0(ws@8.22.0)(zod@3.25.76): - optionalDependencies: - ws: 8.22.0 - zod: 3.25.76 - optional: true - openai@7.43.0(ws@8.22.0)(zod@4.6.7): optionalDependencies: ws: 8.22.0 @@ -8500,9 +8483,9 @@ snapshots: yocto-queue@0.1.0: {} - zod-to-json-schema@3.25.2(zod@3.25.76): + zod-to-json-schema@3.25.2(zod@4.6.7): dependencies: - zod: 3.25.76 + zod: 4.6.7 zod@3.25.76: {} diff --git a/services/api/package.json b/services/api/package.json index 85b93bfa..4dd3a474 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -20,7 +20,7 @@ "@langchain/langgraph": "^1.5.2", "@langchain/openai": "^1.7.0", "@logicsrc/agentswarm": "^0.1.0", - "@profullstack/coinpay-onboard": "0.2.0", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/profile": "0.1.6", "@profullstack/x402-gateway": "^0.13.1", diff --git a/services/api/src/coinpay-login.test.ts b/services/api/src/coinpay-login.test.ts index ee2fb911..bfcef0d2 100644 --- a/services/api/src/coinpay-login.test.ts +++ b/services/api/src/coinpay-login.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { coinpayEmail, releasableEmailHolder, resolveCoinpayUser, type CoinpayUser } from './coinpay-login.js'; +import { authorizeUrl, checkIdToken, pkce, randomToken, sameState } from '@profullstack/coinpay-onboard/oauth'; +import { + coinpayEmail, coinpayIssuer, connectCoinpay, decodePending, encodePending, releasableEmailHolder, resolveCoinpayUser, type CoinpayUser, +} from './coinpay-login.js'; function fakeDb(seed: CoinpayUser[]) { const rows = [...seed]; @@ -79,3 +82,80 @@ describe('coinpayEmail', () => { expect(coinpayEmail({})).toBeNull(); }); }); + + +describe('connectCoinpay (settings)', () => { + const linkFake = (rows: CoinpayUser[]) => ({ + userByCoinpaySub: async (sub: string) => rows.find((r) => r.coinpay_sub === sub) ?? null, + linkCoinpaySub: async (id: string, sub: string) => { + if (rows.some((r) => r.coinpay_sub === sub)) return 'conflict' as const; + const u = rows.find((r) => r.id === id); + if (!u || u.coinpay_sub) return 'conflict' as const; + u.coinpay_sub = sub; + return 'ok' as const; + }, + }); + + it('needs a signed-in session', async () => { + const rows = [{ ...victim }]; + expect(await connectCoinpay(linkFake(rows), null, 's')).toEqual({ ok: false, code: 'not_signed_in' }); + expect(rows[0].coinpay_sub).toBeNull(); + }); + + it('links the sub to the signed-in account, and a later CoinPay login reaches it', async () => { + const rows = [{ ...victim }]; + const deps = linkFake(rows); + expect(await connectCoinpay(deps, rows[0], 'vs')).toEqual({ ok: true }); + const { deps: loginDeps } = fakeDb(rows); + expect((await resolveCoinpayUser(loginDeps, 'vs', { email: 'someone@else.example' })).id).toBe('victim'); + }); + + it('refuses a sub bound to another account, or a second CoinPay account', async () => { + const other: CoinpayUser = { id: 'o', auth_method: 'coinpay', coinpay_sub: 'taken', email: null, email_verified: 0 }; + const rows = [{ ...victim }, other]; + expect(await connectCoinpay(linkFake(rows), rows[0], 'taken')).toEqual({ ok: false, code: 'linked_elsewhere' }); + rows[0].coinpay_sub = 'mine'; + expect(await connectCoinpay(linkFake(rows), rows[0], 'new')).toEqual({ ok: false, code: 'already_connected' }); + }); +}); + +describe('PKCE, state and nonce', () => { + it('the authorize URL carries PKCE S256, state, nonce and the exact redirect', () => { + const { verifier, challenge } = pkce(); + const state = randomToken(); + const nonce = randomToken(); + const u = new URL(authorizeUrl({ base: coinpayIssuer('https://coinpayportal.com/api/oauth/authorize'), clientId: 'cp_x', redirectUri: 'https://tronbrowser.dev/api/auth/coinpay/callback', scopes: ['openid', 'profile', 'email'], state, nonce, challenge })); + expect(u.origin + u.pathname).toBe('https://coinpayportal.com/api/oauth/authorize'); + expect(u.searchParams.get('code_challenge_method')).toBe('S256'); + expect(u.searchParams.get('code_challenge')).toBe(challenge); + expect(u.searchParams.get('state')).toBe(state); + expect(u.searchParams.get('nonce')).toBe(nonce); + expect(u.searchParams.get('scope')).toBe('openid profile email'); + expect(verifier).not.toBe(challenge); + }); + + it('the pending cookie round-trips and junk is refused', () => { + const p = { state: 's', nonce: 'n', verifier: 'v', mode: 'connect' as const, redirect: '/x' }; + expect(decodePending(encodePending(p))).toEqual(p); + expect(decodePending('junk')).toBeNull(); + expect(decodePending(undefined)).toBeNull(); + }); + + it('a forged or missing state is refused', () => { + expect(sameState('abc', 'abc')).toBe(true); + expect(sameState('abc', 'abd')).toBe(false); + expect(sameState('abc', undefined)).toBe(false); + }); + + it('an id_token with the wrong nonce is refused', () => { + const jwt = (c: object) => ['h', Buffer.from(JSON.stringify(c)).toString('base64url'), 's'].join('.'); + const good = { iss: 'https://coinpayportal.com', aud: 'cp_x', sub: 's', nonce: 'n1', exp: Math.floor(Date.now() / 1000) + 60 }; + expect(checkIdToken({ idToken: jwt(good), clientId: 'cp_x', nonce: 'n1' }).sub).toBe('s'); + expect(() => checkIdToken({ idToken: jwt({ ...good, nonce: 'evil' }), clientId: 'cp_x', nonce: 'n1' })).toThrow(); + }); + + it('derives the issuer from the configured authorize URL', () => { + expect(coinpayIssuer('https://pay.example.com/api/oauth/authorize')).toBe('https://pay.example.com'); + expect(coinpayIssuer(undefined)).toBe('https://coinpayportal.com'); + }); +}); diff --git a/services/api/src/coinpay-login.ts b/services/api/src/coinpay-login.ts index b361f051..6daffe78 100644 --- a/services/api/src/coinpay-login.ts +++ b/services/api/src/coinpay-login.ts @@ -68,3 +68,62 @@ export async function resolveCoinpayUser( export function releasableEmailHolder(holder: CoinpayUser | null): boolean { return !!holder && holder.auth_method === 'coinpay' && !holder.password_hash && !holder.email_verified; } + +/* ---------------------------------------------------------------- connect -- */ + +export type ConnectResult = { ok: true } | { ok: false; code: 'not_signed_in' | 'linked_elsewhere' | 'already_connected' }; + +/** + * Connect CoinPay to the signed-in account (settings). Only ever with a session: + * a CoinPay login never claims an account by its email. + */ +export async function connectCoinpay( + deps: { + userByCoinpaySub(sub: string): Promise; + linkCoinpaySub(userId: string, sub: string): Promise<'ok' | 'conflict'>; + }, + current: { id: string; coinpay_sub: string | null } | null, + sub: string, +): Promise { + if (!current) return { ok: false, code: 'not_signed_in' }; + const bound = await deps.userByCoinpaySub(sub); + if (bound) return bound.id === current.id ? { ok: true } : { ok: false, code: 'linked_elsewhere' }; + if (current.coinpay_sub) return { ok: false, code: 'already_connected' }; + return (await deps.linkCoinpaySub(current.id, sub)) === 'ok' ? { ok: true } : { ok: false, code: 'linked_elsewhere' }; +} + +/* ---------------------------------------------------- pending OAuth request -- */ + +/** + * What the start route keeps in a short-lived httpOnly cookie: state, the OIDC + * nonce, the PKCE verifier, the mode, and where to land. + */ +export interface PendingCoinpay { + state: string; + nonce: string; + verifier: string; + mode: 'login' | 'connect'; + redirect: string; +} + +export function encodePending(p: PendingCoinpay): string { + return Buffer.from(JSON.stringify({ s: p.state, n: p.nonce, v: p.verifier, m: p.mode, r: p.redirect })).toString('base64url'); +} + +export function decodePending(raw: string | undefined | null): PendingCoinpay | null { + if (!raw) return null; + try { + const o = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); + if (!o || typeof o.s !== 'string' || typeof o.n !== 'string' || typeof o.v !== 'string') return null; + if (o.m !== 'login' && o.m !== 'connect') return null; + return { state: o.s, nonce: o.n, verifier: o.v, mode: o.m, redirect: typeof o.r === 'string' ? o.r : '' }; + } catch { + return null; + } +} + +/** The CoinPay issuer from an endpoint URL (`/api/oauth/`), else the hosted one. */ +export function coinpayIssuer(endpoint: string | undefined): string { + const m = /^(https?:\/\/[^/]+(?:\/.*?)?)\/api\/oauth\/[a-z_]+\/?$/.exec(String(endpoint ?? '')); + return m?.[1] ?? 'https://coinpayportal.com'; +} diff --git a/services/api/src/db.ts b/services/api/src/db.ts index b0c2b459..2c4f7b24 100644 --- a/services/api/src/db.ts +++ b/services/api/src/db.ts @@ -84,6 +84,35 @@ export async function createUser(u: { }); } +/** + * Connect CoinPay to a signed-in account: set its sub only while the account has + * none. 'conflict' when the sub is already bound elsewhere (coinpay_sub is UNIQUE) + * or the account already has a different one. + */ +export async function linkCoinpaySub(userId: string, sub: string): Promise<'ok' | 'conflict'> { + try { + const r = await db().execute({ + sql: "UPDATE users SET coinpay_sub = ?, updated_at = datetime('now') WHERE id = ? AND coinpay_sub IS NULL", + args: [sub, userId], + }); + return r.rowsAffected ? 'ok' : 'conflict'; + } catch (err) { + if (/unique|duplicate/i.test(String((err as Error)?.message ?? err))) return 'conflict'; + throw err; + } +} + +/** Disconnect CoinPay (callers check the account has a password first). */ +export async function unlinkCoinpaySub(userId: string): Promise { + await db().execute({ sql: "UPDATE users SET coinpay_sub = NULL, updated_at = datetime('now') WHERE id = ?", args: [userId] }); +} + +/** Whether the account can sign in without CoinPay (it has a password). */ +export async function hasPassword(userId: string): Promise { + const r = await db().execute({ sql: 'SELECT password_hash FROM users WHERE id = ?', args: [userId] }); + return !!(r.rows[0] as unknown as { password_hash: string | null } | undefined)?.password_hash; +} + /** Drop an unverified email from an account (see releasableEmailHolder). */ export async function releaseEmail(userId: string): Promise { await db().execute({ sql: "UPDATE users SET email = NULL, updated_at = datetime('now') WHERE id = ? AND email_verified = 0", args: [userId] }); diff --git a/services/api/src/index.ts b/services/api/src/index.ts index fd9c7a86..0be2e572 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -6,7 +6,7 @@ import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; import { createUser, userByCoinpaySub, userByEmail, createSession, userBySession, deleteSession, putEmailToken, consumeEmailToken, setEmailVerified, releaseEmail, - getSettings, putSettings, type User, + getSettings, putSettings, linkCoinpaySub, unlinkCoinpaySub, hasPassword, type User, } from './db.js'; import { token, uuid, hashPassword, verifyPassword, SESSION_TTL, EMAIL_TOKEN_TTL } from './auth.js'; import { sendEmail } from './email.js'; @@ -16,7 +16,12 @@ import { dnsRoutes } from './dns.js'; import { tronRelay } from './mcp/tron.js'; import { safeRedirect } from './redirect.js'; import { extLoginTarget } from './ext-login.js'; -import { resolveCoinpayUser, releasableEmailHolder } from './coinpay-login.js'; +import { + resolveCoinpayUser, releasableEmailHolder, connectCoinpay, encodePending, decodePending, coinpayIssuer, +} from './coinpay-login.js'; +import { + SIGN_IN_SCOPES, authorizeUrl as coinpayAuthorizeUrl, finishSignIn, pkce, randomToken, sameState, +} from '@profullstack/coinpay-onboard/oauth'; import { assertDatabaseUrl, db } from './db.js'; import { pushService } from './push/routes.js'; import { createCrawlGate, crawlCheck } from './crawl-gate.js'; @@ -26,15 +31,19 @@ import { mountProfile, profileStore, saveSignup, signupProfileErrors } from './p // condition to limp along under (there is no file-database fallback). assertDatabaseUrl(); +// Sign in with CoinPay: confidential client, auth code + PKCE S256 + state + +// nonce, client secret on the code exchange, id_token iss/aud/exp/nonce checked +// and the userinfo sub matched (@profullstack/coinpay-onboard/oauth). Scopes are +// exactly what sign-in needs; the registered client must include them, or +// CoinPay silently drops the rest. const CP = { clientId: process.env.COINPAY_CLIENT_ID || '', clientSecret: process.env.COINPAY_CLIENT_SECRET || '', - redirectUri: process.env.COINPAY_REDIRECT_URI || 'https://tronbrowser.com/api/auth/coinpay/callback', - authorizeUrl: process.env.COINPAY_AUTHORIZE_URL || 'https://coinpayportal.com/api/oauth/authorize', - tokenUrl: process.env.COINPAY_TOKEN_URL || 'https://coinpayportal.com/api/oauth/token', - userinfoUrl: process.env.COINPAY_USERINFO_URL || 'https://coinpayportal.com/api/oauth/userinfo', - scopes: (process.env.COINPAY_SCOPES || 'openid profile email did wallet:read').split(/\s+/).filter(Boolean), + redirectUri: process.env.COINPAY_REDIRECT_URI || 'https://tronbrowser.dev/api/auth/coinpay/callback', + base: coinpayIssuer(process.env.COINPAY_AUTHORIZE_URL), }; +const CP_COOKIE = 'cp_pending'; +const CP_COOKIE_PATH = '/api/auth/coinpay'; const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev'; const app = new Hono(); @@ -107,65 +116,79 @@ app.get('/api/auth/ext-login', async (c) => { }); /* ---------- CoinPay OAuth (preferred) ---------- */ -app.get('/api/auth/coinpay/login', (c) => { - if (!CP.clientId) return c.text('CoinPay not configured', 500); - const state = token(); - const redirect = c.req.query('redirect') || ''; - setCookie(c, 'cp_state', state, { httpOnly: true, secure: true, sameSite: 'Lax', path: '/', maxAge: 600 }); - setCookie(c, 'cp_redirect', redirect, { httpOnly: true, secure: true, sameSite: 'Lax', path: '/', maxAge: 600 }); - const u = new URL(CP.authorizeUrl); - u.searchParams.set('response_type', 'code'); - u.searchParams.set('client_id', CP.clientId); - u.searchParams.set('redirect_uri', CP.redirectUri); - u.searchParams.set('scope', CP.scopes.join(' ')); - u.searchParams.set('state', state); - return c.redirect(u.toString()); +// mode=login (default) signs in, or makes an account bound to the CoinPay sub. +// mode=connect links CoinPay to the signed-in account (settings) and needs a +// session; a CoinPay login never claims an account by its email. +app.get('/api/auth/coinpay/login', async (c) => { + if (!CP.clientId || !CP.clientSecret) return c.text('CoinPay not configured', 500); + const mode = c.req.query('mode') === 'connect' ? 'connect' : 'login'; + if (mode === 'connect' && !(await currentUser(c))) return c.redirect(`${APP_URL}/login`); + const { verifier, challenge } = pkce(); + const pending = { state: randomToken(), nonce: randomToken(), verifier, mode, redirect: c.req.query('redirect') || '' } as const; + setCookie(c, CP_COOKIE, encodePending(pending), { httpOnly: true, secure: true, sameSite: 'Lax', path: CP_COOKIE_PATH, maxAge: 600 }); + c.header('cache-control', 'no-store'); + return c.redirect(coinpayAuthorizeUrl({ + base: CP.base, + clientId: CP.clientId, + redirectUri: CP.redirectUri, + scopes: SIGN_IN_SCOPES, + state: pending.state, + nonce: pending.nonce, + challenge, + })); }); app.get('/api/auth/coinpay/callback', async (c) => { + const pending = decodePending(getCookie(c, CP_COOKIE)); + deleteCookie(c, CP_COOKIE, { path: CP_COOKIE_PATH }); + c.header('cache-control', 'no-store'); const code = c.req.query('code'); - const state = c.req.query('state'); - const expectedState = getCookie(c, 'cp_state'); - // Require a non-empty state that matches the cookie. Without the `!state`/ - // `!expectedState` guards, a missing cookie AND missing state param both read - // as undefined, so `undefined !== undefined` is false and the CSRF check is - // silently bypassed (login CSRF). - if (!code || !state || !expectedState || state !== expectedState) { + // A non-empty state that matches the cookie's, compared in constant time. + if (!pending || !code || !sameState(pending.state, c.req.query('state'))) { return c.text('invalid oauth state', 400); } - const redirect = safeRedirect(getCookie(c, 'cp_redirect'), APP_URL); - - const basic = Buffer.from(`${CP.clientId}:${CP.clientSecret}`).toString('base64'); - const tokRes = await fetch(CP.tokenUrl, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - authorization: `Basic ${basic}`, - }, - body: new URLSearchParams({ - grant_type: 'authorization_code', code, redirect_uri: CP.redirectUri, - client_id: CP.clientId, client_secret: CP.clientSecret, - }), - }); - if (!tokRes.ok) return c.text('coinpay token exchange failed: ' + (await tokRes.text()), 502); - const tok: any = await tokRes.json(); - - let info: any = {}; + let claims: { sub: string; email: string | null }; try { - const r = await fetch(CP.userinfoUrl, { headers: { authorization: `Bearer ${tok.access_token}` } }); - if (r.ok) info = await r.json(); - } catch { /* ignore */ } - const sub = info.sub || info.id || tok.sub; - if (!sub) return c.text('coinpay userinfo missing subject', 502); + const r = await finishSignIn({ + code, + verifier: pending.verifier, + nonce: pending.nonce, + clientId: CP.clientId, + clientSecret: CP.clientSecret, + redirectUri: CP.redirectUri, + base: CP.base, + }); + claims = { sub: r.sub, email: r.email }; + } catch (err) { + console.error('[coinpay] sign-in failed:', (err as Error)?.message ?? err); + return c.text('coinpay sign-in failed', 502); + } + + if (pending.mode === 'connect') { + const res = await connectCoinpay({ userByCoinpaySub, linkCoinpaySub }, await currentUser(c), claims.sub); + return c.redirect(`${APP_URL}/settings?coinpay=${res.ok ? 'connected' : res.code}`); + } // Bound to the CoinPay sub only. CoinPay never verifies emails, so its email // never links to an existing account and is never marked verified. - const user = await resolveCoinpayUser({ userByCoinpaySub, userByEmail, createUser, newId: uuid }, String(sub), info); - deleteCookie(c, 'cp_state'); deleteCookie(c, 'cp_redirect'); + const user = await resolveCoinpayUser({ userByCoinpaySub, userByEmail, createUser, newId: uuid }, claims.sub, { email: claims.email }); + const redirect = safeRedirect(pending.redirect, APP_URL); const r = await startSession(c, user.id, redirect); return redirect ? r : c.redirect(`${APP_URL}/?signedin=1`); }); +// Settings -> Disconnect CoinPay. Refused when CoinPay is the only way in. +app.post('/api/auth/coinpay/disconnect', async (c) => { + const user = await currentUser(c); + if (!user) return c.json({ error: 'unauthorized' }, 401); + if (!user.coinpay_sub) return c.json({ ok: true }); + if (!(await hasPassword(user.id))) { + return c.json({ error: 'CoinPay is the only way to sign in to this account, so it cannot be disconnected.' }, 409); + } + await unlinkCoinpaySub(user.id); + return c.json({ ok: true }); +}); + /* ---------- Email + password ---------- */ app.post('/api/auth/signup', async (c) => { const body = await c.req.json().catch(() => ({})); @@ -216,7 +239,11 @@ app.post('/api/auth/logout', async (c) => { app.get('/api/auth/me', async (c) => { const user = await currentUser(c); if (!user) return c.json({ signedIn: false }); - return c.json({ signedIn: true, id: user.id, authMethod: user.auth_method, email: user.email, emailVerified: !!user.email_verified }); + return c.json({ + signedIn: true, id: user.id, authMethod: user.auth_method, email: user.email, emailVerified: !!user.email_verified, + coinpayConnected: !!user.coinpay_sub, + canDisconnectCoinpay: !!user.coinpay_sub && (await hasPassword(user.id)), + }); }); /* ---------- Shared profile (@profullstack/profile): /api/v1/me/profile ---------- */