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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@nichedb/premium": "workspace:*",
"@nichedb/queue": "workspace:*",
"@profullstack/affiliates": "^0.2.0",
"@profullstack/coinpay-onboard": "0.3.1",
"@profullstack/footer": "latest",
"@profullstack/leaderboard": "^0.3.1",
"@profullstack/nichedb": "workspace:*",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { registerAutomotive } from './routes/automotive.js';
import { registerBlacklists } from './routes/blacklists.js';
import { registerBooks } from './routes/books.js';
import { registerBusiness } from './routes/business.js';
import { registerCoinpay } from './routes/coinpay.js';
import { registerCrawlStatus } from './routes/crawlstatus.js';
import { registerCredits } from './routes/credits.js';
import { registerDataDumps } from './routes/data-dumps.js';
Expand Down Expand Up @@ -144,6 +145,8 @@ app.get('/healthz', (c) => c.text('ok'));

registerStatic(app, gateway);
registerAuth(app);
/** Sign in with CoinPay: before registerApi so its /api/v1 meter never sees these redirects. */
registerCoinpay(app);
// Before pages: /i/INV-… is an invite, /i/<number> (pages) is an item.
registerCredits(app);
registerPages(app);
Expand Down
198 changes: 198 additions & 0 deletions apps/web/src/lib/coinpay-signin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Sign in with CoinPay: a confidential OIDC client. Authorization code + PKCE
* S256 + state + nonce, the client secret on the code exchange, and the id_token
* checked (issuer, audience, expiry, nonce) with a userinfo `sub` that must match.
* The protocol half is the house package @profullstack/coinpay-onboard/oauth.
*
* Binding (STANDING rule): an account is found by its CoinPay `sub` only, in
* coinpay_identities. CoinPay never verifies email addresses, so its email is
* never matched against users.email and never becomes one. A first CoinPay
* sign-in gets a new account with an unroutable placeholder email; an existing
* account gets CoinPay only by connecting it while signed in.
*/
import { randomUUID } from 'node:crypto';
import {
authorizeUrl,
finishSignIn,
pkce,
randomToken,
SIGN_IN_SCOPES,
sameState,
} from '@profullstack/coinpay-onboard/oauth';

export const CALLBACK_PATH = '/api/v1/coinpay/oauth/callback';
export const PENDING_COOKIE = 'ndb_cp';
export const PENDING_TTL_SECONDS = 600;
export const PLACEHOLDER_DOMAIN = 'coinpay.nichedb.invalid';

// Read at request time: the app's env comes from app.env on the box.
const env = (name) => {
const v = process.env[name];
return typeof v === 'string' ? v.trim() : '';
};

/** Null when the client is not configured, which hides every CoinPay button. */
export function coinpayConfig(siteUrl) {
const clientId = env('COINPAY_OAUTH_CLIENT_ID');
const clientSecret = env('COINPAY_OAUTH_CLIENT_SECRET');
if (!clientId || !clientSecret) return null;
const issuer = (env('COINPAY_OAUTH_ISSUER') || 'https://coinpayportal.com').replace(/\/+$/, '');
return {
clientId,
clientSecret,
issuer,
redirectUri: siteUrl.replace(/\/+$/, '') + CALLBACK_PATH,
};
}

export function placeholderEmail(sub) {
return `${String(sub)
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')}@${PLACEHOLDER_DOMAIN}`;
}

export function isPlaceholderEmail(email) {
return String(email ?? '')
.trim()
.toLowerCase()
.endsWith(`@${PLACEHOLDER_DOMAIN}`);
}

/** Only same-site paths survive the round trip. */
export function safeNext(raw, fallback = '/following') {
const s = String(raw ?? '');
return s.startsWith('/') && !s.startsWith('//') && !s.startsWith('/\\') && !/[\t\r\n]/.test(s)
? s
: fallback;
}

/** The authorize redirect plus what the callback needs back, kept server side. */
export function beginSignIn(cfg, { mode = 'login', next, userId = null } = {}) {
const { verifier, challenge } = pkce();
const pending = {
state: randomToken(24),
nonce: randomToken(24),
verifier,
mode: mode === 'connect' ? 'connect' : 'login',
next: safeNext(next),
userId,
};
const url = authorizeUrl({
base: cfg.issuer,
clientId: cfg.clientId,
redirectUri: cfg.redirectUri,
state: pending.state,
challenge,
nonce: pending.nonce,
scopes: SIGN_IN_SCOPES,
});
return { id: randomUUID(), pending, url };
}

export const stateMatches = (expected, received) => sameState(expected, received);

/** Code -> tokens -> checked id_token -> userinfo. Throws on any mismatch. */
export function finishCoinpaySignIn(cfg, { code, verifier, nonce }, doFetch) {
return finishSignIn({
code,
verifier,
nonce,
clientId: cfg.clientId,
clientSecret: cfg.clientSecret,
redirectUri: cfg.redirectUri,
base: cfg.issuer,
fetch: doFetch,
});
}

