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:
+2
-1
@@ -18,7 +18,8 @@ ADMIN_EMAILS=admin@example.com
|
|||||||
NEXT_PUBLIC_NODE_DOMAIN=localhost:43821
|
NEXT_PUBLIC_NODE_DOMAIN=localhost:43821
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:43821
|
NEXT_PUBLIC_APP_URL=http://localhost:43821
|
||||||
|
|
||||||
# Recommended: Stuffbox instance offered as the default user-owned media provider
|
# Recommended: Stuffbox instance offered as the default user-owned media provider.
|
||||||
|
# Synapsis registers its callback automatically; no Stuffbox client ID is required.
|
||||||
STUFFBOX_URL=https://stuffbox.xyz
|
STUFFBOX_URL=https://stuffbox.xyz
|
||||||
|
|
||||||
# Optional: node metadata shown before a node record exists in the database
|
# Optional: node metadata shown before a node record exists in the database
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Uninstalling preserves the database and environment by default. Pass `--purge-da
|
|||||||
|
|
||||||
The node database is a local embedded Turso/SQLite file. Media remains in storage controlled by each user, so exported accounts retain portable media URLs and can move between Synapsis nodes without requiring the old node to transfer a shared upload directory.
|
The node database is a local embedded Turso/SQLite file. Media remains in storage controlled by each user, so exported accounts retain portable media URLs and can move between Synapsis nodes without requiring the old node to transfer a shared upload directory.
|
||||||
|
|
||||||
Stuffbox is the default integration. New installs use `https://stuffbox.xyz`; set `STUFFBOX_URL` to a different public URL when using another or self-hosted Stuffbox service. Synapsis uses a consent and PKCE flow, keeps the resulting tokens encrypted with `AUTH_SECRET`, and sends file bytes directly from the user's browser to Stuffbox.
|
Stuffbox is the default integration. New installs use `https://stuffbox.xyz`; set `STUFFBOX_URL` to a different public URL when using another or self-hosted Stuffbox service. Each Synapsis install registers its callback automatically the first time a user connects, so node operators do not need to create or configure a Stuffbox client ID. Synapsis uses a consent and PKCE flow, keeps the resulting tokens encrypted with `AUTH_SECRET`, and sends file bytes directly from the user's browser to Stuffbox.
|
||||||
|
|
||||||
User-owned S3-compatible storage remains available as an advanced fallback. Supported providers include AWS S3, Cloudflare R2, Backblaze B2, Wasabi, and Contabo.
|
User-owned S3-compatible storage remains available as an advanced fallback. Supported providers include AWS S3, Cloudflare R2, Backblaze B2, Wasabi, and Contabo.
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
const tokens = await exchangeAuthorizationCode(pending.baseUrl, {
|
||||||
|
clientId: pending.clientId,
|
||||||
code,
|
code,
|
||||||
codeVerifier: pending.verifier,
|
codeVerifier: pending.verifier,
|
||||||
redirectUri: pending.redirectUri,
|
redirectUri: pending.callbackUrl,
|
||||||
});
|
});
|
||||||
await saveStuffboxTokens(user.id, pending.baseUrl, tokens);
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof StuffboxApiError) {
|
if (error instanceof StuffboxApiError) {
|
||||||
return popupResponse(origin, false, error.message, attemptId);
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/db';
|
|
||||||
import { requireAuth } from '@/lib/auth';
|
import { requireAuth } from '@/lib/auth';
|
||||||
import { configuredStuffboxUrl, createConnectionRequest, StuffboxApiError } from '@/lib/stuffbox/client';
|
import { configuredStuffboxUrl, createConnectionRequest, StuffboxApiError } from '@/lib/stuffbox/client';
|
||||||
import { saveStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
|
import { saveStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
|
||||||
@@ -27,14 +26,10 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const origin = nodeOrigin(request);
|
const origin = nodeOrigin(request);
|
||||||
const redirectUri = `${origin}/api/storage/stuffbox/callback`;
|
const callbackUrl = `${origin}/api/storage/stuffbox/callback`;
|
||||||
const node = user.nodeId
|
|
||||||
? await db.query.nodes.findFirst({ where: { id: user.nodeId } })
|
|
||||||
: null;
|
|
||||||
const pkce = generatePkce();
|
const pkce = generatePkce();
|
||||||
const connection = await createConnectionRequest(baseUrl, {
|
const connection = await createConnectionRequest(baseUrl, {
|
||||||
nodeName: node?.name || new URL(origin).host,
|
callbackUrl,
|
||||||
callbackUrl: redirectUri,
|
|
||||||
codeChallenge: pkce.challenge,
|
codeChallenge: pkce.challenge,
|
||||||
state: pkce.state,
|
state: pkce.state,
|
||||||
scopes: STUFFBOX_SCOPES,
|
scopes: STUFFBOX_SCOPES,
|
||||||
@@ -43,9 +38,10 @@ export async function POST(request: NextRequest) {
|
|||||||
await saveStuffboxConnectionState({
|
await saveStuffboxConnectionState({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
clientId: connection.clientId,
|
||||||
verifier: pkce.verifier,
|
verifier: pkce.verifier,
|
||||||
state: pkce.state,
|
state: pkce.state,
|
||||||
redirectUri,
|
callbackUrl: connection.callbackUrl,
|
||||||
expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000),
|
expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,77 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createUpload, exchangeAuthorizationCode } from './client';
|
import { createConnectionRequest, createUpload, exchangeAuthorizationCode } from './client';
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
describe('Stuffbox client', () => {
|
describe('Stuffbox client', () => {
|
||||||
|
it('self-registers the exact callback and uses the canonical identity returned by Stuffbox', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: {
|
||||||
|
request_id: 'request-1',
|
||||||
|
client_id: 'client-1',
|
||||||
|
callback_url: 'https://synapsis.test/api/storage/stuffbox/callback',
|
||||||
|
authorization_url: 'https://stuffbox.test/connect/request-1',
|
||||||
|
expires_at: '2026-07-15T00:10:00Z',
|
||||||
|
} }), { status: 201, headers: { 'Content-Type': 'application/json' } }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
|
||||||
|
await expect(createConnectionRequest('https://stuffbox.test/', {
|
||||||
|
callbackUrl: 'https://synapsis.test/api/storage/stuffbox/callback',
|
||||||
|
codeChallenge: 'challenge',
|
||||||
|
state: 'state',
|
||||||
|
scopes: ['assets:write'],
|
||||||
|
})).resolves.toEqual({
|
||||||
|
id: 'request-1',
|
||||||
|
clientId: 'client-1',
|
||||||
|
callbackUrl: 'https://synapsis.test/api/storage/stuffbox/callback',
|
||||||
|
authorizationUrl: 'https://stuffbox.test/connect/request-1',
|
||||||
|
expiresAt: '2026-07-15T00:10:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledOnce();
|
||||||
|
const [url, init] = fetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe('https://stuffbox.test/api/v1/connection-requests');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({
|
||||||
|
registration_mode: 'self_hosted',
|
||||||
|
callback_url: 'https://synapsis.test/api/storage/stuffbox/callback',
|
||||||
|
code_challenge: 'challenge',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
scopes: ['assets:write'],
|
||||||
|
state: 'state',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['client_id', {
|
||||||
|
request_id: 'request-1',
|
||||||
|
callback_url: 'https://synapsis.test/api/storage/stuffbox/callback',
|
||||||
|
authorization_url: 'https://stuffbox.test/connect/request-1',
|
||||||
|
expires_at: '2026-07-15T00:10:00Z',
|
||||||
|
}],
|
||||||
|
['callback_url', {
|
||||||
|
request_id: 'request-1',
|
||||||
|
client_id: 'client-1',
|
||||||
|
authorization_url: 'https://stuffbox.test/connect/request-1',
|
||||||
|
expires_at: '2026-07-15T00:10:00Z',
|
||||||
|
}],
|
||||||
|
])('rejects a connection response missing %s', async (field, data) => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data }), {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})));
|
||||||
|
|
||||||
|
await expect(createConnectionRequest('https://stuffbox.test', {
|
||||||
|
callbackUrl: 'https://synapsis.test/api/storage/stuffbox/callback',
|
||||||
|
codeChallenge: 'challenge',
|
||||||
|
state: 'state',
|
||||||
|
scopes: ['assets:write'],
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
message: `Stuffbox response is missing ${field}`,
|
||||||
|
status: 502,
|
||||||
|
code: 'invalid_response',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('exchanges an authorization code and accepts the v1 snake-case response', async () => {
|
it('exchanges an authorization code and accepts the v1 snake-case response', async () => {
|
||||||
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
access_token: 'access-token',
|
access_token: 'access-token',
|
||||||
@@ -15,6 +83,7 @@ describe('Stuffbox client', () => {
|
|||||||
vi.stubGlobal('fetch', fetch);
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
|
||||||
const tokens = await exchangeAuthorizationCode('https://stuffbox.test/', {
|
const tokens = await exchangeAuthorizationCode('https://stuffbox.test/', {
|
||||||
|
clientId: 'client-1',
|
||||||
code: 'authorization-code',
|
code: 'authorization-code',
|
||||||
codeVerifier: 'verifier',
|
codeVerifier: 'verifier',
|
||||||
redirectUri: 'https://synapsis.test/api/storage/stuffbox/callback',
|
redirectUri: 'https://synapsis.test/api/storage/stuffbox/callback',
|
||||||
@@ -27,7 +96,17 @@ describe('Stuffbox client', () => {
|
|||||||
refreshTokenExpiresIn: 2_592_000,
|
refreshTokenExpiresIn: 2_592_000,
|
||||||
scopes: ['assets:read', 'assets:write'],
|
scopes: ['assets:read', 'assets:write'],
|
||||||
});
|
});
|
||||||
expect(fetch).toHaveBeenCalledWith('https://stuffbox.test/api/v1/token', expect.objectContaining({ method: 'POST' }));
|
expect(fetch).toHaveBeenCalledOnce();
|
||||||
|
const [url, init] = fetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe('https://stuffbox.test/api/v1/token');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: 'client-1',
|
||||||
|
code: 'authorization-code',
|
||||||
|
code_verifier: 'verifier',
|
||||||
|
redirect_uri: 'https://synapsis.test/api/storage/stuffbox/callback',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes a direct upload session without exposing the bearer token to the browser response', async () => {
|
it('normalizes a direct upload session without exposing the bearer token to the browser response', async () => {
|
||||||
@@ -55,7 +134,7 @@ describe('Stuffbox client', () => {
|
|||||||
}), { status: 401, headers: { 'Content-Type': 'application/json' } })));
|
}), { status: 401, headers: { 'Content-Type': 'application/json' } })));
|
||||||
|
|
||||||
await expect(exchangeAuthorizationCode('https://stuffbox.test', {
|
await expect(exchangeAuthorizationCode('https://stuffbox.test', {
|
||||||
code: 'bad', codeVerifier: 'verifier', redirectUri: 'https://synapsis.test/callback',
|
clientId: 'client-1', code: 'bad', codeVerifier: 'verifier', redirectUri: 'https://synapsis.test/callback',
|
||||||
})).rejects.toMatchObject({
|
})).rejects.toMatchObject({
|
||||||
message: 'Connection expired', status: 401, code: 'invalid_grant',
|
message: 'Connection expired', status: 401, code: 'invalid_grant',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,17 +97,22 @@ export function configuredStuffboxUrl(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createConnectionRequest(baseUrl: string, input: {
|
export async function createConnectionRequest(baseUrl: string, input: {
|
||||||
nodeName: string;
|
|
||||||
callbackUrl: string;
|
callbackUrl: string;
|
||||||
codeChallenge: string;
|
codeChallenge: string;
|
||||||
state: string;
|
state: string;
|
||||||
scopes: readonly StuffboxScope[];
|
scopes: readonly StuffboxScope[];
|
||||||
}): Promise<{ id: string; authorizationUrl: string; expiresAt: string }> {
|
}): Promise<{
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
authorizationUrl: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}> {
|
||||||
const data = object(await request(baseUrl, '/api/v1/connection-requests', {
|
const data = object(await request(baseUrl, '/api/v1/connection-requests', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
node_name: input.nodeName,
|
registration_mode: 'self_hosted',
|
||||||
callback_url: input.callbackUrl,
|
callback_url: input.callbackUrl,
|
||||||
code_challenge: input.codeChallenge,
|
code_challenge: input.codeChallenge,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
@@ -117,12 +122,15 @@ export async function createConnectionRequest(baseUrl: string, input: {
|
|||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
id: string(data, 'id', 'request_id'),
|
id: string(data, 'id', 'request_id'),
|
||||||
|
clientId: string(data, 'client_id'),
|
||||||
|
callbackUrl: string(data, 'callback_url'),
|
||||||
authorizationUrl: string(data, 'authorizationUrl', 'authorization_url'),
|
authorizationUrl: string(data, 'authorizationUrl', 'authorization_url'),
|
||||||
expiresAt: string(data, 'expiresAt', 'expires_at'),
|
expiresAt: string(data, 'expiresAt', 'expires_at'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exchangeAuthorizationCode(baseUrl: string, input: {
|
export async function exchangeAuthorizationCode(baseUrl: string, input: {
|
||||||
|
clientId: string;
|
||||||
code: string;
|
code: string;
|
||||||
codeVerifier: string;
|
codeVerifier: string;
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
@@ -132,6 +140,7 @@ export async function exchangeAuthorizationCode(baseUrl: string, input: {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
|
client_id: input.clientId,
|
||||||
code: input.code,
|
code: input.code,
|
||||||
code_verifier: input.codeVerifier,
|
code_verifier: input.codeVerifier,
|
||||||
redirect_uri: input.redirectUri,
|
redirect_uri: input.redirectUri,
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ const CONTEXT = 'stuffbox-connection-state';
|
|||||||
export interface StuffboxConnectionState {
|
export interface StuffboxConnectionState {
|
||||||
userId: string;
|
userId: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
clientId: string;
|
||||||
verifier: string;
|
verifier: string;
|
||||||
state: string;
|
state: string;
|
||||||
redirectUri: string;
|
callbackUrl: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +31,20 @@ export async function consumeStuffboxConnectionState(): Promise<StuffboxConnecti
|
|||||||
cookieStore.delete(COOKIE_NAME);
|
cookieStore.delete(COOKIE_NAME);
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(openStuffboxSecret(token, CONTEXT)) as StuffboxConnectionState;
|
const value = JSON.parse(openStuffboxSecret(token, CONTEXT)) as Partial<StuffboxConnectionState>;
|
||||||
return value.expiresAt > Date.now() ? value : null;
|
if (
|
||||||
|
typeof value.userId !== 'string'
|
||||||
|
|| typeof value.baseUrl !== 'string'
|
||||||
|
|| typeof value.clientId !== 'string'
|
||||||
|
|| typeof value.verifier !== 'string'
|
||||||
|
|| typeof value.state !== 'string'
|
||||||
|
|| typeof value.callbackUrl !== 'string'
|
||||||
|
|| typeof value.expiresAt !== 'number'
|
||||||
|
|| value.expiresAt <= Date.now()
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as StuffboxConnectionState;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user