Make Stuffbox linking resilient to cross-origin popup isolation and stale callbacks

Hop-State: A_06FP8WHY25K99094H3X60A8
Hop-Proposal: R_06FP8WGCVDDG61H0VZ1XQJR
Hop-Task: T_06FP8V5D2HSJG08NRN6VS3G
Hop-Attempt: AT_06FP8V5D2KATGJNV2M2SGRR
This commit is contained in:
2026-07-14 23:23:56 -07:00
committed by Hop
parent fb624c4ed1
commit b14be97965
10 changed files with 332 additions and 85 deletions
@@ -28,6 +28,7 @@ export async function GET() {
provider: stuffbox ? 'stuffbox' : user.storageProvider ? 's3' : null,
stuffboxAvailable: Boolean(configuredStuffboxUrl()),
stuffboxBaseUrl: stuffbox?.baseUrl ?? null,
stuffboxUpdatedAt: stuffbox?.updatedAt.toISOString() ?? null,
s3Provider: user.storageProvider ?? null,
});
} catch (error) {
@@ -5,8 +5,8 @@ import { consumeStuffboxConnectionState } from '@/lib/stuffbox/connection-state'
import { saveStuffboxTokens } from '@/lib/stuffbox/tokens';
import { renderStuffboxPopupResponse } from '@/lib/stuffbox/popup-response';
function popupResponse(origin: string, success: boolean, message: string): NextResponse {
return new NextResponse(renderStuffboxPopupResponse(origin, success, message), {
function popupResponse(origin: string, success: boolean, message: string, attemptId?: string): NextResponse {
return new NextResponse(renderStuffboxPopupResponse(origin, success, message, attemptId), {
status: success ? 200 : 400,
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
});
@@ -14,16 +14,18 @@ function popupResponse(origin: string, success: boolean, message: string): NextR
export async function GET(request: NextRequest) {
const origin = request.nextUrl.origin;
let attemptId: string | undefined;
try {
const user = await requireAuth();
const pending = await consumeStuffboxConnectionState();
attemptId = pending?.state;
const code = request.nextUrl.searchParams.get('code');
const state = request.nextUrl.searchParams.get('state');
const denied = request.nextUrl.searchParams.get('error');
if (denied) return popupResponse(origin, false, 'Stuffbox access was not approved.');
if (denied) return popupResponse(origin, false, 'Stuffbox access was not approved.', attemptId);
if (!pending || pending.userId !== user.id || !code || state !== pending.state) {
return popupResponse(origin, false, 'The Stuffbox connection request is invalid or expired.');
return popupResponse(origin, false, 'The Stuffbox connection request is invalid or expired.', attemptId);
}
const tokens = await exchangeAuthorizationCode(pending.baseUrl, {
@@ -32,12 +34,12 @@ export async function GET(request: NextRequest) {
redirectUri: pending.redirectUri,
});
await saveStuffboxTokens(user.id, pending.baseUrl, tokens);
return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.');
return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.', attemptId);
} catch (error) {
if (error instanceof StuffboxApiError) {
return popupResponse(origin, false, error.message);
return popupResponse(origin, false, error.message, attemptId);
}
console.error('Stuffbox callback error:', error);
return popupResponse(origin, false, 'Stuffbox could not be connected.');
return popupResponse(origin, false, 'Stuffbox could not be connected.', attemptId);
}
}
@@ -49,7 +49,11 @@ export async function POST(request: NextRequest) {
expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000),
});
return NextResponse.json({ authorizationUrl: connection.authorizationUrl });
return NextResponse.json({
authorizationUrl: connection.authorizationUrl,
connectionStartedAt: new Date().toISOString(),
connectionAttempt: pkce.state,
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });