2626 */
2727
2828import { 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' ;
3530import {
3631 getCoinpayConfig ,
3732 getAppOrigin ,
@@ -40,15 +35,16 @@ import {
4035} from '@/lib/auth/coinpay.js' ;
4136import { mintSession , serviceClient } from '@/lib/auth/cli-auth.js' ;
4237import { 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. */
4541const PG_UNIQUE_VIOLATION = '23505' ;
4642/** "no rows returned" from PostgREST .single(). */
4743const PG_NO_ROWS = 'PGRST116' ;
4844
4945const 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 ) {
0 commit comments