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
8 changes: 8 additions & 0 deletions apps/web/public/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@
<div class="acct"><span id="who"></span><a href="/store/">Store</a><a href="/">Home</a><a href="#" id="signout">Sign out</a></div>
</header>

<h2>CoinPay account</h2>
<p class="hint">Sign in to TronBrowser with your CoinPay account. One CoinPay account works across every Profullstack app.</p>
<div class="row" id="coinpay">
<span id="coinpayState" class="hint" style="margin:0"></span>
<a id="coinpayConnect" href="/api/auth/coinpay/login?mode=connect" hidden>Connect CoinPay</a>
<button id="coinpayDisconnect" type="button" hidden>Disconnect CoinPay</button>
</div>

<h2>Default AI provider</h2>
<p class="hint">Used by the AI sidebar. Pick a provider and model below; keys sync to your account (cloud).</p>
<div class="grid">
Expand Down
26 changes: 26 additions & 0 deletions apps/web/public/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
119 changes: 51 additions & 68 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 81 additions & 1 deletion services/api/src/coinpay-login.test.ts
Original file line number Diff line number Diff line change
@@ -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];
Expand Down Expand Up @@ -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');
});
});
59 changes: 59 additions & 0 deletions services/api/src/coinpay-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoinpayUser | null>;
linkCoinpaySub(userId: string, sub: string): Promise<'ok' | 'conflict'>;
},
current: { id: string; coinpay_sub: string | null } | null,
sub: string,
): Promise<ConnectResult> {
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 (`<issuer>/api/oauth/<x>`), 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';
}
29 changes: 29 additions & 0 deletions services/api/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<boolean> {
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<void> {
await db().execute({ sql: "UPDATE users SET email = NULL, updated_at = datetime('now') WHERE id = ? AND email_verified = 0", args: [userId] });
Expand Down
Loading
Loading