Skip to content

Commit 3976f0a

Browse files
sh1pt-actions-fleet[bot]ralyodioclaude
authored
feat(auth): CoinPay sign-in audit: nonce + id_token checks, sealed state, Connect/Disconnect (0.21.0) (#354)
Audit of the existing "Log in with CoinPay" against the STANDING sub rule and the relying-party pattern: - Sub binding was already right (app_metadata.coinpay_sub, never email). - Now sends an OIDC nonce and checks the id_token (iss, aud, exp, nonce) and that userinfo's sub matches it, via @profullstack/coinpay-onboard/oauth; PKCE S256 + client secret on the code exchange as before. - The pending state/verifier/nonce cookie is HMAC-sealed under the client secret and scoped to /api/auth/coinpay (was plain JSON on /). - "Sign in with CoinPay" moved first on /auth. - Settings: Connect CoinPay (POST with the Bearer session; the sealed cookie names the signed-in account) and Disconnect (refused when CoinPay is the only way in: placeholder email, no phone, no passkey). - Tests: forged/edited/expired pending cookie, connect without a session, sub bound elsewhere, disconnect guard, binding ignores email and user_metadata. Co-authored-by: Anthony Ettinger <anthony@profullstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5fec22d commit 3976f0a

13 files changed

Lines changed: 617 additions & 81 deletions

File tree

‎bun.lock‎

Lines changed: 135 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.20.0",
4+
"version": "0.21.0",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",
@@ -60,6 +60,7 @@
6060
"@noble/ciphers": "^2.4.0",
6161
"@noble/hashes": "^2.4.0",
6262
"@profullstack/autoblog": "github:profullstack/autoblog#75e54af77cbcf61dbd90fb3ed529c1b2dad1ed6f",
63+
"@profullstack/coinpay-onboard": "0.3.1",
6364
"@profullstack/encrypt": "^0.1.0",
6465
"@profullstack/footer": "latest",
6566
"@profullstack/openprofile": "^0.2.0",

‎src/app/api/auth/coinpay/callback/route.js‎

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,7 @@
2626
*/
2727

2828
import { NextResponse } from 'next/server';
29-
import {
30-
validateCoinPayState,
31-
exchangeCoinPayCode,
32-
fetchCoinPayUserinfo,
33-
COINPAY_STATE_COOKIE
34-
} from '@profullstack/stack/coinpay';
29+
import { finishSignIn, sameState } from '@profullstack/coinpay-onboard/oauth';
3530
import {
3631
getCoinpayConfig,
3732
getAppOrigin,
@@ -40,15 +35,16 @@ import {
4035
} from '@/lib/auth/coinpay.js';
4136
import { mintSession, serviceClient } from '@/lib/auth/cli-auth.js';
4237
import { COINPAY_HANDOFF_COOKIE, coinpayEmail, findAuthUserByCoinpaySub, sessionMessage } from '@/lib/auth/coinpay-identity.js';
38+
import { COINPAY_PENDING_COOKIE, COINPAY_PENDING_PATH, decideConnect, unsealPending } from '@/lib/auth/coinpay-flow.js';
4339

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

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

77-
let popup = false;
73+
const { issuer, clientId, clientSecret } = getCoinpayConfig();
74+
// The sealed pending sign-in from the login/connect route (state, PKCE
75+
// verifier, nonce, popup flag, and for connect the signed-in account).
76+
const pending = unsealPending(request.cookies.get(COINPAY_PENDING_COOKIE)?.value, clientSecret);
77+
const popup = !!pending?.popup;
7878
const fail = (code) =>
7979
popup
8080
? popupPage(appOrigin, { type: 'coinpay-error', code })
81-
: clearState(NextResponse.redirect(`${appOrigin}/auth?error=${encodeURIComponent(code)}`));
81+
: clearState(
82+
NextResponse.redirect(
83+
pending?.mode === 'connect'
84+
? `${appOrigin}/settings?coinpay=${encodeURIComponent(code)}`
85+
: `${appOrigin}/auth?error=${encodeURIComponent(code)}`
86+
)
87+
);
8288

8389
try {
84-
// --- Validate state against the cookie, then consume it ---
85-
const stateCookie = request.cookies.get(COINPAY_STATE_COOKIE)?.value;
86-
let storedState = null;
87-
let codeVerifier;
88-
if (stateCookie) {
89-
try {
90-
const parsed = JSON.parse(stateCookie);
91-
storedState = parsed.state;
92-
codeVerifier = parsed.codeVerifier;
93-
popup = !!parsed.popup;
94-
} catch {
95-
storedState = null;
96-
}
90+
if (!clientId || !clientSecret) {
91+
console.error('coinpay/callback: client credentials not configured');
92+
return fail('coinpay_unavailable');
9793
}
94+
if (!pending || !sameState(pending.state, url.searchParams.get('state'))) return fail('coinpay_state_mismatch');
9895

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

105-
const { issuer, clientId, clientSecret } = getCoinpayConfig();
106-
if (!clientId || !clientSecret) {
107-
console.error('coinpay/callback: client credentials not configured');
108-
return fail('coinpay_unavailable');
101+
// --- Code exchange (PKCE + client secret), id_token checks (iss, aud,
102+
// exp, nonce), then userinfo, whose sub must match the id_token's ---
103+
let claims;
104+
try {
105+
const r = await finishSignIn({
106+
code,
107+
verifier: pending.verifier,
108+
nonce: pending.nonce,
109+
clientId,
110+
clientSecret,
111+
redirectUri: getRedirectUri(appOrigin),
112+
base: issuer
113+
});
114+
claims = { ...r.userinfo, sub: r.sub, email: r.email ?? undefined, name: r.name ?? undefined };
115+
} catch (err) {
116+
console.error('coinpay/callback: sign-in not verified:', err?.message || err);
117+
return fail('coinpay_id_token');
109118
}
110-
111-
// --- Exchange code for tokens, then read who this is ---
112-
const tokens = await exchangeCoinPayCode({
113-
issuer,
114-
code,
115-
redirectUri: getRedirectUri(appOrigin),
116-
clientId,
117-
clientSecret,
118-
codeVerifier
119-
});
120-
const claims = await fetchCoinPayUserinfo({ issuer, accessToken: tokens.access_token });
121119
const coinpaySub = typeof claims.sub === 'string' || typeof claims.sub === 'number' ? String(claims.sub) : '';
122120
if (!coinpaySub) {
123121
console.error('coinpay/callback: userinfo had no sub');
@@ -127,6 +125,34 @@ export async function GET(request) {
127125

128126
const service = serviceClient();
129127

128+
// --- Connect: link this CoinPay sub to the account that started it ---
129+
if (pending.mode === 'connect') {
130+
const { data: me, error: meError } = await service.auth.admin.getUserById(pending.userId);
131+
if (meError || !me?.user) return fail('coinpay_not_signed_in');
132+
const boundTo = await findAuthUserByCoinpaySub(service, coinpaySub);
133+
const decision = decideConnect({
134+
userId: me.user.id,
135+
sub: coinpaySub,
136+
boundTo,
137+
currentSub: me.user.app_metadata?.coinpay_sub || null
138+
});
139+
if (!decision.ok) return fail(decision.code);
140+
if (!decision.already) {
141+
const { error: linkError } = await service.auth.admin.updateUserById(me.user.id, {
142+
// app_metadata: only the service role can write it. The email is
143+
// unverified contact info, never an identity.
144+
app_metadata: { coinpay_sub: coinpaySub, coinpay_email: claimedEmail || null }
145+
});
146+
if (linkError) {
147+
console.error('coinpay/callback: link failed', linkError.message);
148+
return fail('coinpay_provision_failed');
149+
}
150+
}
151+
if (popup) return popupPage(appOrigin, { type: 'coinpay-connected' });
152+
return clearState(NextResponse.redirect(`${appOrigin}/settings?coinpay=connected`));
153+
}
154+
155+
130156
// --- a) Find-or-create the auth user BY SUB ---
131157
let authUser = await findAuthUserByCoinpaySub(service, coinpaySub);
132158
if (!authUser) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @fileoverview Connect CoinPay to the signed-in account (Settings).
3+
*
4+
* POST with the Bearer session (qrypt keeps sessions in localStorage, so a
5+
* plain link could not say who is signed in). Answers the CoinPay authorize URL
6+
* for the page to navigate to, and sets the sealed pending cookie that names
7+
* this account; the callback links the CoinPay sub to it.
8+
*/
9+
10+
import { NextResponse } from 'next/server';
11+
import { authorizeUrl, pkce, randomToken, SIGN_IN_SCOPES } from '@profullstack/coinpay-onboard/oauth';
12+
import { withAuth } from '@/lib/api/middleware/auth.js';
13+
import { getCoinpayConfig, getAppOrigin, getRedirectUri } from '@/lib/auth/coinpay.js';
14+
import { COINPAY_PENDING_COOKIE, COINPAY_PENDING_PATH, COINPAY_PENDING_TTL, sealPending } from '@/lib/auth/coinpay-flow.js';
15+
16+
const noStore = { 'cache-control': 'no-store' };
17+
18+
export const POST = withAuth(async ({ request, locals }) => {
19+
const { issuer, clientId, clientSecret } = getCoinpayConfig();
20+
if (!clientId || !clientSecret) {
21+
return NextResponse.json({ error: 'CoinPay is not available right now.' }, { status: 503, headers: noStore });
22+
}
23+
const appOrigin = getAppOrigin(new URL(request.url).origin);
24+
const state = randomToken();
25+
const nonce = randomToken();
26+
const { verifier, challenge } = pkce();
27+
const url = authorizeUrl({
28+
clientId,
29+
redirectUri: getRedirectUri(appOrigin),
30+
state,
31+
nonce,
32+
challenge,
33+
scopes: SIGN_IN_SCOPES,
34+
base: issuer
35+
});
36+
const response = NextResponse.json({ url }, { headers: noStore });
37+
response.cookies.set(
38+
COINPAY_PENDING_COOKIE,
39+
sealPending({ state, verifier, nonce, mode: 'connect', userId: locals.user.id }, clientSecret),
40+
{
41+
httpOnly: true,
42+
secure: process.env.NODE_ENV === 'production',
43+
sameSite: 'lax',
44+
path: COINPAY_PENDING_PATH,
45+
maxAge: COINPAY_PENDING_TTL
46+
}
47+
);
48+
return response;
49+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { NextResponse } from 'next/server';
2+
import { withAuth } from '@/lib/api/middleware/auth.js';
3+
import { serviceClient } from '@/lib/auth/cli-auth.js';
4+
import { isCoinpayEmail } from '@/lib/auth/coinpay-identity.js';
5+
import { canDisconnect } from '@/lib/auth/coinpay-flow.js';
6+
7+
const noStore = { 'cache-control': 'no-store' };
8+
9+
/** POST: stop CoinPay signing in to this account, unless it is the only way in. */
10+
export const POST = withAuth(async ({ locals }) => {
11+
const service = serviceClient();
12+
const { data, error } = await service.auth.admin.getUserById(locals.user.id);
13+
if (error || !data?.user) return NextResponse.json({ error: 'Not found' }, { status: 404, headers: noStore });
14+
const user = data.user;
15+
if (!user.app_metadata?.coinpay_sub) return NextResponse.json({ connected: false }, { headers: noStore });
16+
const { count } = await service
17+
.from('webauthn_credentials')
18+
.select('id', { count: 'exact', head: true })
19+
.eq('auth_user_id', user.id);
20+
if (!canDisconnect(user, count || 0, isCoinpayEmail)) {
21+
return NextResponse.json(
22+
{ error: 'CoinPay is the only way to sign in to this account. Add a passkey first, then disconnect.' },
23+
{ status: 409, headers: noStore }
24+
);
25+
}
26+
const { error: updateError } = await service.auth.admin.updateUserById(user.id, {
27+
app_metadata: { coinpay_sub: null, coinpay_email: null }
28+
});
29+
if (updateError) return NextResponse.json({ error: 'Could not disconnect CoinPay' }, { status: 500, headers: noStore });
30+
return NextResponse.json({ connected: false }, { headers: noStore });
31+
});

‎src/app/api/auth/coinpay/login/route.js‎

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,18 @@
11
/**
22
* @fileoverview "Log in with CoinPay" — OAuth2/OIDC authorize entrypoint.
33
*
4-
* Generates a CSRF `state` + PKCE (S256) verifier, stores them in a short-lived
5-
* httpOnly cookie, and 302-redirects the user to the CoinPay authorize URL.
4+
* Generates a CSRF `state`, an OIDC `nonce` and a PKCE (S256) verifier, keeps
5+
* them in a short-lived, HMAC-sealed httpOnly cookie, and 302-redirects the
6+
* user to the CoinPay authorize URL.
67
*
78
* Open to ANYONE: this initiates creation of a normal "verified" account (NOT
89
* the anonymous/invite-only tier). Additive only.
910
*/
1011

1112
import { NextResponse } from 'next/server';
12-
import {
13-
generateCoinPayState,
14-
generateCoinPayPkcePair,
15-
getCoinPayAuthorizeUrl,
16-
COINPAY_STATE_COOKIE
17-
} from '@profullstack/stack/coinpay';
13+
import { authorizeUrl, pkce, randomToken, SIGN_IN_SCOPES } from '@profullstack/coinpay-onboard/oauth';
1814
import { getCoinpayConfig, getAppOrigin, getRedirectUri } from '@/lib/auth/coinpay.js';
19-
20-
/** State cookie lifetime in seconds (short — only needs to survive the round-trip). */
21-
const STATE_COOKIE_MAX_AGE = 600;
15+
import { COINPAY_PENDING_COOKIE, COINPAY_PENDING_PATH, COINPAY_PENDING_TTL, sealPending } from '@/lib/auth/coinpay-flow.js';
2216

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

45-
const state = generateCoinPayState();
46-
const { codeVerifier, codeChallenge } = generateCoinPayPkcePair();
47-
48-
const authorizeUrl = getCoinPayAuthorizeUrl({
49-
issuer,
50-
clientId,
51-
redirectUri,
52-
state,
53-
codeChallenge
54-
});
39+
const state = randomToken();
40+
const nonce = randomToken();
41+
const { verifier, challenge } = pkce();
5542

56-
const response = NextResponse.redirect(authorizeUrl);
43+
const response = NextResponse.redirect(
44+
authorizeUrl({ clientId, redirectUri, state, nonce, challenge, scopes: SIGN_IN_SCOPES, base: issuer })
45+
);
5746

58-
// Persist state + PKCE verifier (+ popup flag) for the callback.
59-
response.cookies.set(COINPAY_STATE_COOKIE, JSON.stringify({ state, codeVerifier, popup }), {
47+
// Persist state + nonce + PKCE verifier (+ popup flag) for the callback.
48+
response.cookies.set(COINPAY_PENDING_COOKIE, sealPending({ state, verifier, nonce, mode: 'login', popup }, clientSecret), {
6049
httpOnly: true,
6150
secure: process.env.NODE_ENV === 'production',
6251
sameSite: 'lax',
63-
path: '/',
64-
maxAge: STATE_COOKIE_MAX_AGE
52+
path: COINPAY_PENDING_PATH,
53+
maxAge: COINPAY_PENDING_TTL
6554
});
6655

6756
return response;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextResponse } from 'next/server';
2+
import { withAuth } from '@/lib/api/middleware/auth.js';
3+
import { serviceClient } from '@/lib/auth/cli-auth.js';
4+
import { getCoinpayConfig } from '@/lib/auth/coinpay.js';
5+
import { isCoinpayEmail } from '@/lib/auth/coinpay-identity.js';
6+
import { canDisconnect } from '@/lib/auth/coinpay-flow.js';
7+
8+
/** GET: whether CoinPay signs in to this account, for the Settings card. */
9+
export const GET = withAuth(async ({ locals }) => {
10+
const service = serviceClient();
11+
const { data } = await service.auth.admin.getUserById(locals.user.id);
12+
const user = data?.user || locals.user;
13+
const { count } = await service
14+
.from('webauthn_credentials')
15+
.select('id', { count: 'exact', head: true })
16+
.eq('auth_user_id', user.id);
17+
const { clientId, clientSecret } = getCoinpayConfig();
18+
return NextResponse.json(
19+
{
20+
configured: !!(clientId && clientSecret),
21+
connected: !!user.app_metadata?.coinpay_sub,
22+
email: user.app_metadata?.coinpay_email || user.user_metadata?.coinpay_email || null,
23+
canDisconnect: canDisconnect(user, count || 0, isCoinpayEmail)
24+
},
25+
{ headers: { 'cache-control': 'no-store' } }
26+
);
27+
});

0 commit comments

Comments
 (0)