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 @@ -11,6 +11,7 @@
},
"dependencies": {
"@logicsrc/openaccess": "^0.3.1",
"@profullstack/coinpay-onboard": "0.3.1",
"@profullstack/contact-form": "0.3.2",
"@profullstack/footer": "latest",
"@profullstack/leaderboard": "^0.3.1",
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/app/SignInPanels.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PasskeySignIn } from './Passkey.jsx';
import { coinpayEnabled } from '../lib/coinpay.js';

/**
* The two ways into an account, as a pair of panels.
Expand Down Expand Up @@ -30,6 +31,20 @@ export default function SignInPanels({
}) {
return (
<>
{/* CoinPay is the fleet's master account, so it is offered first. It
binds to the CoinPay identity, never to a matching address here. */}
{coinpayEnabled() && (
<div className="submit-box">
<p className="eyebrow">Sign in with CoinPay</p>
<p className="hint">Use your CoinPay account. No email round trip.</p>
<div className="submit-actions">
<a className="button" href={`/api/v1/coinpay/oauth/start?next=${encodeURIComponent(next)}`}>
Sign in with CoinPay
</a>
</div>
</div>
)}

<form className="submit-box" action="/api/auth/magic" method="post">
<p className="eyebrow">{emailEyebrow}</p>
{/* Which page the form was on, so the redirect afterwards comes back
Expand Down
72 changes: 66 additions & 6 deletions apps/web/src/app/account/page.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation';
import { accounts, apikeys, dataset, newsletter, profiles, webrings } from '@rssamplifier/db';
import { profileStore } from '@rssamplifier/auth';
import { accounts, apikeys, coinpay, dataset, newsletter, profiles, webrings } from '@rssamplifier/db';
import { hasOtherSignIn, profileStore } from '@rssamplifier/auth';
import { COLUMNS } from '@profullstack/profile';

import Toolbar from '../Toolbar.jsx';
Expand All @@ -13,6 +13,8 @@ import { currentUser } from '../../lib/auth.js';
import { topicLabel } from '../../lib/following.js';
import { ANONYMOUS_HOURLY } from '../../lib/ratelimit.js';
import { latestClosedWindow } from '../../lib/datasetWindow.js';
import { coinpayEnabled } from '../../lib/coinpay.js';
import { explainCoinpay } from '../../lib/signInForm.js';

export const dynamic = 'force-dynamic';

Expand All @@ -24,7 +26,7 @@ export const metadata = {
/**
* The account page: what you follow, and how you get back in.
*
* @param {{ searchParams: Promise<{ welcome?: string, revoked?: string, created?: string, keyRevoked?: string, keyError?: string, newsletter?: string, ringsite?: string }> }} props
* @param {{ searchParams: Promise<{ coinpay?: string, welcome?: string, revoked?: string, created?: string, keyRevoked?: string, keyError?: string, newsletter?: string, ringsite?: string }> }} props
*/
export default async function AccountPage({ searchParams }) {
const params = await searchParams;
Expand All @@ -50,6 +52,11 @@ export default async function AccountPage({ searchParams }) {
webrings.ringsForAccount(client, userId).catch(() => []),
]);

// Sign in with CoinPay: connected or not, and whether it may be dropped.
const coinpayIdentity = await coinpay.identityForUser(client, userId).catch(() => null);
const placeholder = coinpay.isPlaceholderEmail(user.email);
const canDisconnect = coinpayIdentity ? await hasOtherSignIn(client, userId) : false;

// One row per ring, whether owned or joined through a claimed site.
const ringRows = [...new Map(myRings.map((r) => [r.slug, r])).values()];
const ringSite =
Expand All @@ -60,7 +67,7 @@ export default async function AccountPage({ searchParams }) {
const profileValues = Object.fromEntries(
COLUMNS.map((c) => [c, mine?.[c] == null ? null : String(mine[c])]),
);
if (!profileValues.email) profileValues.email = String(user.email);
if (!profileValues.email && !placeholder) profileValues.email = String(user.email);

// Only for an account that has one, so the overwhelming majority of readers —
// who will never license the corpus — do not pay a query to be told they have
Expand All @@ -73,10 +80,12 @@ export default async function AccountPage({ searchParams }) {

return (
<>
<p className="eyebrow">Signed in as {String(user.email)}</p>
<p className="eyebrow">
{placeholder ? 'Signed in with CoinPay' : `Signed in as ${String(user.email)}`}
</p>
<h1>Your account</h1>

{params.welcome && (
{params.welcome === '1' && (
<p className="notice">
Welcome. Add a passkey below and you will not need the email link again — though it will
keep working if you ever lose the device.
Expand All @@ -85,6 +94,57 @@ export default async function AccountPage({ searchParams }) {

{params.revoked && <p className="notice">That passkey has been removed.</p>}

<h2 id="coinpay">CoinPay</h2>
{params.coinpay && <p className="notice">{explainCoinpay(params.coinpay)}</p>}
{params.welcome === 'coinpay' && (
<p className="notice">
Welcome. You signed in with CoinPay. Add an email address below if you want the weekly
digest, alerts by email, or the emailed link as a way back in.
</p>
)}
{coinpayIdentity ? (
<div className="submit-box">
<p>
Connected{coinpayIdentity.email ? ` (${String(coinpayIdentity.email)})` : ''}. You can
sign in with CoinPay.
</p>
{canDisconnect ? (
<form method="post" action="/api/v1/coinpay/disconnect">
<div className="submit-actions">
<button type="submit">Disconnect CoinPay</button>
</div>
</form>
) : (
<p className="hint">
CoinPay is the only way into this account, so it cannot be disconnected. Add an
email address or a passkey first.
</p>
)}
</div>
) : coinpayEnabled() ? (
<div className="submit-box">
<p className="hint">Connect your CoinPay account to sign in with it.</p>
<div className="submit-actions">
<a className="button" href="/api/v1/coinpay/oauth/start?link=1&next=%2Faccount">
Connect CoinPay
</a>
</div>
</div>
) : null}
{placeholder && (
<form method="post" action="/api/v1/coinpay/email" className="submit-box">
<p className="eyebrow">Add an email address</p>
<p className="hint">
CoinPay does not confirm addresses, so this account has none yet. We mail a link to the
address; following it adds it.
</p>
<input type="email" name="email" placeholder="you@example.com" aria-label="Your email address" autoComplete="email" required />
<div className="submit-actions">
<button type="submit">Send the link</button>
</div>
</form>
)}

<h2>Your details</h2>
<p className="hint">
Your name and, if you like, where you work. Only the email and your name are
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/api/alerts/channels/route.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { alerts } from '@rssamplifier/db';
import { alerts, coinpay } from '@rssamplifier/db';
import { WebhookError } from '@rssamplifier/notify';

import { db } from '../../../../lib/db.js';
Expand Down Expand Up @@ -65,7 +65,8 @@ export async function POST(req) {
* @returns {Promise<Response>}
*/
async function addEmail(client, userId, email) {
if (!email) return redirect(`${PAGE}?error=no-address`);
// A CoinPay-only account has a placeholder, not an address it proved.
if (!email || coinpay.isPlaceholderEmail(email)) return redirect(`${PAGE}?error=no-address`);
if (!(await room(client, userId))) return redirect(`${PAGE}?error=too-many`);

await alerts.addChannel(client, { userId, kind: 'email', target: email, label: 'Email' });
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/app/api/auth/passkey/revoke/route.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { accounts } from '@rssamplifier/db';
import { canRemovePasskey } from '@rssamplifier/auth';

import { db } from '../../../../../lib/db.js';
import { currentUser } from '../../../../../lib/auth.js';
Expand All @@ -15,6 +16,10 @@ export const dynamic = 'force-dynamic';
* account can never be locked out by this, and refusing would strand anyone
* whose only key is on a device they no longer have.
*
* The one exception is an account made by Sign in with CoinPay that has since
* disconnected CoinPay and never proved an address: its last passkey is the
* only way in, so it stays.
*
* @param {Request} req
*/
export async function POST(req) {
Expand All @@ -38,6 +43,12 @@ export async function POST(req) {
return json({ error: 'bad-request' }, 400);
}

if (!(await canRemovePasskey(db(), String(user.id)))) {
return wantsHtml
? new Response(null, { status: 303, headers: { location: '/account?coinpay=last-passkey#coinpay' } })
: json({ error: 'last-way-in' }, 409);
}

const removed = await accounts.deleteCredential(db(), id, String(user.id));

if (wantsHtml) {
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/app/api/v1/coinpay/disconnect/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { disconnectCoinpay } from '@rssamplifier/auth';

import { db } from '../../../../../lib/db.js';
import { currentUser } from '../../../../../lib/auth.js';
import { sameOrigin, seeOther } from '../../../../../lib/coinpay.js';

export const dynamic = 'force-dynamic';

/**
* Disconnect CoinPay from the signed-in account: the account page's form.
* Refused when CoinPay is the only way back in (no proven address, no passkey).
*
* @param {Request} req
*/
export async function POST(req) {
if (!sameOrigin(req)) return new Response('Bad origin', { status: 403 });
const user = await currentUser();
if (!user) return seeOther('/login?next=%2Faccount');

const result = await disconnectCoinpay(db(), String(user.id));
return seeOther(`/account?coinpay=${result.ok ? 'disconnected' : result.error}#coinpay`);
}
28 changes: 28 additions & 0 deletions apps/web/src/app/api/v1/coinpay/email/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { requestEmailClaimLink } from '@rssamplifier/auth';

import { db, siteUrl } from '../../../../../lib/db.js';
import { currentUser } from '../../../../../lib/auth.js';
import { sameOrigin, seeOther } from '../../../../../lib/coinpay.js';
import { attempt, callerAddress } from '../../../../../lib/authThrottle.js';

export const dynamic = 'force-dynamic';

/**
* A CoinPay-only account asks to use a real address. Nothing changes until the
* link mailed to that address is followed (see consumeSignInLink).
*
* @param {Request} req
*/
export async function POST(req) {
if (!sameOrigin(req)) return new Response('Bad origin', { status: 403 });
const user = await currentUser();
if (!user) return seeOther('/login?next=%2Faccount');

const verdict = attempt(`coinpay-email:${callerAddress(req)}`);
if (!verdict.ok) return seeOther('/account?coinpay=too-many#coinpay');

const form = await req.formData().catch(() => null);
const email = form?.get('email');
const result = await requestEmailClaimLink(db(), String(user.id), typeof email === 'string' ? email : '', siteUrl());
return seeOther(`/account?coinpay=${result.ok ? 'email-sent' : `email-${result.error}`}#coinpay`);
}
75 changes: 75 additions & 0 deletions apps/web/src/app/api/v1/coinpay/oauth/callback/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { cookies } from 'next/headers';
import { finishSignIn } from '@profullstack/coinpay-onboard/oauth';
import { signInWithCoinpay, startSession, takeCoinpayCallback } from '@rssamplifier/auth';

import { db } from '../../../../../../lib/db.js';
import { currentUser, requestMeta, setSessionCookie } from '../../../../../../lib/auth.js';
import { STATE_COOKIE, STATE_COOKIE_PATH, coinpayConfig, seeOther } from '../../../../../../lib/coinpay.js';

export const dynamic = 'force-dynamic';

/**
* Finish "Sign in with CoinPay".
*
* Refused before the code is exchanged when the state is not the one this
* browser started, was already used, or (for Connect CoinPay) the session is
* not the one that asked. The exchange sends the PKCE verifier and the client
* secret; the id_token's issuer, audience, expiry and nonce are checked, and
* userinfo's sub must match it (@profullstack/coinpay-onboard).
*
* @param {Request} req
*/
export async function GET(req) {
const config = coinpayConfig();
if (!config) return seeOther('/login');

const params = new URL(req.url).searchParams;
const store = await cookies();
const cookieState = store.get(STATE_COOKIE)?.value;
store.set(STATE_COOKIE, '', { path: STATE_COOKIE_PATH, maxAge: 0 });

const user = await currentUser();
const checked = await takeCoinpayCallback(db(), {
state: params.get('state'),
cookieState,
sessionUserId: user ? String(user.id) : null,
});
if (!checked.ok) return seeOther(`/login?error=coinpay-${checked.error}`);
const { pending } = checked;
const linking = pending.linkUserId !== null;

const code = params.get('code');
if (params.get('error') || !code) {
return seeOther(linking ? '/account?coinpay=cancelled#coinpay' : '/login?error=coinpay-cancelled');
}

let profile;
try {
const result = await finishSignIn({
code,
verifier: pending.verifier,
nonce: pending.nonce,
clientId: config.clientId,
clientSecret: config.clientSecret,
redirectUri: config.redirectUri,
});
profile = { sub: result.sub, email: result.email, name: result.name };
} catch (err) {
console.error('[coinpay] sign-in failed:', err?.message ?? err);
return seeOther(linking ? '/account?coinpay=failed#coinpay' : '/login?error=coinpay-failed');
}

const outcome = await signInWithCoinpay(db(), profile, { linkUserId: pending.linkUserId });
if (!outcome.ok) {
return seeOther(linking ? `/account?coinpay=${outcome.error}#coinpay` : `/login?error=coinpay-${outcome.error}`);
}
if (linking) return seeOther('/account?coinpay=connected#coinpay');

const meta = await requestMeta();
const { token } = await startSession(db(), outcome.userId, meta);
await setSessionCookie(token);

if (outcome.created) return seeOther('/account?welcome=coinpay#coinpay');
return seeOther(pending.next);
}

64 changes: 64 additions & 0 deletions apps/web/src/app/api/v1/coinpay/oauth/start/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { cookies } from 'next/headers';
import { authorizeUrl, pkce, randomToken, SIGN_IN_SCOPES } from '@profullstack/coinpay-onboard/oauth';
import { COINPAY_PENDING_MS, saveCoinpayPending } from '@rssamplifier/auth';

import { db } from '../../../../../../lib/db.js';
import { currentUser } from '../../../../../../lib/auth.js';
import { STATE_COOKIE, STATE_COOKIE_PATH, coinpayConfig, seeOther } from '../../../../../../lib/coinpay.js';

export const dynamic = 'force-dynamic';

/**
* Start "Sign in with CoinPay" (or, with ?link=1, "Connect CoinPay" from the
* account page). PKCE S256, state and nonce; the verifier and nonce stay in
* coinpay_pending, and state is pinned to this browser by an httpOnly cookie.
*
* @param {Request} req
*/
export async function GET(req) {
const config = coinpayConfig();
if (!config) return seeOther('/login');

const params = new URL(req.url).searchParams;
let linkUserId = null;
if (params.get('link') === '1') {
// Connecting happens only from inside the account it joins.
const user = await currentUser();
if (!user) return seeOther('/login?next=%2Faccount');
linkUserId = String(user.id);
}

const state = randomToken();
const nonce = randomToken();
const { verifier, challenge } = pkce();
await saveCoinpayPending(db(), state, {
verifier,
nonce,
next: params.get('next') ?? '/account',
linkUserId,
});

const store = await cookies();
store.set(STATE_COOKIE, state, {
httpOnly: true,
sameSite: 'lax',
secure: config.redirectUri.startsWith('https:'),
path: STATE_COOKIE_PATH,
maxAge: Math.floor(COINPAY_PENDING_MS / 1000),
});

return new Response(null, {
status: 302,
headers: {
location: authorizeUrl({
clientId: config.clientId,
redirectUri: config.redirectUri,
state,
challenge,
nonce,
scopes: SIGN_IN_SCOPES,
}),
'cache-control': 'no-store',
},
});
}
Loading
Loading