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
137 changes: 135 additions & 2 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.20.0",
"version": "0.21.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down Expand Up @@ -60,6 +60,7 @@
"@noble/ciphers": "^2.4.0",
"@noble/hashes": "^2.4.0",
"@profullstack/autoblog": "github:profullstack/autoblog#75e54af77cbcf61dbd90fb3ed529c1b2dad1ed6f",
"@profullstack/coinpay-onboard": "0.3.1",
"@profullstack/encrypt": "^0.1.0",
"@profullstack/footer": "latest",
"@profullstack/openprofile": "^0.2.0",
Expand Down
104 changes: 65 additions & 39 deletions src/app/api/auth/coinpay/callback/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,7 @@
*/

import { NextResponse } from 'next/server';
import {
validateCoinPayState,
exchangeCoinPayCode,
fetchCoinPayUserinfo,
COINPAY_STATE_COOKIE
} from '@profullstack/stack/coinpay';
import { finishSignIn, sameState } from '@profullstack/coinpay-onboard/oauth';
import {
getCoinpayConfig,
getAppOrigin,
Expand All @@ -40,15 +35,16 @@ import {
} from '@/lib/auth/coinpay.js';
import { mintSession, serviceClient } from '@/lib/auth/cli-auth.js';
import { COINPAY_HANDOFF_COOKIE, coinpayEmail, findAuthUserByCoinpaySub, sessionMessage } from '@/lib/auth/coinpay-identity.js';
import { COINPAY_PENDING_COOKIE, COINPAY_PENDING_PATH, decideConnect, unsealPending } from '@/lib/auth/coinpay-flow.js';

/** Postgres unique-violation error code. */
const PG_UNIQUE_VIOLATION = '23505';
/** "no rows returned" from PostgREST .single(). */
const PG_NO_ROWS = 'PGRST116';

const clearState = (res) => {
res.cookies.set(COINPAY_STATE_COOKIE, '', {
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 0
res.cookies.set(COINPAY_PENDING_COOKIE, '', {
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: COINPAY_PENDING_PATH, maxAge: 0
});
return res;
};
Expand All @@ -74,50 +70,52 @@ export async function GET(request) {
const url = new URL(request.url);
const appOrigin = getAppOrigin(url.origin);

let popup = false;
const { issuer, clientId, clientSecret } = getCoinpayConfig();
// The sealed pending sign-in from the login/connect route (state, PKCE
// verifier, nonce, popup flag, and for connect the signed-in account).
const pending = unsealPending(request.cookies.get(COINPAY_PENDING_COOKIE)?.value, clientSecret);
const popup = !!pending?.popup;
const fail = (code) =>
popup
? popupPage(appOrigin, { type: 'coinpay-error', code })
: clearState(NextResponse.redirect(`${appOrigin}/auth?error=${encodeURIComponent(code)}`));
: clearState(
NextResponse.redirect(
pending?.mode === 'connect'
? `${appOrigin}/settings?coinpay=${encodeURIComponent(code)}`
: `${appOrigin}/auth?error=${encodeURIComponent(code)}`
)
);

try {
// --- Validate state against the cookie, then consume it ---
const stateCookie = request.cookies.get(COINPAY_STATE_COOKIE)?.value;
let storedState = null;
let codeVerifier;
if (stateCookie) {
try {
const parsed = JSON.parse(stateCookie);
storedState = parsed.state;
codeVerifier = parsed.codeVerifier;
popup = !!parsed.popup;
} catch {
storedState = null;
}
if (!clientId || !clientSecret) {
console.error('coinpay/callback: client credentials not configured');
return fail('coinpay_unavailable');
}
if (!pending || !sameState(pending.state, url.searchParams.get('state'))) return fail('coinpay_state_mismatch');

// No code means CoinPay sent the user back without one: cancelled
// (?error=…) or broken. The provider's error text is not logged.
const code = url.searchParams.get('code');
if (!code) return fail(url.searchParams.has('error') ? 'coinpay_denied' : 'coinpay_missing_code');
if (!validateCoinPayState(url.searchParams.get('state'), storedState)) return fail('coinpay_state_mismatch');

const { issuer, clientId, clientSecret } = getCoinpayConfig();
if (!clientId || !clientSecret) {
console.error('coinpay/callback: client credentials not configured');
return fail('coinpay_unavailable');
// --- Code exchange (PKCE + client secret), id_token checks (iss, aud,
// exp, nonce), then userinfo, whose sub must match the id_token's ---
let claims;
try {
const r = await finishSignIn({
code,
verifier: pending.verifier,
nonce: pending.nonce,
clientId,
clientSecret,
redirectUri: getRedirectUri(appOrigin),
base: issuer
});
claims = { ...r.userinfo, sub: r.sub, email: r.email ?? undefined, name: r.name ?? undefined };
} catch (err) {
console.error('coinpay/callback: sign-in not verified:', err?.message || err);
return fail('coinpay_id_token');
}

// --- Exchange code for tokens, then read who this is ---
const tokens = await exchangeCoinPayCode({
issuer,
code,
redirectUri: getRedirectUri(appOrigin),
clientId,
clientSecret,
codeVerifier
});
const claims = await fetchCoinPayUserinfo({ issuer, accessToken: tokens.access_token });
const coinpaySub = typeof claims.sub === 'string' || typeof claims.sub === 'number' ? String(claims.sub) : '';
if (!coinpaySub) {
console.error('coinpay/callback: userinfo had no sub');
Expand All @@ -127,6 +125,34 @@ export async function GET(request) {

const service = serviceClient();

// --- Connect: link this CoinPay sub to the account that started it ---
if (pending.mode === 'connect') {
const { data: me, error: meError } = await service.auth.admin.getUserById(pending.userId);
if (meError || !me?.user) return fail('coinpay_not_signed_in');
const boundTo = await findAuthUserByCoinpaySub(service, coinpaySub);
const decision = decideConnect({
userId: me.user.id,
sub: coinpaySub,
boundTo,
currentSub: me.user.app_metadata?.coinpay_sub || null
});
if (!decision.ok) return fail(decision.code);
if (!decision.already) {
const { error: linkError } = await service.auth.admin.updateUserById(me.user.id, {
// app_metadata: only the service role can write it. The email is
// unverified contact info, never an identity.
app_metadata: { coinpay_sub: coinpaySub, coinpay_email: claimedEmail || null }
});
if (linkError) {
console.error('coinpay/callback: link failed', linkError.message);
return fail('coinpay_provision_failed');
}
}
if (popup) return popupPage(appOrigin, { type: 'coinpay-connected' });
return clearState(NextResponse.redirect(`${appOrigin}/settings?coinpay=connected`));
}


// --- a) Find-or-create the auth user BY SUB ---
let authUser = await findAuthUserByCoinpaySub(service, coinpaySub);
if (!authUser) {
Expand Down
49 changes: 49 additions & 0 deletions src/app/api/auth/coinpay/connect/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* @fileoverview Connect CoinPay to the signed-in account (Settings).
*
* POST with the Bearer session (qrypt keeps sessions in localStorage, so a
* plain link could not say who is signed in). Answers the CoinPay authorize URL
* for the page to navigate to, and sets the sealed pending cookie that names
* this account; the callback links the CoinPay sub to it.
*/

import { NextResponse } from 'next/server';
import { authorizeUrl, pkce, randomToken, SIGN_IN_SCOPES } from '@profullstack/coinpay-onboard/oauth';
import { withAuth } from '@/lib/api/middleware/auth.js';
import { getCoinpayConfig, getAppOrigin, getRedirectUri } from '@/lib/auth/coinpay.js';
import { COINPAY_PENDING_COOKIE, COINPAY_PENDING_PATH, COINPAY_PENDING_TTL, sealPending } from '@/lib/auth/coinpay-flow.js';

const noStore = { 'cache-control': 'no-store' };

export const POST = withAuth(async ({ request, locals }) => {
const { issuer, clientId, clientSecret } = getCoinpayConfig();
if (!clientId || !clientSecret) {
return NextResponse.json({ error: 'CoinPay is not available right now.' }, { status: 503, headers: noStore });
}
const appOrigin = getAppOrigin(new URL(request.url).origin);
const state = randomToken();
const nonce = randomToken();
const { verifier, challenge } = pkce();
const url = authorizeUrl({
clientId,
redirectUri: getRedirectUri(appOrigin),
state,
nonce,
challenge,
scopes: SIGN_IN_SCOPES,
base: issuer
});
const response = NextResponse.json({ url }, { headers: noStore });
response.cookies.set(
COINPAY_PENDING_COOKIE,
sealPending({ state, verifier, nonce, mode: 'connect', userId: locals.user.id }, clientSecret),
{
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: COINPAY_PENDING_PATH,
maxAge: COINPAY_PENDING_TTL
}
);
return response;
});
31 changes: 31 additions & 0 deletions src/app/api/auth/coinpay/disconnect/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/middleware/auth.js';
import { serviceClient } from '@/lib/auth/cli-auth.js';
import { isCoinpayEmail } from '@/lib/auth/coinpay-identity.js';
import { canDisconnect } from '@/lib/auth/coinpay-flow.js';

const noStore = { 'cache-control': 'no-store' };

/** POST: stop CoinPay signing in to this account, unless it is the only way in. */
export const POST = withAuth(async ({ locals }) => {
const service = serviceClient();
const { data, error } = await service.auth.admin.getUserById(locals.user.id);
if (error || !data?.user) return NextResponse.json({ error: 'Not found' }, { status: 404, headers: noStore });
const user = data.user;
if (!user.app_metadata?.coinpay_sub) return NextResponse.json({ connected: false }, { headers: noStore });
const { count } = await service
.from('webauthn_credentials')
.select('id', { count: 'exact', head: true })
.eq('auth_user_id', user.id);
if (!canDisconnect(user, count || 0, isCoinpayEmail)) {
return NextResponse.json(
{ error: 'CoinPay is the only way to sign in to this account. Add a passkey first, then disconnect.' },
{ status: 409, headers: noStore }
);
}
const { error: updateError } = await service.auth.admin.updateUserById(user.id, {
app_metadata: { coinpay_sub: null, coinpay_email: null }
});
if (updateError) return NextResponse.json({ error: 'Could not disconnect CoinPay' }, { status: 500, headers: noStore });
return NextResponse.json({ connected: false }, { headers: noStore });
});
45 changes: 17 additions & 28 deletions src/app/api/auth/coinpay/login/route.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
/**
* @fileoverview "Log in with CoinPay" — OAuth2/OIDC authorize entrypoint.
*
* Generates a CSRF `state` + PKCE (S256) verifier, stores them in a short-lived
* httpOnly cookie, and 302-redirects the user to the CoinPay authorize URL.
* Generates a CSRF `state`, an OIDC `nonce` and a PKCE (S256) verifier, keeps
* them in a short-lived, HMAC-sealed httpOnly cookie, and 302-redirects the
* user to the CoinPay authorize URL.
*
* Open to ANYONE: this initiates creation of a normal "verified" account (NOT
* the anonymous/invite-only tier). Additive only.
*/

import { NextResponse } from 'next/server';
import {
generateCoinPayState,
generateCoinPayPkcePair,
getCoinPayAuthorizeUrl,
COINPAY_STATE_COOKIE
} from '@profullstack/stack/coinpay';
import { authorizeUrl, pkce, randomToken, SIGN_IN_SCOPES } from '@profullstack/coinpay-onboard/oauth';
import { getCoinpayConfig, getAppOrigin, getRedirectUri } from '@/lib/auth/coinpay.js';

/** State cookie lifetime in seconds (short — only needs to survive the round-trip). */
const STATE_COOKIE_MAX_AGE = 600;
import { COINPAY_PENDING_COOKIE, COINPAY_PENDING_PATH, COINPAY_PENDING_TTL, sealPending } from '@/lib/auth/coinpay-flow.js';

/**
* GET /api/auth/coinpay/login
Expand All @@ -27,8 +21,8 @@ const STATE_COOKIE_MAX_AGE = 600;
*/
export async function GET(request) {
try {
const { issuer, clientId } = getCoinpayConfig();
if (!clientId) {
const { issuer, clientId, clientSecret } = getCoinpayConfig();
if (!clientId || !clientSecret) {
console.error('coinpay/login: COINPAY_OAUTH_CLIENT_ID is not configured');
const appOrigin = getAppOrigin(new URL(request.url).origin);
return NextResponse.redirect(`${appOrigin}/auth?error=coinpay_unavailable`);
Expand All @@ -42,26 +36,21 @@ export async function GET(request) {
// redirect to the OAuth provider can't render).
const popup = new URL(request.url).searchParams.get('popup') === '1';

const state = generateCoinPayState();
const { codeVerifier, codeChallenge } = generateCoinPayPkcePair();

const authorizeUrl = getCoinPayAuthorizeUrl({
issuer,
clientId,
redirectUri,
state,
codeChallenge
});
const state = randomToken();
const nonce = randomToken();
const { verifier, challenge } = pkce();

const response = NextResponse.redirect(authorizeUrl);
const response = NextResponse.redirect(
authorizeUrl({ clientId, redirectUri, state, nonce, challenge, scopes: SIGN_IN_SCOPES, base: issuer })
);

// Persist state + PKCE verifier (+ popup flag) for the callback.
response.cookies.set(COINPAY_STATE_COOKIE, JSON.stringify({ state, codeVerifier, popup }), {
// Persist state + nonce + PKCE verifier (+ popup flag) for the callback.
response.cookies.set(COINPAY_PENDING_COOKIE, sealPending({ state, verifier, nonce, mode: 'login', popup }, clientSecret), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: STATE_COOKIE_MAX_AGE
path: COINPAY_PENDING_PATH,
maxAge: COINPAY_PENDING_TTL
});

return response;
Expand Down
27 changes: 27 additions & 0 deletions src/app/api/auth/coinpay/status/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/middleware/auth.js';
import { serviceClient } from '@/lib/auth/cli-auth.js';
import { getCoinpayConfig } from '@/lib/auth/coinpay.js';
import { isCoinpayEmail } from '@/lib/auth/coinpay-identity.js';
import { canDisconnect } from '@/lib/auth/coinpay-flow.js';

/** GET: whether CoinPay signs in to this account, for the Settings card. */
export const GET = withAuth(async ({ locals }) => {
const service = serviceClient();
const { data } = await service.auth.admin.getUserById(locals.user.id);
const user = data?.user || locals.user;
const { count } = await service
.from('webauthn_credentials')
.select('id', { count: 'exact', head: true })
.eq('auth_user_id', user.id);
const { clientId, clientSecret } = getCoinpayConfig();
return NextResponse.json(
{
configured: !!(clientId && clientSecret),
connected: !!user.app_metadata?.coinpay_sub,
email: user.app_metadata?.coinpay_email || user.user_metadata?.coinpay_email || null,
canDisconnect: canDisconnect(user, count || 0, isCoinpayEmail)
},
{ headers: { 'cache-control': 'no-store' } }
);
});
Loading
Loading