/**
* The identity store over any `run(text, params) => rows` (postgres.js
* `sql.unsafe` in the app, PGlite in tests).
*/
export function identityStore(run) {
const one = async (text, params) => (await run(text, params))[0] ?? null;
return {
async userIdForSub(sub) {
const row = await one('select user_id from coinpay_identities where sub = $1', [sub]);
return row ? String(row.user_id) : null;
},

async identityFor(userId) {
return one('select sub, email, created_at from coinpay_identities where user_id = $1', [
userId,
]);
},

/** Sign-in: the bound account, or a new one bound to this sub. Never by email. */
async signIn({ sub, email = null, name = null }) {
if (!sub) throw new Error('no sub');
const bound = await one(
'update coinpay_identities set last_login_at = now(), email = $2 where sub = $1 returning user_id',
[sub, email],
);
if (bound) return { userId: String(bound.user_id), created: false };
// A plain user (never admin: role is fixed here, not ADMIN_EMAILS or
// "first account"). The CTE makes the user and its binding together.
const made = await one(
`with u as (
insert into users (email, role, display_name) values ($2, 'user', $4)
on conflict (email) do nothing
returning id
)
insert into coinpay_identities (sub, user_id, email, last_login_at)
select $1, u.id, $3, now() from u
on conflict (sub) do nothing
returning user_id`,
[sub, placeholderEmail(sub), email, name ? String(name).slice(0, 120) : null],
);
if (made) return { userId: String(made.user_id), created: true };
// Lost a race with the same sub's first sign-in: use what it bound.
const again = await this.userIdForSub(sub);
if (again) return { userId: again, created: false };
throw new Error('could not bind the CoinPay identity');
},

/** Connect from a signed-in session. */
async link(userId, { sub, email = null }) {
const existing = await one('select user_id from coinpay_identities where sub = $1', [sub]);
if (existing)
return String(existing.user_id) === String(userId)
? { ok: true }
: { ok: false, reason: 'taken' };
const mine = await one('select sub from coinpay_identities where user_id = $1', [userId]);
if (mine) return { ok: false, reason: 'already_linked' };
const row = await one(
`insert into coinpay_identities (sub, user_id, email) values ($1, $2, $3)
on conflict do nothing returning sub`,
[sub, userId, email],
);
return row ? { ok: true } : { ok: false, reason: 'taken' };
},

/**
* Disconnect, unless CoinPay is the only way in: a placeholder-email account
* cannot get a magic link, so it needs a passkey first.
*/
async unlink(userId) {
const user = await one('select email::text as email from users where id = $1', [userId]);
if (!user) return { ok: false, reason: 'no_account' };
const status = await this.status(userId, user.email);
if (!status.linked) return { ok: false, reason: 'not_linked' };
if (!status.canDisconnect) return { ok: false, reason: 'only_login' };
await run('delete from coinpay_identities where user_id = $1', [userId]);
return { ok: true };
},

async status(userId, email) {
const linked = Boolean(await this.identityFor(userId));
let other = !isPlaceholderEmail(email);
if (!other) {
const pk = await one('select count(*)::int as n from passkeys where user_id = $1', [
userId,
]);
other = Number(pk?.n ?? 0) > 0;
}
return { linked, canDisconnect: linked && other };
},
};
}
4 changes: 3 additions & 1 deletion apps/web/src/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { vapidKeysFromEnv, vapidPublicKeyResponse } from '@profullstack/notifica
import { saveSignupProfile, validateProfile } from '@profullstack/profile';
import { getCookie, setCookie } from 'hono/cookie';
import { AUTH, attempt, callerAddress, forgive, VIEW } from '../lib/auth-throttle.js';
import { isPlaceholderEmail } from '../lib/coinpay-signin.js';
import { acceptInvite, attributeReferral, looksLikeInvite } from '../lib/credits.js';
import { render, requireUser, respond } from '../lib/http.js';
import { profiles } from '../lib/profile.js';
Expand Down Expand Up @@ -133,7 +134,8 @@ export function registerAuth(app) {
.toLowerCase();
const next = String(body.next ?? '/following');
const invite = looksLikeInvite(body.invite) ? String(body.invite) : inviteFrom(c);
if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
// A CoinPay-only account's placeholder address never gets a link.
if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) && !isPlaceholderEmail(email)) {
try {
const url = await auth.createLoginLink(email, { next, profile, invite });
await sendLoginLink({ email, url });
Expand Down
148 changes: 148 additions & 0 deletions apps/web/src/routes/coinpay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { getCookie, setCookie } from 'hono/cookie';
import {
beginSignIn,
coinpayConfig,
finishCoinpaySignIn,
identityStore,
PENDING_COOKIE,
PENDING_TTL_SECONDS,
stateMatches,
} from '../lib/coinpay-signin.js';

/**
* The app's own wiring. Loaded lazily so tests (and pages that never touch
* CoinPay) do not open Postgres or Redis by importing this module.
*/
async function defaultDeps() {
const [{ config }, { sql }, q, auth, { connection }] = await Promise.all([
import('@nichedb/config'),
import('@nichedb/db'),
import('@nichedb/db/queries'),
import('@nichedb/auth'),
import('@nichedb/queue'),
]);
const key = (id) => `cp:pending:${id}`;
return {
siteUrl: config.siteUrl,
secure: config.isProd,
run: (text, params) => sql.unsafe(text, params),
pending: {
save: (id, value) =>
connection.set(key(id), JSON.stringify(value), 'EX', PENDING_TTL_SECONDS),
// Single use: read and delete in one step.
take: async (id) => {
const raw = await connection.getdel(key(id));
return raw ? JSON.parse(raw) : null;
},
},
startSession: (userId, userAgent) =>
q.startSession({ userId, ttlDays: config.session.ttlDays, userAgent }),
sessionCookie: (sid) => auth.sessionCookie(sid),
fetch: undefined,
};
}

/**
* Sign in with CoinPay:
* GET /api/v1/coinpay/oauth/start?next=/x sign in / sign up
* GET /api/v1/coinpay/oauth/start?mode=connect link to the signed-in account
* GET /api/v1/coinpay/oauth/callback
* POST /api/v1/coinpay/disconnect
* Registered before the /api/v1 meter: these are browser redirects, not API calls.
*/
export function registerCoinpay(app, overrides = null) {
let depsPromise = null;
const deps = () => {
depsPromise ??= overrides ? Promise.resolve(overrides) : defaultDeps();
return depsPromise;
};
const back = (to, msg, kind = 'error') => `${to}?${kind}=${encodeURIComponent(msg)}`;

app.get('/api/v1/coinpay/oauth/start', async (c) => {
const d = await deps();
const cfg = coinpayConfig(d.siteUrl);
if (!cfg) return c.text('Sign in with CoinPay is not configured.', 503);
const mode = c.req.query('mode') === 'connect' ? 'connect' : 'login';
const user = c.get('user');
let userId = null;
if (mode === 'connect') {
// A browser session, not an API key: linking changes how the account signs in.
if (!user || c.get('viaKey')) return c.redirect('/login?next=/settings', 303);
userId = String(user.id);
}
const { id, pending, url } = beginSignIn(cfg, {
mode,
next: mode === 'connect' ? '/settings' : c.req.query('next'),
userId,
});
await d.pending.save(id, pending);
setCookie(c, PENDING_COOKIE, id, {
httpOnly: true,
sameSite: 'Lax',
path: '/',
maxAge: PENDING_TTL_SECONDS,
secure: d.secure,
});
return c.redirect(url, 302);
});

app.get('/api/v1/coinpay/oauth/callback', async (c) => {
const d = await deps();
const cfg = coinpayConfig(d.siteUrl);
if (!cfg) return c.text('Sign in with CoinPay is not configured.', 503);
const id = getCookie(c, PENDING_COOKIE);
setCookie(c, PENDING_COOKIE, '', { path: '/', maxAge: 0 });
const pending = id ? await d.pending.take(id) : null;
if (!pending) return c.redirect(back('/login', 'That sign-in expired. Try again.'), 303);
if (c.req.query('error'))
return c.redirect(back('/login', 'CoinPay sign-in was cancelled.'), 303);
if (!stateMatches(pending.state, c.req.query('state')))
return c.text('CoinPay sign-in failed: state mismatch.', 400);
const code = c.req.query('code');
if (!code) return c.text('CoinPay sign-in failed: no code.', 400);

let who;
try {
who = await finishCoinpaySignIn(
cfg,
{ code, verifier: pending.verifier, nonce: pending.nonce },
d.fetch,
);
} catch (err) {
console.error('[coinpay] sign-in refused:', err.message);
return c.text('CoinPay sign-in could not be verified.', 400);
}
const store = identityStore(d.run);

if (pending.mode === 'connect') {
const user = c.get('user');
if (!user || c.get('viaKey') || String(user.id) !== String(pending.userId))
return c.redirect(back('/login', 'Sign in first, then connect CoinPay.'), 303);
const r = await store.link(user.id, { sub: who.sub, email: who.email });
if (r.ok) return c.redirect(back('/settings', 'CoinPay connected.', 'notice'), 303);
const why =
r.reason === 'taken'
? 'That CoinPay account is already linked to another nichedb account.'
: 'This account already has a different CoinPay account connected.';
return c.redirect(back('/settings', why), 303);
}

const { userId } = await store.signIn({ sub: who.sub, email: who.email, name: who.name });
const sid = await d.startSession(userId, c.req.header('user-agent'));
c.header('set-cookie', d.sessionCookie(sid), { append: true });
return c.redirect(pending.next || '/following', 303);
});

app.post('/api/v1/coinpay/disconnect', async (c) => {
const d = await deps();
const user = c.get('user');
if (!user || c.get('viaKey')) return c.redirect('/login?next=/settings', 303);
const r = await identityStore(d.run).unlink(user.id);
if (r.ok) return c.redirect(back('/settings', 'CoinPay disconnected.', 'notice'), 303);
const why =
r.reason === 'only_login'
? 'CoinPay is the only way into this account. Add a passkey first.'
: 'CoinPay is not connected.';
return c.redirect(back('/settings', why), 303);
});
}
Loading
Loading