Make every Synapsis installation register its exact Stuffbox callback automatically, persist the returned public client identity through PKCE, and remove manual operator client setup.
Hop-State: A_06FP9ZHD1XSYW28M9X8V7QR Hop-Proposal: R_06FP9ZFX5NN6M3YRB1XHX2R Hop-Task: T_06FP9XCTDAAN1SZZVBD5G5R Hop-Attempt: AT_06FP9XCTDBN2JFGK1XC6830
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { exchangeAuthorizationCode } from '@/lib/stuffbox/client';
|
||||
import { consumeStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
|
||||
import { renderStuffboxPopupResponse } from '@/lib/stuffbox/popup-response';
|
||||
import { saveStuffboxTokens } from '@/lib/stuffbox/tokens';
|
||||
import { GET } from './route';
|
||||
|
||||
vi.mock('@/lib/auth', () => ({ requireAuth: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/stuffbox/client', () => {
|
||||
class MockStuffboxApiError extends Error {}
|
||||
|
||||
return {
|
||||
exchangeAuthorizationCode: vi.fn(),
|
||||
StuffboxApiError: MockStuffboxApiError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lib/stuffbox/connection-state', () => ({ consumeStuffboxConnectionState: vi.fn() }));
|
||||
vi.mock('@/lib/stuffbox/popup-response', () => ({ renderStuffboxPopupResponse: vi.fn() }));
|
||||
vi.mock('@/lib/stuffbox/tokens', () => ({ saveStuffboxTokens: vi.fn() }));
|
||||
|
||||
describe('GET /api/storage/stuffbox/callback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ id: 'user-1' } as never);
|
||||
vi.mocked(consumeStuffboxConnectionState).mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
baseUrl: 'https://stuffbox.example',
|
||||
clientId: 'client-returned-by-stuffbox',
|
||||
verifier: 'pkce-verifier',
|
||||
state: 'connection-state',
|
||||
callbackUrl: 'https://canonical.synapsis.example/api/storage/stuffbox/callback',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(exchangeAuthorizationCode).mockResolvedValue({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresIn: 900,
|
||||
scopes: ['assets:write'],
|
||||
});
|
||||
vi.mocked(saveStuffboxTokens).mockResolvedValue(undefined);
|
||||
vi.mocked(renderStuffboxPopupResponse).mockReturnValue('<html>connected</html>');
|
||||
});
|
||||
|
||||
it('exchanges the code with the same client ID and callback saved at connect time', async () => {
|
||||
const response = await GET(new NextRequest(
|
||||
'https://synapsis.example/api/storage/stuffbox/callback?code=authorization-code&state=connection-state',
|
||||
));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(exchangeAuthorizationCode).toHaveBeenCalledWith('https://stuffbox.example', {
|
||||
clientId: 'client-returned-by-stuffbox',
|
||||
code: 'authorization-code',
|
||||
codeVerifier: 'pkce-verifier',
|
||||
redirectUri: 'https://canonical.synapsis.example/api/storage/stuffbox/callback',
|
||||
});
|
||||
expect(saveStuffboxTokens).toHaveBeenCalledWith('user-1', 'https://stuffbox.example', {
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresIn: 900,
|
||||
scopes: ['assets:write'],
|
||||
});
|
||||
expect(renderStuffboxPopupResponse).toHaveBeenCalledWith(
|
||||
'https://canonical.synapsis.example',
|
||||
true,
|
||||
'Stuffbox connected.',
|
||||
'connection-state',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,12 +29,13 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const tokens = await exchangeAuthorizationCode(pending.baseUrl, {
|
||||
clientId: pending.clientId,
|
||||
code,
|
||||
codeVerifier: pending.verifier,
|
||||
redirectUri: pending.redirectUri,
|
||||
redirectUri: pending.callbackUrl,
|
||||
});
|
||||
await saveStuffboxTokens(user.id, pending.baseUrl, tokens);
|
||||
return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.', attemptId);
|
||||
return popupResponse(new URL(pending.callbackUrl).origin, true, 'Stuffbox connected.', attemptId);
|
||||
} catch (error) {
|
||||
if (error instanceof StuffboxApiError) {
|
||||
return popupResponse(origin, false, error.message, attemptId);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { configuredStuffboxUrl, createConnectionRequest } from '@/lib/stuffbox/client';
|
||||
import { saveStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
|
||||
import { generatePkce } from '@/lib/stuffbox/crypto';
|
||||
import { POST } from './route';
|
||||
|
||||
vi.mock('@/lib/auth', () => ({ requireAuth: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/stuffbox/client', () => {
|
||||
class MockStuffboxApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configuredStuffboxUrl: vi.fn(),
|
||||
createConnectionRequest: vi.fn(),
|
||||
StuffboxApiError: MockStuffboxApiError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lib/stuffbox/connection-state', () => ({ saveStuffboxConnectionState: vi.fn() }));
|
||||
vi.mock('@/lib/stuffbox/crypto', () => ({ generatePkce: vi.fn() }));
|
||||
|
||||
describe('POST /api/storage/stuffbox/connect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-15T01:00:00.000Z'));
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://synapsis.example/app');
|
||||
vi.mocked(requireAuth).mockResolvedValue({ id: 'user-1' } as never);
|
||||
vi.mocked(configuredStuffboxUrl).mockReturnValue('https://stuffbox.example');
|
||||
vi.mocked(generatePkce).mockReturnValue({
|
||||
challenge: 'pkce-challenge',
|
||||
verifier: 'pkce-verifier',
|
||||
state: 'connection-state',
|
||||
});
|
||||
vi.mocked(createConnectionRequest).mockResolvedValue({
|
||||
id: 'request-1',
|
||||
clientId: 'client-returned-by-stuffbox',
|
||||
callbackUrl: 'https://canonical.synapsis.example/api/storage/stuffbox/callback',
|
||||
authorizationUrl: 'https://stuffbox.example/connect/request-1',
|
||||
expiresAt: '2026-07-15T01:05:00.000Z',
|
||||
});
|
||||
vi.mocked(saveStuffboxConnectionState).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('persists the client ID and canonical callback returned by Stuffbox', async () => {
|
||||
const response = await POST(new NextRequest('https://synapsis.example/api/storage/stuffbox/connect', {
|
||||
method: 'POST',
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(createConnectionRequest).toHaveBeenCalledWith('https://stuffbox.example', {
|
||||
callbackUrl: 'https://synapsis.example/api/storage/stuffbox/callback',
|
||||
codeChallenge: 'pkce-challenge',
|
||||
state: 'connection-state',
|
||||
scopes: ['assets:write'],
|
||||
});
|
||||
expect(saveStuffboxConnectionState).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
baseUrl: 'https://stuffbox.example',
|
||||
clientId: 'client-returned-by-stuffbox',
|
||||
verifier: 'pkce-verifier',
|
||||
state: 'connection-state',
|
||||
callbackUrl: 'https://canonical.synapsis.example/api/storage/stuffbox/callback',
|
||||
expiresAt: Date.parse('2026-07-15T01:05:00.000Z'),
|
||||
});
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
authorizationUrl: 'https://stuffbox.example/connect/request-1',
|
||||
connectionAttempt: 'connection-state',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { configuredStuffboxUrl, createConnectionRequest, StuffboxApiError } from '@/lib/stuffbox/client';
|
||||
import { saveStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
|
||||
@@ -27,14 +26,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const origin = nodeOrigin(request);
|
||||
const redirectUri = `${origin}/api/storage/stuffbox/callback`;
|
||||
const node = user.nodeId
|
||||
? await db.query.nodes.findFirst({ where: { id: user.nodeId } })
|
||||
: null;
|
||||
const callbackUrl = `${origin}/api/storage/stuffbox/callback`;
|
||||
const pkce = generatePkce();
|
||||
const connection = await createConnectionRequest(baseUrl, {
|
||||
nodeName: node?.name || new URL(origin).host,
|
||||
callbackUrl: redirectUri,
|
||||
callbackUrl,
|
||||
codeChallenge: pkce.challenge,
|
||||
state: pkce.state,
|
||||
scopes: STUFFBOX_SCOPES,
|
||||
@@ -43,9 +38,10 @@ export async function POST(request: NextRequest) {
|
||||
await saveStuffboxConnectionState({
|
||||
userId: user.id,
|
||||
baseUrl,
|
||||
clientId: connection.clientId,
|
||||
verifier: pkce.verifier,
|
||||
state: pkce.state,
|
||||
redirectUri,
|
||||
callbackUrl: connection.callbackUrl,
|
||||
expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user