From 26e07537499227c3b748186e3fb1977e2b3a7cb3 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 3 Jun 2026 15:33:52 +0200 Subject: [PATCH 01/15] Strip secrets from adapter config Apply stripSecrets to decrypted adapter configs in the GET handler before stringifying and returning them. This prevents sensitive fields from being exposed in the API response by importing and using stripSecrets from the crypto utility. --- src/app/api/adapters/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/adapters/route.ts b/src/app/api/adapters/route.ts index fcf1582c..cc12fcb7 100644 --- a/src/app/api/adapters/route.ts +++ b/src/app/api/adapters/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -import { encryptConfig, decryptConfig } from "@/lib/crypto"; +import { encryptConfig, decryptConfig, stripSecrets } from "@/lib/crypto"; import { headers } from "next/headers"; import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; @@ -62,7 +62,7 @@ export async function GET(req: NextRequest) { return { ...adapter, - config: JSON.stringify(decryptedConfig) + config: JSON.stringify(stripSecrets(decryptedConfig)) }; } catch (e: unknown) { log.error("Failed to process config for adapter", { adapterId: adapter.id }, wrapError(e)); From 0fe4e6eb9d7668e411ad06df023061aad287ac8b Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 3 Jun 2026 15:34:27 +0200 Subject: [PATCH 02/15] Add tests for adapter secret disclosure Introduce regression tests for GET /api/adapters to ensure sensitive fields are never returned decrypted to read/view users. The new test file stubs environment and dependencies, encrypts various adapter configs (storage, OAuth storage, database, notification) and asserts that sensitive keys (access keys, secrets, tokens, passwords, private keys, etc.) are masked while non-sensitive fields are preserved. Also includes a test asserting a 400 response when the required type query parameter is missing. --- .../adapters-route-secret-disclosure.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tests/unit/lib/adapters-route-secret-disclosure.test.ts diff --git a/tests/unit/lib/adapters-route-secret-disclosure.test.ts b/tests/unit/lib/adapters-route-secret-disclosure.test.ts new file mode 100644 index 00000000..3b2bf5ce --- /dev/null +++ b/tests/unit/lib/adapters-route-secret-disclosure.test.ts @@ -0,0 +1,182 @@ +/** + * Regression tests for GHSA adapter secret disclosure. + * + * Verifies that GET /api/adapters never returns decrypted values for fields + * in SENSITIVE_KEYS to callers who only have read/view permission. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Encryption key for AES-256-GCM (64 hex chars = 32 bytes) ───────────────── +const VALID_KEY = "a".repeat(64); + +// Set the key before any module is imported so crypto functions work throughout +vi.stubEnv("ENCRYPTION_KEY", VALID_KEY); + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("@/lib/logging/logger", () => ({ + logger: { + child: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +vi.mock("@/lib/auth/access-control", () => ({ + getAuthContext: vi.fn().mockResolvedValue({ userId: "user-readonly" }), + checkPermissionWithContext: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/adapters", () => ({ + registerAdapters: vi.fn(), +})); + +vi.mock("@/services/audit-service", () => ({ + auditService: { log: vi.fn() }, +})); + +vi.mock("@/lib/core/audit-types", () => ({ + AUDIT_ACTIONS: {}, + AUDIT_RESOURCES: {}, +})); + +vi.mock("@/lib/auth/permissions", () => ({ + PERMISSIONS: { + DESTINATIONS: { READ: "destinations:read", WRITE: "destinations:write" }, + SOURCES: { VIEW: "sources:view", WRITE: "sources:write" }, + NOTIFICATIONS: { READ: "notifications:read", WRITE: "notifications:write" }, + }, +})); + +vi.mock("@/lib/adapters/credential-validation", () => ({ + validateCredentialAssignments: vi.fn(), +})); + +const mockFindMany = vi.fn(); +vi.mock("@/lib/prisma", () => ({ + default: { + adapterConfig: { + findMany: (...args: any[]) => mockFindMany(...args), + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn(), + }, + }, +})); + +// Import real crypto (not mocked) and route after all vi.mock calls +import { encryptConfig } from "@/lib/crypto"; +const { GET } = await import("@/app/api/adapters/route"); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("GET /api/adapters – secret disclosure regression", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("does not return decrypted secretAccessKey / accessKeyId for storage adapters", async () => { + const encryptedConfig = JSON.stringify(encryptConfig({ + endpoint: "https://s3.example.com", + bucket: "my-bucket", + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + })); + + mockFindMany.mockResolvedValue([ + { id: "1", type: "storage", name: "S3 Prod", adapterId: "s3-generic", config: encryptedConfig, createdAt: new Date() }, + ]); + + const req = new NextRequest("http://localhost/api/adapters?type=storage"); + const res = await GET(req); + expect(res.status).toBe(200); + + const body = await res.json(); + const config = JSON.parse(body[0].config); + + expect(config.bucket).toBe("my-bucket"); + expect(config.endpoint).toBe("https://s3.example.com"); + expect(config.accessKeyId).toBe(""); + expect(config.secretAccessKey).toBe(""); + }); + + it("does not return decrypted clientSecret / refreshToken for OAuth storage adapters", async () => { + const encryptedConfig = JSON.stringify(encryptConfig({ + clientId: "my-client-id", + clientSecret: "POC_CLIENT_SECRET_SHOULD_NOT_LEAK", + refreshToken: "POC_REFRESH_TOKEN_SHOULD_NOT_LEAK", + folderId: "root", + })); + + mockFindMany.mockResolvedValue([ + { id: "2", type: "storage", name: "Google Drive", adapterId: "google-drive", config: encryptedConfig, createdAt: new Date() }, + ]); + + const req = new NextRequest("http://localhost/api/adapters?type=storage"); + const res = await GET(req); + const body = await res.json(); + const config = JSON.parse(body[0].config); + + expect(config.clientId).toBe("my-client-id"); + expect(config.folderId).toBe("root"); + expect(config.clientSecret).toBe(""); + expect(config.refreshToken).toBe(""); + }); + + it("does not return decrypted password / privateKey for database adapters", async () => { + const encryptedConfig = JSON.stringify(encryptConfig({ + host: "db.internal", + user: "dbuser", + password: "super-secret-db-pass", + privateKey: "-----BEGIN RSA PRIVATE KEY-----\nFAKE\n-----END RSA PRIVATE KEY-----", + })); + + mockFindMany.mockResolvedValue([ + { id: "3", type: "database", name: "Prod Postgres", adapterId: "postgres", config: encryptedConfig, createdAt: new Date() }, + ]); + + const req = new NextRequest("http://localhost/api/adapters?type=database"); + const res = await GET(req); + const body = await res.json(); + const config = JSON.parse(body[0].config); + + expect(config.host).toBe("db.internal"); + expect(config.user).toBe("dbuser"); + expect(config.password).toBe(""); + expect(config.privateKey).toBe(""); + }); + + it("does not return decrypted webhookUrl / token for notification adapters", async () => { + const encryptedConfig = JSON.stringify(encryptConfig({ + webhookUrl: "https://hooks.slack.com/services/SECRET_TOKEN", + token: "xoxb-some-slack-bot-token", + channel: "#alerts", + })); + + mockFindMany.mockResolvedValue([ + { id: "4", type: "notification", name: "Slack Alerts", adapterId: "slack", config: encryptedConfig, createdAt: new Date() }, + ]); + + const req = new NextRequest("http://localhost/api/adapters?type=notification"); + const res = await GET(req); + const body = await res.json(); + const config = JSON.parse(body[0].config); + + expect(config.channel).toBe("#alerts"); + expect(config.webhookUrl).toBe(""); + expect(config.token).toBe(""); + }); + + it("returns 400 when type parameter is missing", async () => { + const req = new NextRequest("http://localhost/api/adapters"); + const res = await GET(req); + expect(res.status).toBe(400); + }); +}); From e6b06dc378b64fd70d3a1922be145641a2d54fd0 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 3 Jun 2026 22:09:36 +0200 Subject: [PATCH 03/15] Move adapter secrets to credential profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor to centralize sensitive adapter credentials into CredentialProfile (vault) and prevent secrets from ever being returned to the client. Key changes: - OAuth flows (Dropbox, Google Drive, OneDrive) now require an assigned OAUTH credential profile and use the profile's clientId/clientSecret; refresh tokens are stored on the credential profile via updateCredentialProfile instead of in the adapter config. - Adapter update (PUT) now preserves existing secrets on edit by decrypting the existing config, merging incoming values with stored secrets (mergeSecrets), and only re-encrypting when config is provided. - Adapter listing endpoint now returns a safe DTO (toAdapterListItem) that redacts sensitive keys and exposes a secretStatus map so the UI can indicate which fields have stored values without exposing them. - Filesystem folder-browser endpoints (Dropbox/Google Drive/OneDrive) accept adapterId and resolve credentials server-side (resolveAdapterConfig); permission checks changed to DESTINATIONS.READ. - UI updates: SecretStatusContext and provider, schema fields render "saved — leave blank to keep" placeholders, folder browser components now pass adapterConfigId instead of raw credentials, credential picker and credential profile dialogs were extended to support WEBHOOK and OAUTH types. - Config resolver extended to apply WEBHOOK and OAUTH profiles and to spray additional token field names (e.g. authToken). - MongoDB config/connection honors deprecated inline URI for backward compatibility and schema description updated. - Added Adapter DTO and tests (adapter-secrets, adapter-dto, crypto), and updated changelog to document vNEXT Docker image. This change improves security by keeping secret material in audited credential profiles and preventing decrypted secrets from being serialized to clients. --- src/app/api/adapters/[id]/route.ts | 26 ++++- src/app/api/adapters/dropbox/auth/route.ts | 17 ++- .../api/adapters/dropbox/callback/route.ts | 34 +++--- .../api/adapters/google-drive/auth/route.ts | 18 ++- .../adapters/google-drive/callback/route.ts | 36 +++--- src/app/api/adapters/onedrive/auth/route.ts | 19 ++- .../api/adapters/onedrive/callback/route.ts | 34 +++--- src/app/api/adapters/route.ts | 34 ++---- .../api/system/filesystem/dropbox/route.ts | 26 ++++- .../system/filesystem/google-drive/route.ts | 26 ++++- .../api/system/filesystem/onedrive/route.ts | 26 ++++- src/components/adapter/adapter-form.tsx | 11 +- src/components/adapter/credential-picker.tsx | 2 + .../adapter/dropbox-folder-browser.tsx | 11 +- src/components/adapter/form-sections.tsx | 76 +++++------- .../adapter/google-drive-folder-browser.tsx | 11 +- .../adapter/onedrive-folder-browser.tsx | 11 +- src/components/adapter/schema-field.tsx | 11 +- .../adapter/secret-status-context.tsx | 17 +++ src/components/adapter/types.ts | 4 +- .../settings/credential-profile-dialog.tsx | 26 ++++- .../settings/credential-profiles-list.tsx | 2 + src/lib/adapters/config-resolver.ts | 23 +++- .../adapters/database/mongodb/connection.ts | 3 + src/lib/adapters/definitions/database.ts | 6 +- src/lib/adapters/dto.ts | 105 +++++++++++++++++ src/lib/adapters/notification/discord.ts | 1 + .../adapters/notification/generic-webhook.ts | 1 + src/lib/adapters/notification/slack.ts | 1 + src/lib/adapters/notification/teams.ts | 1 + src/lib/adapters/notification/twilio-sms.ts | 3 + src/lib/adapters/storage/dropbox.ts | 3 + src/lib/adapters/storage/google-drive.ts | 3 + src/lib/adapters/storage/onedrive.ts | 3 + src/lib/core/credential-requirements.ts | 16 ++- src/lib/core/credentials.ts | 26 ++++- src/lib/crypto/index.ts | 104 ++++++++++++++++- tests/audit/adapter-secrets.test.ts | 73 ++++++++++++ tests/unit/adapters/config-resolver.test.ts | 61 +++++++++- tests/unit/lib/adapter-dto.test.ts | 94 +++++++++++++++ tests/unit/lib/crypto.test.ts | 109 ++++++++++++++++++ 41 files changed, 925 insertions(+), 189 deletions(-) create mode 100644 src/components/adapter/secret-status-context.tsx create mode 100644 src/lib/adapters/dto.ts create mode 100644 tests/audit/adapter-secrets.test.ts create mode 100644 tests/unit/lib/adapter-dto.test.ts diff --git a/src/app/api/adapters/[id]/route.ts b/src/app/api/adapters/[id]/route.ts index d290e0e3..7239087a 100644 --- a/src/app/api/adapters/[id]/route.ts +++ b/src/app/api/adapters/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -import { encryptConfig } from "@/lib/crypto"; +import { encryptConfig, decryptConfig, mergeSecrets } from "@/lib/crypto"; import { headers } from "next/headers"; import { auditService } from "@/services/audit-service"; import { AUDIT_ACTIONS, AUDIT_RESOURCES } from "@/lib/core/audit-types"; @@ -111,7 +111,7 @@ export async function PUT( // RBAC: Check permission based on adapter type const existingAdapter = await prisma.adapterConfig.findUnique({ where: { id: params.id }, - select: { type: true, adapterId: true, lastError: true } + select: { type: true, adapterId: true, lastError: true, config: true } }); if (!existingAdapter) { return NextResponse.json({ success: false, error: "Adapter not found" }, { status: 404 }); @@ -151,15 +151,29 @@ export async function PUT( } } - const configObj = typeof config === 'string' ? JSON.parse(config) : config; - const encryptedConfig = encryptConfig(configObj); - const configString = JSON.stringify(encryptedConfig); + // Build the encrypted config string only when a config payload was supplied. + // Secret-preserving merge: the API returns redacted secrets, so an edit + // round-trip submits empty secret fields. Re-fill them from the existing + // (decrypted) config before re-encrypting so we never clobber a real + // secret with an encrypted empty string (data-loss bug). + let configString: string | undefined; + if (config !== undefined) { + const incomingConfig = typeof config === 'string' ? JSON.parse(config) : config; + let existingDecrypted: unknown = {}; + try { + existingDecrypted = decryptConfig(JSON.parse(existingAdapter.config)); + } catch (e) { + log.warn("Failed to decrypt existing config during update; secret merge skipped", { adapterId: params.id }, wrapError(e)); + } + const mergedConfig = mergeSecrets(incomingConfig, existingDecrypted); + configString = JSON.stringify(encryptConfig(mergedConfig)); + } const updatedAdapter = await prisma.adapterConfig.update({ where: { id: params.id }, data: { name, - config: configString, + ...(configString !== undefined ? { config: configString } : {}), ...(primaryCredentialId !== undefined ? { primaryCredentialId: primaryCredentialId ?? null } : {}), ...(sshCredentialId !== undefined ? { sshCredentialId: sshCredentialId ?? null } : {}), ...(metadata !== undefined ? { metadata: JSON.stringify(metadata) } : {}), diff --git a/src/app/api/adapters/dropbox/auth/route.ts b/src/app/api/adapters/dropbox/auth/route.ts index 32a00cfc..5045b6e9 100644 --- a/src/app/api/adapters/dropbox/auth/route.ts +++ b/src/app/api/adapters/dropbox/auth/route.ts @@ -4,7 +4,8 @@ import { headers } from "next/headers"; import prisma from "@/lib/prisma"; import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; -import { decryptConfig } from "@/lib/crypto"; +import { getDecryptedCredentialData } from "@/services/auth/credential-service"; +import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; const log = logger.child({ route: "adapters/dropbox/auth" }); @@ -37,9 +38,15 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Adapter not found or not a Dropbox adapter" }, { status: 404 }); } - const config = decryptConfig(JSON.parse(adapterConfig.config)); + if (!adapterConfig.primaryCredentialId) { + return NextResponse.json({ error: "Assign an OAuth credential profile (with the app key + secret) before authorizing." }, { status: 400 }); + } + const profile = (await getDecryptedCredentialData( + adapterConfig.primaryCredentialId, + "OAUTH" + )) as OAuthData; - if (!config.clientId || !config.clientSecret) { + if (!profile.clientId || !profile.clientSecret) { return NextResponse.json({ error: "App Key and App Secret are required" }, { status: 400 }); } @@ -48,8 +55,8 @@ export async function POST(req: NextRequest) { const redirectUri = `${origin}/api/adapters/dropbox/callback`; const dbxAuth = new DropboxAuth({ - clientId: config.clientId, - clientSecret: config.clientSecret, + clientId: profile.clientId, + clientSecret: profile.clientSecret, fetch: fetch, }); diff --git a/src/app/api/adapters/dropbox/callback/route.ts b/src/app/api/adapters/dropbox/callback/route.ts index dd74bb5b..f81e8701 100644 --- a/src/app/api/adapters/dropbox/callback/route.ts +++ b/src/app/api/adapters/dropbox/callback/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -import { decryptConfig, encryptConfig } from "@/lib/crypto"; +import { getDecryptedCredentialData, updateCredentialProfile } from "@/services/auth/credential-service"; +import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; @@ -57,7 +58,16 @@ export async function GET(req: NextRequest) { ); } - const config = decryptConfig(JSON.parse(adapterConfig.config)); + if (!adapterConfig.primaryCredentialId) { + return NextResponse.redirect( + `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Assign an OAuth credential profile (with the app key + secret) before authorizing.")}` + ); + } + + const profile = (await getDecryptedCredentialData( + adapterConfig.primaryCredentialId, + "OAUTH" + )) as OAuthData; const redirectUri = `${origin}/api/adapters/dropbox/callback`; @@ -71,8 +81,8 @@ export async function GET(req: NextRequest) { body: new URLSearchParams({ code, grant_type: "authorization_code", - client_id: config.clientId, - client_secret: config.clientSecret, + client_id: profile.clientId, + client_secret: profile.clientSecret, redirect_uri: redirectUri, }), }); @@ -94,19 +104,9 @@ export async function GET(req: NextRequest) { ); } - // Update the adapter config with the refresh token - const updatedConfig = { - ...config, - refreshToken: tokenData.refresh_token, - }; - - const encryptedConfig = encryptConfig(updatedConfig); - - await prisma.adapterConfig.update({ - where: { id: state }, - data: { - config: JSON.stringify(encryptedConfig), - }, + // Store the refresh token in the credential profile (not the adapter config). + await updateCredentialProfile(adapterConfig.primaryCredentialId, { + data: { clientId: profile.clientId, clientSecret: profile.clientSecret, refreshToken: tokenData.refresh_token } satisfies OAuthData, }); log.info("Dropbox OAuth completed successfully", { adapterId: state }); diff --git a/src/app/api/adapters/google-drive/auth/route.ts b/src/app/api/adapters/google-drive/auth/route.ts index 9a825e89..1efffccb 100644 --- a/src/app/api/adapters/google-drive/auth/route.ts +++ b/src/app/api/adapters/google-drive/auth/route.ts @@ -4,7 +4,8 @@ import { headers } from "next/headers"; import prisma from "@/lib/prisma"; import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; -import { decryptConfig } from "@/lib/crypto"; +import { getDecryptedCredentialData } from "@/services/auth/credential-service"; +import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; const log = logger.child({ route: "adapters/google-drive/auth" }); @@ -42,9 +43,16 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Adapter not found or not a Google Drive adapter" }, { status: 404 }); } - const config = decryptConfig(JSON.parse(adapterConfig.config)); + // clientId + clientSecret both come from the OAUTH credential profile. + if (!adapterConfig.primaryCredentialId) { + return NextResponse.json({ error: "Assign an OAuth credential profile (with the client ID + secret) before authorizing." }, { status: 400 }); + } + const profile = (await getDecryptedCredentialData( + adapterConfig.primaryCredentialId, + "OAUTH" + )) as OAuthData; - if (!config.clientId || !config.clientSecret) { + if (!profile.clientId || !profile.clientSecret) { return NextResponse.json({ error: "Client ID and Client Secret are required" }, { status: 400 }); } @@ -53,8 +61,8 @@ export async function POST(req: NextRequest) { const redirectUri = `${origin}/api/adapters/google-drive/callback`; const oauth2Client = new google.auth.OAuth2( - config.clientId, - config.clientSecret, + profile.clientId, + profile.clientSecret, redirectUri ); diff --git a/src/app/api/adapters/google-drive/callback/route.ts b/src/app/api/adapters/google-drive/callback/route.ts index 72c08093..ca11da4d 100644 --- a/src/app/api/adapters/google-drive/callback/route.ts +++ b/src/app/api/adapters/google-drive/callback/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { google } from "googleapis"; import prisma from "@/lib/prisma"; -import { decryptConfig, encryptConfig } from "@/lib/crypto"; +import { getDecryptedCredentialData, updateCredentialProfile } from "@/services/auth/credential-service"; +import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; @@ -57,13 +58,24 @@ export async function GET(req: NextRequest) { ); } - const config = decryptConfig(JSON.parse(adapterConfig.config)); + // clientId + clientSecret + refreshToken all live in the OAUTH credential + // profile referenced by the adapter. + if (!adapterConfig.primaryCredentialId) { + return NextResponse.redirect( + `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Assign an OAuth credential profile (with the client ID + secret) before authorizing.")}` + ); + } + + const profile = (await getDecryptedCredentialData( + adapterConfig.primaryCredentialId, + "OAUTH" + )) as OAuthData; const redirectUri = `${origin}/api/adapters/google-drive/callback`; const oauth2Client = new google.auth.OAuth2( - config.clientId, - config.clientSecret, + profile.clientId, + profile.clientSecret, redirectUri ); @@ -77,19 +89,9 @@ export async function GET(req: NextRequest) { ); } - // Update the adapter config with the refresh token - const updatedConfig = { - ...config, - refreshToken: tokens.refresh_token, - }; - - const encryptedConfig = encryptConfig(updatedConfig); - - await prisma.adapterConfig.update({ - where: { id: state }, - data: { - config: JSON.stringify(encryptedConfig), - }, + // Store the refresh token in the credential profile (not the adapter config). + await updateCredentialProfile(adapterConfig.primaryCredentialId, { + data: { clientId: profile.clientId, clientSecret: profile.clientSecret, refreshToken: tokens.refresh_token } satisfies OAuthData, }); log.info("Google Drive OAuth completed successfully", { adapterId: state }); diff --git a/src/app/api/adapters/onedrive/auth/route.ts b/src/app/api/adapters/onedrive/auth/route.ts index c52ebdba..c2c38bf9 100644 --- a/src/app/api/adapters/onedrive/auth/route.ts +++ b/src/app/api/adapters/onedrive/auth/route.ts @@ -3,7 +3,8 @@ import { headers } from "next/headers"; import prisma from "@/lib/prisma"; import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; -import { decryptConfig } from "@/lib/crypto"; +import { getDecryptedCredentialData } from "@/services/auth/credential-service"; +import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; const log = logger.child({ route: "adapters/onedrive/auth" }); @@ -42,10 +43,16 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Adapter not found or not a OneDrive adapter" }, { status: 404 }); } - const config = decryptConfig(JSON.parse(adapterConfig.config)); - - if (!config.clientId || !config.clientSecret) { - return NextResponse.json({ error: "Client ID and Client Secret are required" }, { status: 400 }); + // clientId + clientSecret live in the assigned OAUTH profile. + if (!adapterConfig.primaryCredentialId) { + return NextResponse.json({ error: "Assign an OAuth credential profile (with the client ID + secret) before authorizing." }, { status: 400 }); + } + const profile = (await getDecryptedCredentialData( + adapterConfig.primaryCredentialId, + "OAUTH" + )) as OAuthData; + if (!profile.clientId) { + return NextResponse.json({ error: "Client ID is required" }, { status: 400 }); } // Build callback URL from the request origin @@ -53,7 +60,7 @@ export async function POST(req: NextRequest) { const redirectUri = `${origin}/api/adapters/onedrive/callback`; const authUrl = new URL("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"); - authUrl.searchParams.set("client_id", config.clientId); + authUrl.searchParams.set("client_id", profile.clientId); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("scope", SCOPES.join(" ")); diff --git a/src/app/api/adapters/onedrive/callback/route.ts b/src/app/api/adapters/onedrive/callback/route.ts index d478af07..b05a24f1 100644 --- a/src/app/api/adapters/onedrive/callback/route.ts +++ b/src/app/api/adapters/onedrive/callback/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -import { decryptConfig, encryptConfig } from "@/lib/crypto"; +import { getDecryptedCredentialData, updateCredentialProfile } from "@/services/auth/credential-service"; +import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; @@ -57,7 +58,16 @@ export async function GET(req: NextRequest) { ); } - const config = decryptConfig(JSON.parse(adapterConfig.config)); + if (!adapterConfig.primaryCredentialId) { + return NextResponse.redirect( + `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Assign an OAuth credential profile (with the client ID + secret) before authorizing.")}` + ); + } + + const profile = (await getDecryptedCredentialData( + adapterConfig.primaryCredentialId, + "OAUTH" + )) as OAuthData; const redirectUri = `${origin}/api/adapters/onedrive/callback`; @@ -70,8 +80,8 @@ export async function GET(req: NextRequest) { body: new URLSearchParams({ code, grant_type: "authorization_code", - client_id: config.clientId, - client_secret: config.clientSecret, + client_id: profile.clientId, + client_secret: profile.clientSecret, redirect_uri: redirectUri, scope: "Files.ReadWrite.All offline_access User.Read", }), @@ -94,19 +104,9 @@ export async function GET(req: NextRequest) { ); } - // Update the adapter config with the refresh token - const updatedConfig = { - ...config, - refreshToken: tokenData.refresh_token, - }; - - const encryptedConfig = encryptConfig(updatedConfig); - - await prisma.adapterConfig.update({ - where: { id: state }, - data: { - config: JSON.stringify(encryptedConfig), - }, + // Store the refresh token in the credential profile (not the adapter config). + await updateCredentialProfile(adapterConfig.primaryCredentialId, { + data: { clientId: profile.clientId, clientSecret: profile.clientSecret, refreshToken: tokenData.refresh_token } satisfies OAuthData, }); log.info("OneDrive OAuth completed successfully", { adapterId: state }); diff --git a/src/app/api/adapters/route.ts b/src/app/api/adapters/route.ts index cc12fcb7..28360903 100644 --- a/src/app/api/adapters/route.ts +++ b/src/app/api/adapters/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -import { encryptConfig, decryptConfig, stripSecrets } from "@/lib/crypto"; +import { encryptConfig } from "@/lib/crypto"; +import { toAdapterListItem } from "@/lib/adapters/dto"; import { headers } from "next/headers"; import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; @@ -45,32 +46,13 @@ export async function GET(req: NextRequest) { orderBy: { createdAt: 'desc' } }); - const decryptedAdapters = adapters.map(adapter => { - try { - // Parse the config JSON first - const configObj = JSON.parse(adapter.config); - // Decrypt sensitive fields - const decryptedConfig = decryptConfig(configObj); - // Return adapter with config object (or string depending on frontend expectation) - // The frontend seems to expect objects if we look at similar code or just stringified - // Actually the API previously returned the raw Prisma result where `config` is string. - // However, the frontend likely JSON.parse() it. - // Wait, Prisma returns `config` as string because schema says `String`. - - // If I modify the response here, I should make sure I am consistent. - // If I return the string, I should stringify it back. - - return { - ...adapter, - config: JSON.stringify(stripSecrets(decryptedConfig)) - }; - } catch (e: unknown) { - log.error("Failed to process config for adapter", { adapterId: adapter.id }, wrapError(e)); - return adapter; // Return as-is if error (fallback) - } - }); + // Map every row through the safe DTO. `toAdapterListItem` redacts all + // sensitive keys (deletes them, not blanks them) and reports `secretStatus`, + // so a decrypted secret can never reach the client regardless of caller + // permission level. See src/lib/adapters/dto.ts. + const items = adapters.map(toAdapterListItem); - return NextResponse.json(decryptedAdapters); + return NextResponse.json(items); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Failed to fetch adapters"; return NextResponse.json({ error: message }, { status: 500 }); diff --git a/src/app/api/system/filesystem/dropbox/route.ts b/src/app/api/system/filesystem/dropbox/route.ts index 81f50ba4..9a81a872 100644 --- a/src/app/api/system/filesystem/dropbox/route.ts +++ b/src/app/api/system/filesystem/dropbox/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { Dropbox } from "dropbox"; +import prisma from "@/lib/prisma"; import { checkPermission } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; +import { resolveAdapterConfig } from "@/lib/adapters/config-resolver"; import { logger } from "@/lib/logging/logger"; import { wrapError } from "@/lib/logging/errors"; @@ -12,19 +14,37 @@ const log = logger.child({ route: "system/filesystem/dropbox" }); * Browse Dropbox folders for the folder picker. * * Body: { - * config: { clientId, clientSecret, refreshToken }, + * adapterId: string, // saved Dropbox adapter config id * folderPath?: string // Folder to list ("" or undefined = root) * } * + * Credentials are resolved server-side from the adapter's OAUTH credential + * profile - they never travel through the client. + * * Returns the same shape as the local/remote filesystem API: * { success, data: { currentPath, currentName, parentPath, entries: [{ name, type, path }] } } */ export async function POST(req: NextRequest) { try { - await checkPermission(PERMISSIONS.SETTINGS.READ); + await checkPermission(PERMISSIONS.DESTINATIONS.READ); const body = await req.json(); - const { config, folderPath } = body; + const { adapterId, folderPath } = body; + + if (!adapterId) { + return NextResponse.json({ success: false, error: "Missing adapterId" }, { status: 400 }); + } + + const adapterRow = await prisma.adapterConfig.findUnique({ where: { id: adapterId } }); + if (!adapterRow || adapterRow.adapterId !== "dropbox") { + return NextResponse.json({ success: false, error: "Adapter not found" }, { status: 404 }); + } + + const config = (await resolveAdapterConfig(adapterRow)) as { + clientId?: string; + clientSecret?: string; + refreshToken?: string; + }; if (!config?.clientId || !config?.clientSecret || !config?.refreshToken) { return NextResponse.json( diff --git a/src/app/api/system/filesystem/google-drive/route.ts b/src/app/api/system/filesystem/google-drive/route.ts index 3ba1c1d7..a652b1b5 100644 --- a/src/app/api/system/filesystem/google-drive/route.ts +++ b/src/app/api/system/filesystem/google-drive/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { google } from "googleapis"; +import prisma from "@/lib/prisma"; import { checkPermission } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; +import { resolveAdapterConfig } from "@/lib/adapters/config-resolver"; import { logger } from "@/lib/logging/logger"; import { wrapError } from "@/lib/logging/errors"; @@ -12,10 +14,13 @@ const log = logger.child({ route: "system/filesystem/google-drive" }); * Browse Google Drive folders for the folder picker. * * Body: { - * config: { clientId, clientSecret, refreshToken }, + * adapterId: string, // saved Google Drive adapter config id * folderId?: string // Folder to list (undefined = root) * } * + * Credentials (clientSecret/refreshToken) are resolved server-side from the + * adapter's OAUTH credential profile - they never travel through the client. + * * Returns the same shape as the local/remote filesystem API: * { success, data: { currentPath, parentPath, entries: [{ name, type, path }] } } * @@ -23,10 +28,25 @@ const log = logger.child({ route: "system/filesystem/google-drive" }); */ export async function POST(req: NextRequest) { try { - await checkPermission(PERMISSIONS.SETTINGS.READ); + await checkPermission(PERMISSIONS.DESTINATIONS.READ); const body = await req.json(); - const { config, folderId } = body; + const { adapterId, folderId } = body; + + if (!adapterId) { + return NextResponse.json({ success: false, error: "Missing adapterId" }, { status: 400 }); + } + + const adapterRow = await prisma.adapterConfig.findUnique({ where: { id: adapterId } }); + if (!adapterRow || adapterRow.adapterId !== "google-drive") { + return NextResponse.json({ success: false, error: "Adapter not found" }, { status: 404 }); + } + + const config = (await resolveAdapterConfig(adapterRow)) as { + clientId?: string; + clientSecret?: string; + refreshToken?: string; + }; if (!config?.clientId || !config?.clientSecret || !config?.refreshToken) { return NextResponse.json( diff --git a/src/app/api/system/filesystem/onedrive/route.ts b/src/app/api/system/filesystem/onedrive/route.ts index 3f335bb6..d5b8b2a8 100644 --- a/src/app/api/system/filesystem/onedrive/route.ts +++ b/src/app/api/system/filesystem/onedrive/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { Client } from "@microsoft/microsoft-graph-client"; +import prisma from "@/lib/prisma"; import { checkPermission } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; +import { resolveAdapterConfig } from "@/lib/adapters/config-resolver"; import { logger } from "@/lib/logging/logger"; import { wrapError } from "@/lib/logging/errors"; @@ -15,19 +17,37 @@ const TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; * Browse OneDrive folders for the folder picker. * * Body: { - * config: { clientId, clientSecret, refreshToken }, + * adapterId: string, // saved OneDrive adapter config id * folderPath?: string // Folder to list ("" or undefined = root) * } * + * Credentials are resolved server-side from the adapter's OAUTH credential + * profile - they never travel through the client. + * * Returns the same shape as the local/remote filesystem API: * { success, data: { currentPath, currentName, parentPath, entries: [{ name, type, path }] } } */ export async function POST(req: NextRequest) { try { - await checkPermission(PERMISSIONS.SETTINGS.READ); + await checkPermission(PERMISSIONS.DESTINATIONS.READ); const body = await req.json(); - const { config, folderPath } = body; + const { adapterId, folderPath } = body; + + if (!adapterId) { + return NextResponse.json({ success: false, error: "Missing adapterId" }, { status: 400 }); + } + + const adapterRow = await prisma.adapterConfig.findUnique({ where: { id: adapterId } }); + if (!adapterRow || adapterRow.adapterId !== "onedrive") { + return NextResponse.json({ success: false, error: "Adapter not found" }, { status: 404 }); + } + + const config = (await resolveAdapterConfig(adapterRow)) as { + clientId?: string; + clientSecret?: string; + refreshToken?: string; + }; if (!config?.clientId || !config?.clientSecret || !config?.refreshToken) { return NextResponse.json( diff --git a/src/components/adapter/adapter-form.tsx b/src/components/adapter/adapter-form.tsx index 4259be6c..7c9b658d 100644 --- a/src/components/adapter/adapter-form.tsx +++ b/src/components/adapter/adapter-form.tsx @@ -23,6 +23,7 @@ import { AdapterConfig } from "./types"; import { useAdapterConnection } from "./use-adapter-connection"; import { DatabaseFormContent, GenericFormContent, NotificationFormContent, StorageFormContent } from "./form-sections"; import { SchemaField } from "./schema-field"; +import { SecretStatusProvider } from "./secret-status-context"; /** * Walks a Zod schema shape and calls form.setValue for every field that has a @@ -113,11 +114,17 @@ export function AdapterForm({ type, adapters, onSuccess, initialData, onBack }: credentialKeys.push("username", "authType", "password", "privateKey", "passphrase"); } if (selectedAdapter.credentials?.primary === "TOKEN") { - credentialKeys.push("token", "appToken", "accessToken", "botToken"); + credentialKeys.push("token", "appToken", "accessToken", "botToken", "authToken"); } if (selectedAdapter.credentials?.primary === "SMTP") { credentialKeys.push("user", "password"); } + if (selectedAdapter.credentials?.primary === "WEBHOOK") { + credentialKeys.push("webhookUrl", "url", "authHeader"); + } + if (selectedAdapter.credentials?.primary === "OAUTH") { + credentialKeys.push("clientId", "clientSecret", "refreshToken"); + } if (selectedAdapter.credentials?.ssh === "SSH_KEY") { credentialKeys.push( "sshUsername", "sshAuthType", "sshPassword", "sshPrivateKey", "sshPassphrase", @@ -265,6 +272,7 @@ export function AdapterForm({ type, adapters, onSuccess, initialData, onBack }: return ( <>
+ {/* Header: Name and Type */}
@@ -459,6 +467,7 @@ export function AdapterForm({ type, adapters, onSuccess, initialData, onBack }:
+
!open && setConnectionError(null)}> diff --git a/src/components/adapter/credential-picker.tsx b/src/components/adapter/credential-picker.tsx index 4401b4e9..fcc9ce45 100644 --- a/src/components/adapter/credential-picker.tsx +++ b/src/components/adapter/credential-picker.tsx @@ -43,6 +43,8 @@ const TYPE_BADGE: Record = { ACCESS_KEY: "Access Key", TOKEN: "Token", SMTP: "SMTP", + WEBHOOK: "Webhook", + OAUTH: "OAuth", }; export function CredentialPicker({ diff --git a/src/components/adapter/dropbox-folder-browser.tsx b/src/components/adapter/dropbox-folder-browser.tsx index 788d25fe..62fd9520 100644 --- a/src/components/adapter/dropbox-folder-browser.tsx +++ b/src/components/adapter/dropbox-folder-browser.tsx @@ -24,11 +24,8 @@ interface DropboxFolderBrowserProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (folderPath: string) => void; - config: { - clientId: string; - clientSecret: string; - refreshToken: string; - }; + /** Saved adapter config id; credentials are resolved server-side from its OAUTH profile. */ + adapterConfigId: string; initialPath?: string; } @@ -41,7 +38,7 @@ export function DropboxFolderBrowser({ open, onOpenChange, onSelect, - config, + adapterConfigId, initialPath, }: DropboxFolderBrowserProps) { const [currentPath, setCurrentPath] = useState(initialPath || ""); @@ -66,7 +63,7 @@ export function DropboxFolderBrowser({ const res = await fetch("/api/system/filesystem/dropbox", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ config, folderPath }), + body: JSON.stringify({ adapterId: adapterConfigId, folderPath }), }); const json = await res.json(); diff --git a/src/components/adapter/form-sections.tsx b/src/components/adapter/form-sections.tsx index 794c4a66..36de7179 100644 --- a/src/components/adapter/form-sections.tsx +++ b/src/components/adapter/form-sections.tsx @@ -372,7 +372,7 @@ export function DatabaseFormContent({ onPrimaryChange={onPrimaryChange} /> @@ -509,7 +509,7 @@ function SshAwareTabLayout({ onPrimaryChange={onPrimaryChange} /> @@ -552,7 +552,7 @@ function SshAwareTabLayout({ onPrimaryChange={onPrimaryChange} /> @@ -688,18 +688,9 @@ export function StorageFormContent({ ? STORAGE_CONFIG_KEYS.filter(k => k !== 'refreshToken') : STORAGE_CONFIG_KEYS; - // Check if the config has a refresh token (for existing/authorized adapters) - const hasRefreshToken = initialData ? (() => { - try { - const config = JSON.parse(initialData.config); - return !!config.refreshToken; - } catch { - return false; - } - })() : false; - - // Watch full config for Google Drive folder browser - const config = watch("config"); + // Check if a refresh token is stored (for existing/authorized adapters). + // The list DTO redacts the value, so we rely on the `secretStatus` flag. + const hasRefreshToken = initialData?.secretStatus?.refreshToken === true; return ( @@ -771,20 +762,20 @@ export function StorageFormContent({ {isGoogleDrive ? ( ) : isDropbox ? ( ) : isOneDrive ? ( ) : hasRealConfigKeys ? ( <> @@ -879,21 +870,21 @@ export function GenericFormContent({ adapter, detectedVersion }: { adapter: Adap */ function GoogleDriveFolderField({ adapter: _adapter, - config, hasRefreshToken, + adapterConfigId, }: { adapter: AdapterDefinition; - config: Record; hasRefreshToken: boolean; + adapterConfigId?: string; }) { const { setValue, watch } = useFormContext(); const [isBrowserOpen, setIsBrowserOpen] = useState(false); const folderId = watch("config.folderId") || ""; const [folderName, setFolderName] = useState(null); - // Get refresh token from current form values (might be encrypted in DB but decrypted in form) - const refreshToken = config?.refreshToken as string | undefined; - const canBrowse = hasRefreshToken && !!refreshToken && !!config?.clientId && !!config?.clientSecret; + // Secrets (clientSecret/refreshToken) live in the vault and are resolved + // server-side by adapterId; the browser only needs the saved adapter id. + const canBrowse = hasRefreshToken && !!adapterConfigId; return (
@@ -937,11 +928,7 @@ function GoogleDriveFolderField({ setValue("config.folderId", selectedId); setFolderName(selectedName); }} - config={{ - clientId: config.clientId as string, - clientSecret: config.clientSecret as string, - refreshToken: refreshToken!, - }} + adapterConfigId={adapterConfigId!} initialFolderId={folderId || undefined} /> )} @@ -955,19 +942,18 @@ function GoogleDriveFolderField({ */ function DropboxFolderField({ adapter: _adapter, - config, hasRefreshToken, + adapterConfigId, }: { adapter: AdapterDefinition; - config: Record; hasRefreshToken: boolean; + adapterConfigId?: string; }) { const { setValue, watch } = useFormContext(); const [isBrowserOpen, setIsBrowserOpen] = useState(false); const folderPath = watch("config.folderPath") || ""; - const refreshToken = config?.refreshToken as string | undefined; - const canBrowse = hasRefreshToken && !!refreshToken && !!config?.clientId && !!config?.clientSecret; + const canBrowse = hasRefreshToken && !!adapterConfigId; return (
@@ -1005,11 +991,7 @@ function DropboxFolderField({ onSelect={(selectedPath) => { setValue("config.folderPath", selectedPath); }} - config={{ - clientId: config.clientId as string, - clientSecret: config.clientSecret as string, - refreshToken: refreshToken!, - }} + adapterConfigId={adapterConfigId!} initialPath={folderPath || undefined} /> )} @@ -1023,19 +1005,18 @@ function DropboxFolderField({ */ function OneDriveFolderField({ adapter: _adapter, - config, hasRefreshToken, + adapterConfigId, }: { adapter: AdapterDefinition; - config: Record; hasRefreshToken: boolean; + adapterConfigId?: string; }) { const { setValue, watch } = useFormContext(); const [isBrowserOpen, setIsBrowserOpen] = useState(false); const folderPath = watch("config.folderPath") || ""; - const refreshToken = config?.refreshToken as string | undefined; - const canBrowse = hasRefreshToken && !!refreshToken && !!config?.clientId && !!config?.clientSecret; + const canBrowse = hasRefreshToken && !!adapterConfigId; return (
@@ -1073,11 +1054,7 @@ function OneDriveFolderField({ onSelect={(selectedPath) => { setValue("config.folderPath", selectedPath); }} - config={{ - clientId: config.clientId as string, - clientSecret: config.clientSecret as string, - refreshToken: refreshToken!, - }} + adapterConfigId={adapterConfigId!} initialPath={folderPath || undefined} /> )} @@ -1158,9 +1135,14 @@ function getCredentialManagedKeys(adapter: AdapterDefinition): Set { } else if (reqs.primary === "ACCESS_KEY") { ["accessKeyId", "secretAccessKey"].forEach((k) => hidden.add(k)); } else if (reqs.primary === "TOKEN") { - ["token", "appToken", "accessToken", "botToken"].forEach((k) => hidden.add(k)); + ["token", "appToken", "accessToken", "botToken", "authToken"].forEach((k) => hidden.add(k)); } else if (reqs.primary === "SMTP") { ["user", "password"].forEach((k) => hidden.add(k)); + } else if (reqs.primary === "WEBHOOK") { + ["webhookUrl", "url", "authHeader"].forEach((k) => hidden.add(k)); + } else if (reqs.primary === "OAUTH") { + // The whole OAuth-app identity lives in the vault profile. + ["clientId", "clientSecret", "refreshToken"].forEach((k) => hidden.add(k)); } if (reqs.ssh === "SSH_KEY") { diff --git a/src/components/adapter/google-drive-folder-browser.tsx b/src/components/adapter/google-drive-folder-browser.tsx index 696d14e8..4d9111b8 100644 --- a/src/components/adapter/google-drive-folder-browser.tsx +++ b/src/components/adapter/google-drive-folder-browser.tsx @@ -24,11 +24,8 @@ interface GoogleDriveFolderBrowserProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (folderId: string, folderName: string) => void; - config: { - clientId: string; - clientSecret: string; - refreshToken: string; - }; + /** Saved adapter config id; credentials are resolved server-side from its OAUTH profile. */ + adapterConfigId: string; initialFolderId?: string; } @@ -41,7 +38,7 @@ export function GoogleDriveFolderBrowser({ open, onOpenChange, onSelect, - config, + adapterConfigId, initialFolderId, }: GoogleDriveFolderBrowserProps) { const [currentFolderId, setCurrentFolderId] = useState(initialFolderId || "root"); @@ -71,7 +68,7 @@ export function GoogleDriveFolderBrowser({ const res = await fetch("/api/system/filesystem/google-drive", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ config, folderId }), + body: JSON.stringify({ adapterId: adapterConfigId, folderId }), }); const json = await res.json(); diff --git a/src/components/adapter/onedrive-folder-browser.tsx b/src/components/adapter/onedrive-folder-browser.tsx index 8ab74295..5a0e3f53 100644 --- a/src/components/adapter/onedrive-folder-browser.tsx +++ b/src/components/adapter/onedrive-folder-browser.tsx @@ -24,11 +24,8 @@ interface OneDriveFolderBrowserProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (folderPath: string) => void; - config: { - clientId: string; - clientSecret: string; - refreshToken: string; - }; + /** Saved adapter config id; credentials are resolved server-side from its OAUTH profile. */ + adapterConfigId: string; initialPath?: string; } @@ -41,7 +38,7 @@ export function OneDriveFolderBrowser({ open, onOpenChange, onSelect, - config, + adapterConfigId, initialPath, }: OneDriveFolderBrowserProps) { const [currentPath, setCurrentPath] = useState(initialPath || ""); @@ -66,7 +63,7 @@ export function OneDriveFolderBrowser({ const res = await fetch("/api/system/filesystem/onedrive", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ config, folderPath }), + body: JSON.stringify({ adapterId: adapterConfigId, folderPath }), }); const json = await res.json(); diff --git a/src/components/adapter/schema-field.tsx b/src/components/adapter/schema-field.tsx index 156379d0..c2421964 100644 --- a/src/components/adapter/schema-field.tsx +++ b/src/components/adapter/schema-field.tsx @@ -30,6 +30,7 @@ import { import { Button } from "@/components/ui/button"; import { FolderOpen } from "lucide-react"; import { PLACEHOLDERS } from "./form-constants"; +import { useSecretStatus } from "./secret-status-context"; import { DatabasePicker } from "./database-picker"; import { FileBrowserDialog } from "@/components/system/file-browser-dialog"; import { useState } from "react"; @@ -99,7 +100,15 @@ export function SchemaField({ const isTextArea = fieldKey.toLowerCase().includes("privatekey") || fieldKey.toLowerCase().includes("certificate") || fieldKey.toLowerCase().includes("options") || fieldKey === "customHeaders" || fieldKey === "payloadTemplate"; const description = (schemaShape as any).description; - const placeholder = PLACEHOLDERS[`${adapterId}.${fieldKey}`] || PLACEHOLDERS[fieldKey]; + const rawPlaceholder = PLACEHOLDERS[`${adapterId}.${fieldKey}`] || PLACEHOLDERS[fieldKey]; + + // When editing, the API redacts stored secrets (the value is never sent). + // `secretStatus[fieldKey]` tells us a value IS stored, so show a "leave blank + // to keep" hint instead of an empty-looking field. Submitting it blank keeps + // the existing secret (server-side mergeSecrets); typing replaces it. + const secretStatus = useSecretStatus(); + const hasStoredSecret = secretStatus[fieldKey] === true; + const placeholder = hasStoredSecret ? "•••••••• — saved, leave blank to keep" : rawPlaceholder; const isPathField = fieldKey === 'path' || fieldKey === 'sqliteBinaryPath' || fieldKey === 'basePath'; const [isFileBrowserOpen, setIsFileBrowserOpen] = useState(false); diff --git a/src/components/adapter/secret-status-context.tsx b/src/components/adapter/secret-status-context.tsx new file mode 100644 index 00000000..792ebd7e --- /dev/null +++ b/src/components/adapter/secret-status-context.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { createContext, useContext } from "react"; + +/** + * Provides the `secretStatus` map from the adapter list DTO (which sensitive + * fields already have a stored value) down to individual form fields, so a + * secret input can render a "saved — leave blank to keep" placeholder instead + * of looking empty. The API never sends the secret value itself. + */ +const SecretStatusContext = createContext>({}); + +export const SecretStatusProvider = SecretStatusContext.Provider; + +export function useSecretStatus(): Record { + return useContext(SecretStatusContext); +} diff --git a/src/components/adapter/types.ts b/src/components/adapter/types.ts index b4e2cd17..9eb0d746 100644 --- a/src/components/adapter/types.ts +++ b/src/components/adapter/types.ts @@ -4,7 +4,9 @@ export interface AdapterConfig { name: string; adapterId: string; type: string; - config: string; // JSON string + config: string; // JSON string (sensitive keys redacted by the API DTO) + /** Map of sensitive key -> whether a non-empty value is stored (from the list DTO). */ + secretStatus?: Record; metadata?: string; // JSON string createdAt: string; primaryCredentialId?: string | null; diff --git a/src/components/settings/credential-profile-dialog.tsx b/src/components/settings/credential-profile-dialog.tsx index 95c5e472..76d3243a 100644 --- a/src/components/settings/credential-profile-dialog.tsx +++ b/src/components/settings/credential-profile-dialog.tsx @@ -31,14 +31,18 @@ const TYPE_LABELS: Record = { ACCESS_KEY: "Access Key (S3 / API)", TOKEN: "Token", SMTP: "SMTP", + WEBHOOK: "Webhook URL", + OAUTH: "OAuth (Client Secret)", }; const TYPE_DESCRIPTIONS: Record = { USERNAME_PASSWORD: "Database / FTP / SMB user + password.", SSH_KEY: "SSH credentials (password, private key, or agent).", ACCESS_KEY: "S3-style access key + secret key pair.", - TOKEN: "Bearer token (Gotify, ntfy, Telegram bot, generic webhook).", + TOKEN: "Bearer token (Gotify, ntfy, Telegram bot, Twilio).", SMTP: "SMTP user + password for email notifications.", + WEBHOOK: "Webhook URL (Discord, Slack, Teams, generic webhook) + optional auth header.", + OAUTH: "OAuth app (Google Drive, Dropbox, OneDrive): client ID + secret. The refresh token is added automatically after authorization.", }; export interface CredentialProfileSummary { @@ -68,6 +72,8 @@ const DEFAULTS: Record = { ACCESS_KEY: { accessKeyId: "", secretAccessKey: "" }, TOKEN: { token: "" }, SMTP: { user: "", password: "" }, + WEBHOOK: { url: "", authHeader: "" }, + OAUTH: { clientId: "", clientSecret: "" }, }; export function CredentialProfileDialog({ @@ -362,6 +368,24 @@ function TypeFields({ ); } + if (type === "WEBHOOK") { + return ( +
+ update("url", v)} /> + update("authHeader", v)} /> +
+ ); + } + + if (type === "OAUTH") { + return ( +
+ update("clientId", v)} /> + update("clientSecret", v)} /> +
+ ); + } + return null; } diff --git a/src/components/settings/credential-profiles-list.tsx b/src/components/settings/credential-profiles-list.tsx index 79ab83a0..88247dc5 100644 --- a/src/components/settings/credential-profiles-list.tsx +++ b/src/components/settings/credential-profiles-list.tsx @@ -30,6 +30,8 @@ const TYPE_LABELS: Record = { ACCESS_KEY: "Access Key", TOKEN: "Token", SMTP: "SMTP", + WEBHOOK: "Webhook", + OAUTH: "OAuth", }; const TYPE_FILTER_OPTIONS = (Object.entries(TYPE_LABELS) as [CredentialType, string][]).map( diff --git a/src/lib/adapters/config-resolver.ts b/src/lib/adapters/config-resolver.ts index 95063844..81fed130 100644 --- a/src/lib/adapters/config-resolver.ts +++ b/src/lib/adapters/config-resolver.ts @@ -11,6 +11,8 @@ import type { AccessKeyData, TokenData, SmtpData, + WebhookData, + OAuthData, } from "@/lib/core/credentials"; const log = logger.child({ module: "ConfigResolver" }); @@ -215,12 +217,13 @@ function applyPrimaryOverlay( const p = profile as TokenData; // Write to all known token field names. Each notification adapter // schema uses a different key (Gotify: appToken, ntfy: accessToken, - // Telegram: botToken) and zod strips unknowns it doesn't declare, - // so spraying is safe and avoids per-adapter switch logic. + // Telegram: botToken, Twilio: authToken) and zod strips unknowns it + // doesn't declare, so spraying is safe and avoids per-adapter switch logic. config.token = p.token; config.appToken = p.token; config.accessToken = p.token; config.botToken = p.token; + config.authToken = p.token; return; } case "SMTP": { @@ -229,6 +232,22 @@ function applyPrimaryOverlay( config.password = p.password; return; } + case "WEBHOOK": { + const p = profile as WebhookData; + // Discord/Slack/Teams use `webhookUrl`; the generic webhook also uses + // an optional `authHeader`. Spray both known field names. + config.webhookUrl = p.url; + config.url = p.url; + if (p.authHeader !== undefined) config.authHeader = p.authHeader; + return; + } + case "OAUTH": { + const p = profile as OAuthData; + config.clientId = p.clientId; + config.clientSecret = p.clientSecret; + if (p.refreshToken !== undefined) config.refreshToken = p.refreshToken; + return; + } } } diff --git a/src/lib/adapters/database/mongodb/connection.ts b/src/lib/adapters/database/mongodb/connection.ts index a1085e1f..903ee0db 100644 --- a/src/lib/adapters/database/mongodb/connection.ts +++ b/src/lib/adapters/database/mongodb/connection.ts @@ -15,6 +15,9 @@ const log = logger.child({ service: "mongodb-connection" }); * Build MongoDB connection URI from config */ function buildConnectionUri(config: MongoDBConfig): string { + // Backward-compat: honor a stored inline `uri` for sources created before the + // URI field was deprecated. The UI no longer exposes it; new sources arrive + // here with host/port + credentials resolved from the vault profile. if (config.uri) { return config.uri; } diff --git a/src/lib/adapters/definitions/database.ts b/src/lib/adapters/definitions/database.ts index 36fa2e59..8022d815 100644 --- a/src/lib/adapters/definitions/database.ts +++ b/src/lib/adapters/definitions/database.ts @@ -34,7 +34,11 @@ export const PostgresSchema = z.object({ }); export const MongoDBSchema = z.object({ - uri: z.string().optional().describe("Connection URI (overrides other settings)"), + // DEPRECATED: inline connection URI. No longer offered in the UI because it + // embeds credentials directly in the adapter config. New sources build the + // URI from host/port + a USERNAME_PASSWORD credential profile. Still honored + // at runtime for existing sources (see buildConnectionUri) until reconfigured. + uri: z.string().optional().describe("DEPRECATED — use host/port + a credential profile instead"), host: z.string().default("localhost"), port: z.coerce.number().default(27017), user: z.string().optional(), diff --git a/src/lib/adapters/dto.ts b/src/lib/adapters/dto.ts new file mode 100644 index 00000000..bc9bd3d1 --- /dev/null +++ b/src/lib/adapters/dto.ts @@ -0,0 +1,105 @@ +import { decryptConfig, redactSecrets, getSecretStatus } from "@/lib/crypto"; + +/** + * Adapter-list Data Transfer Object. + * + * This is the ONLY shape the adapter-listing API serialises back to clients. + * Its `config` field is rebuilt via `redactSecrets`, which DELETES every key in + * `SENSITIVE_KEYS` — so the DTO structurally cannot carry a decrypted secret, + * regardless of which adapter type or future field is added. `secretStatus` + * tells the UI which secrets are set (so it can render "leave blank to keep") + * without exposing the value. + * + * Do not add a field here that could hold a secret value. Secrets belong in the + * Vault (`CredentialProfile`) and are surfaced only via the audited reveal flow. + */ +export interface AdapterListItemDTO { + id: string; + name: string; + type: string; + adapterId: string; + /** JSON string of the structural config with all sensitive keys removed. */ + config: string; + /** Map of sensitive key -> whether a non-empty value is stored. */ + secretStatus: Record; + metadata: string | null; + createdAt: Date; + updatedAt: Date; + primaryCredentialId: string | null; + sshCredentialId: string | null; + defaultRetentionPolicyId: string | null; + lastHealthCheck: Date | null; + lastStatus: string; + lastError: string | null; + consecutiveFailures: number; +} + +/** + * The minimum set of `AdapterConfig` row fields the DTO mapper needs. Kept loose + * so Prisma rows can be passed directly without importing generated types. + */ +export interface AdapterRowInput { + id: string; + name: string; + type: string; + adapterId: string; + config: string; + metadata: string | null; + createdAt: Date; + updatedAt: Date; + primaryCredentialId: string | null; + sshCredentialId: string | null; + defaultRetentionPolicyId: string | null; + lastHealthCheck: Date | null; + lastStatus: string; + lastError: string | null; + consecutiveFailures: number; +} + +/** + * Maps a stored `AdapterConfig` row to the safe list DTO. Decrypts the config + * internally only to compute `secretStatus` and to redact it — no decrypted + * secret value ever leaves this function. + */ +export function toAdapterListItem(row: AdapterRowInput): AdapterListItemDTO { + let config = "{}"; + let secretStatus: Record = {}; + + try { + const parsed = JSON.parse(row.config); + try { + const decrypted = decryptConfig(parsed); + secretStatus = getSecretStatus(decrypted); + config = JSON.stringify(redactSecrets(decrypted)); + } catch { + // Decryption failed (corrupt data / rotated key). Still redact secret + // keys from the raw parsed config so structural fields survive without + // leaking the (encrypted) secret values. + secretStatus = getSecretStatus(parsed); + config = JSON.stringify(redactSecrets(parsed)); + } + } catch { + // Unparseable config — return an empty structural config rather than the raw string. + config = "{}"; + secretStatus = {}; + } + + return { + id: row.id, + name: row.name, + type: row.type, + adapterId: row.adapterId, + config, + secretStatus, + metadata: row.metadata, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + primaryCredentialId: row.primaryCredentialId, + sshCredentialId: row.sshCredentialId, + defaultRetentionPolicyId: row.defaultRetentionPolicyId, + lastHealthCheck: row.lastHealthCheck, + lastStatus: row.lastStatus, + lastError: row.lastError, + consecutiveFailures: row.consecutiveFailures, + }; +} diff --git a/src/lib/adapters/notification/discord.ts b/src/lib/adapters/notification/discord.ts index 17ddc362..76f38d00 100644 --- a/src/lib/adapters/notification/discord.ts +++ b/src/lib/adapters/notification/discord.ts @@ -11,6 +11,7 @@ export const DiscordAdapter: NotificationAdapter = { type: "notification", name: "Discord Webhook", configSchema: DiscordSchema, + credentials: { primary: "WEBHOOK" }, async test(config: DiscordConfig): Promise<{ success: boolean; message: string }> { try { diff --git a/src/lib/adapters/notification/generic-webhook.ts b/src/lib/adapters/notification/generic-webhook.ts index e45f7324..3f6325d8 100644 --- a/src/lib/adapters/notification/generic-webhook.ts +++ b/src/lib/adapters/notification/generic-webhook.ts @@ -31,6 +31,7 @@ export const GenericWebhookAdapter: NotificationAdapter = { type: "notification", name: "Generic Webhook", configSchema: GenericWebhookSchema, + credentials: { primary: "WEBHOOK" }, async test(config: GenericWebhookConfig): Promise<{ success: boolean; message: string }> { try { diff --git a/src/lib/adapters/notification/slack.ts b/src/lib/adapters/notification/slack.ts index 27e72e10..13d238cb 100644 --- a/src/lib/adapters/notification/slack.ts +++ b/src/lib/adapters/notification/slack.ts @@ -11,6 +11,7 @@ export const SlackAdapter: NotificationAdapter = { type: "notification", name: "Slack Webhook", configSchema: SlackSchema, + credentials: { primary: "WEBHOOK" }, async test(config: SlackConfig): Promise<{ success: boolean; message: string }> { try { diff --git a/src/lib/adapters/notification/teams.ts b/src/lib/adapters/notification/teams.ts index 10f9d1c7..70a3ec73 100644 --- a/src/lib/adapters/notification/teams.ts +++ b/src/lib/adapters/notification/teams.ts @@ -17,6 +17,7 @@ export const TeamsAdapter: NotificationAdapter = { type: "notification", name: "Microsoft Teams", configSchema: TeamsSchema, + credentials: { primary: "WEBHOOK" }, async test(config: TeamsConfig): Promise<{ success: boolean; message: string }> { try { diff --git a/src/lib/adapters/notification/twilio-sms.ts b/src/lib/adapters/notification/twilio-sms.ts index 1630ad69..c026dbdc 100644 --- a/src/lib/adapters/notification/twilio-sms.ts +++ b/src/lib/adapters/notification/twilio-sms.ts @@ -43,6 +43,9 @@ export const TwilioSmsAdapter: NotificationAdapter = { type: "notification", name: "SMS (Twilio)", configSchema: TwilioSmsSchema, + // The auth token is the secret; it comes from a TOKEN profile (resolver sprays + // it to `authToken`). `accountSid` stays structural (now in SENSITIVE_KEYS). + credentials: { primary: "TOKEN" }, async test(config: TwilioSmsConfig): Promise<{ success: boolean; message: string }> { try { diff --git a/src/lib/adapters/storage/dropbox.ts b/src/lib/adapters/storage/dropbox.ts index 59fe4720..3ee842de 100644 --- a/src/lib/adapters/storage/dropbox.ts +++ b/src/lib/adapters/storage/dropbox.ts @@ -144,6 +144,9 @@ export const DropboxAdapter: StorageAdapter = { type: "storage", name: "Dropbox", configSchema: DropboxSchema, + // clientSecret + refreshToken live in an OAUTH credential profile; clientId + // stays structural. The refreshToken is written by the OAuth callback. + credentials: { primary: "OAUTH" }, async upload( config: DropboxConfig, diff --git a/src/lib/adapters/storage/google-drive.ts b/src/lib/adapters/storage/google-drive.ts index b0baac5d..3513d4ae 100644 --- a/src/lib/adapters/storage/google-drive.ts +++ b/src/lib/adapters/storage/google-drive.ts @@ -154,6 +154,9 @@ export const GoogleDriveAdapter: StorageAdapter = { type: "storage", name: "Google Drive", configSchema: GoogleDriveSchema, + // clientSecret + refreshToken live in an OAUTH credential profile; clientId + // stays structural. The refreshToken is written by the OAuth callback. + credentials: { primary: "OAUTH" }, async upload( config: GoogleDriveConfig, diff --git a/src/lib/adapters/storage/onedrive.ts b/src/lib/adapters/storage/onedrive.ts index 3dbffcca..1e681dc5 100644 --- a/src/lib/adapters/storage/onedrive.ts +++ b/src/lib/adapters/storage/onedrive.ts @@ -268,6 +268,9 @@ export const OneDriveAdapter: StorageAdapter = { type: "storage", name: "Microsoft OneDrive", configSchema: OneDriveSchema, + // clientSecret + refreshToken live in an OAUTH credential profile; clientId + // stays structural. The refreshToken is written by the OAuth callback. + credentials: { primary: "OAUTH" }, async openSession(config: OneDriveConfig, onLog?): Promise { const accessToken = await getAccessToken(config); diff --git a/src/lib/core/credential-requirements.ts b/src/lib/core/credential-requirements.ts index 6540648f..2d43b962 100644 --- a/src/lib/core/credential-requirements.ts +++ b/src/lib/core/credential-requirements.ts @@ -8,8 +8,8 @@ import type { CredentialType } from "@/lib/core/credentials"; * - The adapter form to decide whether to render the primary/SSH picker * * NOTE: When adding a new adapter, mirror its `credentials` declaration here. - * Adapters without a credential profile (local-filesystem, OAuth storage, - * non-token webhook notifications) are intentionally absent. + * Adapters without a credential profile (e.g. local-filesystem) are + * intentionally absent. */ export const ADAPTER_CREDENTIAL_REQUIREMENTS: Record< string, @@ -39,11 +39,23 @@ export const ADAPTER_CREDENTIAL_REQUIREMENTS: Record< "s3-r2": { primary: "ACCESS_KEY" }, "s3-hetzner": { primary: "ACCESS_KEY" }, + // OAuth cloud storage (clientSecret + refreshToken in an OAUTH profile) + "google-drive": { primary: "OAUTH" }, + dropbox: { primary: "OAUTH" }, + onedrive: { primary: "OAUTH" }, + // Notifications email: { primary: "SMTP" }, gotify: { primary: "TOKEN" }, ntfy: { primary: "TOKEN" }, telegram: { primary: "TOKEN" }, + "twilio-sms": { primary: "TOKEN" }, // token = Twilio Auth Token; accountSid stays structural + + // Webhook notifications (URL + optional auth header in the vault) + discord: { primary: "WEBHOOK" }, + slack: { primary: "WEBHOOK" }, + teams: { primary: "WEBHOOK" }, + "generic-webhook": { primary: "WEBHOOK" }, }; export function getCredentialRequirements(adapterId: string) { diff --git a/src/lib/core/credentials.ts b/src/lib/core/credentials.ts index 3c03fb17..a517ea8e 100644 --- a/src/lib/core/credentials.ts +++ b/src/lib/core/credentials.ts @@ -13,6 +13,8 @@ export const CREDENTIAL_TYPES = [ "ACCESS_KEY", "TOKEN", "SMTP", + "WEBHOOK", + "OAUTH", ] as const; export type CredentialType = (typeof CREDENTIAL_TYPES)[number]; @@ -65,6 +67,22 @@ export const SmtpSchema = z.object({ password: z.string().min(1, "Password is required"), }); +export const WebhookSchema = z.object({ + url: z.string().url("Valid Webhook URL is required"), + authHeader: z.string().optional(), +}); + +export const OAuthSchema = z.object({ + // clientId + clientSecret form one OAuth-app registration; keeping them together + // (with the refreshToken) avoids the mismatch where a refreshToken issued for one + // clientId is used with another. clientId is not itself a secret. + clientId: z.string().min(1, "Client ID is required"), + clientSecret: z.string().min(1, "Client Secret is required"), + // Set automatically by the OAuth callback after the consent flow; optional so + // the profile can be created up-front with just the client id + secret. + refreshToken: z.string().optional(), +}); + // --------------------------------------------------------------------------- // Type-to-schema map (single source of truth for validation) // --------------------------------------------------------------------------- @@ -75,6 +93,8 @@ export const CREDENTIAL_SCHEMAS = { ACCESS_KEY: AccessKeySchema, TOKEN: TokenSchema, SMTP: SmtpSchema, + WEBHOOK: WebhookSchema, + OAUTH: OAuthSchema, } as const satisfies Record; export type UsernamePasswordData = z.infer; @@ -82,13 +102,17 @@ export type SshKeyData = z.infer; export type AccessKeyData = z.infer; export type TokenData = z.infer; export type SmtpData = z.infer; +export type WebhookData = z.infer; +export type OAuthData = z.infer; export type CredentialData = | UsernamePasswordData | SshKeyData | AccessKeyData | TokenData - | SmtpData; + | SmtpData + | WebhookData + | OAuthData; /** * A credential profile as exposed to clients (no `data` payload). diff --git a/src/lib/crypto/index.ts b/src/lib/crypto/index.ts index a29dc8eb..a72d4dec 100644 --- a/src/lib/crypto/index.ts +++ b/src/lib/crypto/index.ts @@ -88,7 +88,7 @@ export function decrypt(text: string): string { } } -const SENSITIVE_KEYS = [ +export const SENSITIVE_KEYS = [ 'password', 'token', 'secret', @@ -103,6 +103,12 @@ const SENSITIVE_KEYS = [ 'privateKey', // SSH Private Key 'clientSecret', // OAuth Client Secret (Google Drive, etc.) 'refreshToken', // OAuth Refresh Token + 'authHeader', // Generic Webhook Authorization header + 'accountSid', // Twilio Account SID + 'authToken', // Twilio Auth Token + 'appToken', // Gotify application token + 'botToken', // Telegram bot token + 'accessToken', // ntfy access token ]; @@ -177,3 +183,99 @@ export function decryptConfig(config: any): any { return result; } + +/** + * Merges sensitive fields of an incoming (plaintext) config with an existing + * (plaintext) config, preserving the existing secret whenever the incoming + * value for a sensitive key is empty or absent. + * + * This is the server-side half of the "hasSecret" pattern: the API only ever + * returns redacted secrets (see `stripSecrets` / the adapter DTO), so an edit + * round-trip submits empty secret fields. Without this merge, re-encrypting the + * submitted config would clobber the real secret with an encrypted empty string. + * + * Non-sensitive keys are taken verbatim from `incoming`. Nested objects are + * merged recursively for sensitive keys; non-object structural values pass + * through from `incoming`. + */ +export function mergeSecrets(incoming: any, existing: any): any { + if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { + return incoming; + } + const existingObj = + existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}; + + const result: Record = { ...incoming }; + + const isEmpty = (v: unknown) => v === undefined || v === null || v === ''; + + // 1. Sensitive keys present in `incoming`: if empty, restore from existing. + // Nested objects merge recursively. + for (const key of Object.keys(result)) { + const value = result[key]; + const existingValue = existingObj[key]; + + if (value && typeof value === 'object') { + result[key] = mergeSecrets(value, existingValue); + } else if (SENSITIVE_KEYS.includes(key) && isEmpty(value) && existingValue !== undefined) { + result[key] = existingValue; + } + } + + // 2. Sensitive keys present in `existing` but absent from `incoming`: restore + // them. The API redacts (removes) secret keys, so an edit round-trip omits + // untouched secrets entirely — without this they would be lost on save. + for (const key of Object.keys(existingObj)) { + if (!(key in result) && SENSITIVE_KEYS.includes(key) && !isEmpty(existingObj[key])) { + result[key] = existingObj[key]; + } + } + + return result; +} + +/** + * Recursively removes sensitive fields from a (decrypted) config, returning a + * config that structurally cannot carry a secret. Unlike `stripSecrets` (which + * blanks values to `""`), this deletes the keys entirely so a response DTO never + * even hints at a value. Use together with `getSecretStatus` to tell the client + * which secrets are set without exposing them. + */ +export function redactSecrets(config: any): any { + if (!config || typeof config !== 'object') { + return config; + } + + if (Array.isArray(config)) { + return config.map(redactSecrets); + } + + const result: Record = {}; + for (const key of Object.keys(config)) { + const value = config[key]; + if (SENSITIVE_KEYS.includes(key) && typeof value !== 'object') { + continue; // drop scalar secret entirely + } + result[key] = value && typeof value === 'object' ? redactSecrets(value) : value; + } + return result; +} + +/** + * Reports which sensitive top-level keys of a (decrypted) config hold a + * non-empty value, e.g. `{ clientSecret: true, refreshToken: false }`. Lets the + * UI render "secret is set, leave blank to keep" without seeing the value. + */ +export function getSecretStatus(config: any): Record { + const status: Record = {}; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return status; + } + for (const key of Object.keys(config)) { + if (SENSITIVE_KEYS.includes(key)) { + const value = config[key]; + status[key] = typeof value === 'string' ? value.length > 0 : value != null; + } + } + return status; +} diff --git a/tests/audit/adapter-secrets.test.ts b/tests/audit/adapter-secrets.test.ts new file mode 100644 index 00000000..40bb4110 --- /dev/null +++ b/tests/audit/adapter-secrets.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +/** + * Security audit: no API route may serialise a *decrypted* adapter config back + * to the client. This guards against re-introducing the secret-disclosure class + * fixed in the adapter-listing endpoint (GHSA-cj5h-46h6-72wc follow-up). + * + * Rule: if a route assigns an identifier from `decryptConfig(...)` or + * `resolveAdapterConfig(...)`, that identifier must never be passed (directly or + * via spread) to `NextResponse.json(...)`. Routes may still use the decrypted + * config server-side to derive non-secret data (db list, OAuth flow, redirects). + */ +describe('Security Audit: adapter secret disclosure', () => { + const API_DIR = path.join(process.cwd(), 'src/app/api'); + + function collectRouteFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...collectRouteFiles(full)); + else if (entry.isFile() && entry.name === 'route.ts') out.push(full); + } + return out; + } + + const routeFiles = collectRouteFiles(API_DIR); + + it('finds API route files to scan', () => { + expect(routeFiles.length).toBeGreaterThan(0); + }); + + routeFiles.forEach((file) => { + const rel = path.relative(process.cwd(), file); + const content = fs.readFileSync(file, 'utf-8'); + + const usesDecrypt = /decryptConfig\s*\(|resolveAdapterConfig\s*\(/.test(content); + if (!usesDecrypt) return; + + it(`${rel} does not serialise a decrypted config to the client`, () => { + // Identifiers assigned from a decrypt/resolve call. + const decryptedIdents = new Set(); + const assignRe = + /(?:const|let|var)\s+([A-Za-z0-9_]+)\s*=\s*(?:await\s+)?(?:[A-Za-z0-9_.]*\s*=\s*)?(?:.*?)(?:decryptConfig|resolveAdapterConfig)\s*\(/g; + let m: RegExpExecArray | null; + while ((m = assignRe.exec(content)) !== null) { + decryptedIdents.add(m[1]); + } + + for (const ident of decryptedIdents) { + const escaped = ident.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // NextResponse.json(ident) or NextResponse.json({ ...ident }) or NextResponse.json({ config: ident }) + const leakRe = new RegExp( + `NextResponse\\.json\\(\\s*(?:\\{[^}]*(?:\\.\\.\\.|:\\s*)${escaped}\\b|${escaped}\\b)` + ); + expect( + leakRe.test(content), + `${rel} appears to pass decrypted config "${ident}" into NextResponse.json. ` + + `Return derived non-secret data or use toAdapterListItem/redactSecrets instead.` + ).toBe(false); + } + }); + }); + + it('the adapter-listing route uses the safe DTO (toAdapterListItem)', () => { + const listRoute = path.join(API_DIR, 'adapters/route.ts'); + const content = fs.readFileSync(listRoute, 'utf-8'); + expect(content).toMatch(/toAdapterListItem/); + // Must not hand-roll a raw decrypted response. + expect(content).not.toMatch(/JSON\.stringify\(\s*decryptConfig/); + }); +}); diff --git a/tests/unit/adapters/config-resolver.test.ts b/tests/unit/adapters/config-resolver.test.ts index 73c6f4ea..24922349 100644 --- a/tests/unit/adapters/config-resolver.test.ts +++ b/tests/unit/adapters/config-resolver.test.ts @@ -202,6 +202,61 @@ describe("resolveAdapterConfig", () => { expect(result.password).toBe("smtp-pw"); }); + it("overlays WEBHOOK onto `webhookUrl`/`url`/`authHeader` for webhook adapters", async () => { + (getDecryptedCredentialData as any).mockResolvedValue({ + url: "https://hooks.example.com/abc", + authHeader: "Bearer xyz", + }); + + const result = (await resolveAdapterConfig( + buildRow({ + adapterId: "discord", + primaryCredentialId: "cred-1", + config: { username: "Backup Bot" }, + }) + )) as Record; + + expect(result.webhookUrl).toBe("https://hooks.example.com/abc"); + expect(result.url).toBe("https://hooks.example.com/abc"); + expect(result.authHeader).toBe("Bearer xyz"); + }); + + it("overlays TOKEN onto `authToken` for Twilio", async () => { + (getDecryptedCredentialData as any).mockResolvedValue({ token: "tw-secret" }); + + const result = (await resolveAdapterConfig( + buildRow({ + adapterId: "twilio-sms", + primaryCredentialId: "cred-1", + config: { accountSid: "AC123", from: "+1", to: "+2" }, + }) + )) as Record; + + expect(result.authToken).toBe("tw-secret"); + expect(result.accountSid).toBe("AC123"); // structural field preserved + }); + + it("overlays OAUTH clientId/clientSecret/refreshToken for cloud storage", async () => { + (getDecryptedCredentialData as any).mockResolvedValue({ + clientId: "oauth-client-id", + clientSecret: "oauth-client-secret", + refreshToken: "oauth-refresh", + }); + + const result = (await resolveAdapterConfig( + buildRow({ + adapterId: "google-drive", + primaryCredentialId: "cred-1", + config: { folderId: "root" }, + }) + )) as Record; + + expect(result.clientId).toBe("oauth-client-id"); + expect(result.clientSecret).toBe("oauth-client-secret"); + expect(result.refreshToken).toBe("oauth-refresh"); + expect(result.folderId).toBe("root"); + }); + it("throws ConfigurationError when config JSON is malformed", async () => { await expect( resolveAdapterConfig({ @@ -257,8 +312,10 @@ describe("Adapter credential declarations", () => { expect(registry.get("email")?.credentials).toEqual({ primary: "SMTP", primaryOptional: true }); expect(registry.get("sqlite")?.credentials).toEqual({ ssh: "SSH_KEY" }); expect(registry.get("local-filesystem")?.credentials).toBeUndefined(); - expect(registry.get("discord")?.credentials).toBeUndefined(); - expect(registry.get("google-drive")?.credentials).toBeUndefined(); + expect(registry.get("discord")?.credentials).toEqual({ primary: "WEBHOOK" }); + expect(registry.get("generic-webhook")?.credentials).toEqual({ primary: "WEBHOOK" }); + expect(registry.get("twilio-sms")?.credentials).toEqual({ primary: "TOKEN" }); + expect(registry.get("google-drive")?.credentials).toEqual({ primary: "OAUTH" }); }); }); diff --git a/tests/unit/lib/adapter-dto.test.ts b/tests/unit/lib/adapter-dto.test.ts new file mode 100644 index 00000000..f7c4c74c --- /dev/null +++ b/tests/unit/lib/adapter-dto.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const VALID_KEY = "a".repeat(64); + +/** + * Mirrors the reported PoC (read-only user retrieving decrypted adapter secrets): + * a stored adapter whose secrets are encrypted must, once mapped through the + * list DTO, expose NO decrypted secret value or secret key to the client. + */ +describe("toAdapterListItem (adapter list DTO)", () => { + beforeEach(() => vi.stubEnv("ENCRYPTION_KEY", VALID_KEY)); + afterEach(() => vi.unstubAllEnvs()); + + async function getModules() { + vi.resetModules(); + const crypto = await import("@/lib/crypto"); + const dto = await import("@/lib/adapters/dto"); + return { ...crypto, ...dto }; + } + + function baseRow(config: string) { + return { + id: "a1", + name: "Prod", + type: "storage", + adapterId: "google-drive", + config, + metadata: null, + createdAt: new Date(0), + updatedAt: new Date(0), + primaryCredentialId: null, + sshCredentialId: null, + defaultRetentionPolicyId: null, + lastHealthCheck: null, + lastStatus: "ONLINE", + lastError: null, + consecutiveFailures: 0, + }; + } + + it("never leaks decrypted secret values for an OAuth storage adapter", async () => { + const { encryptConfig, toAdapterListItem } = await getModules(); + const stored = JSON.stringify( + encryptConfig({ + clientId: "poc-client-id", + clientSecret: "POC_CLIENT_SECRET_SHOULD_NOT_LEAK", + refreshToken: "POC_REFRESH_TOKEN_SHOULD_NOT_LEAK", + folderId: "root", + }) + ); + + const dto = toAdapterListItem(baseRow(stored)); + const serialized = JSON.stringify(dto); + + expect(serialized).not.toContain("POC_CLIENT_SECRET_SHOULD_NOT_LEAK"); + expect(serialized).not.toContain("POC_REFRESH_TOKEN_SHOULD_NOT_LEAK"); + + const cfg = JSON.parse(dto.config); + expect(cfg.clientId).toBe("poc-client-id"); // non-secret preserved + expect(cfg.folderId).toBe("root"); + expect("clientSecret" in cfg).toBe(false); // secret key removed entirely + expect("refreshToken" in cfg).toBe(false); + + // secretStatus reports presence without the value + expect(dto.secretStatus.clientSecret).toBe(true); + expect(dto.secretStatus.refreshToken).toBe(true); + }); + + it("reports secretStatus=false for an absent/empty secret", async () => { + const { encryptConfig, toAdapterListItem } = await getModules(); + const stored = JSON.stringify(encryptConfig({ clientId: "id", clientSecret: "set" })); + const dto = toAdapterListItem(baseRow(stored)); + expect(dto.secretStatus.clientSecret).toBe(true); + expect(dto.secretStatus.refreshToken).toBeUndefined(); + }); + + it("redacts a database password without dropping structural fields", async () => { + const { encryptConfig, toAdapterListItem } = await getModules(); + const stored = JSON.stringify( + encryptConfig({ host: "db.prod", port: 5432, username: "u", password: "TOPSECRET" }) + ); + const dto = toAdapterListItem({ ...baseRow(stored), type: "database", adapterId: "postgres" }); + expect(JSON.stringify(dto)).not.toContain("TOPSECRET"); + const cfg = JSON.parse(dto.config); + expect(cfg).toEqual({ host: "db.prod", port: 5432, username: "u" }); + }); + + it("returns an empty config for unparseable stored config (no raw leak)", async () => { + const { toAdapterListItem } = await getModules(); + const dto = toAdapterListItem(baseRow("not-json")); + expect(dto.config).toBe("{}"); + expect(dto.secretStatus).toEqual({}); + }); +}); diff --git a/tests/unit/lib/crypto.test.ts b/tests/unit/lib/crypto.test.ts index 4ff53e4f..3e1fa1a8 100644 --- a/tests/unit/lib/crypto.test.ts +++ b/tests/unit/lib/crypto.test.ts @@ -190,6 +190,115 @@ describe("stripSecrets", () => { stripSecrets(original); expect(original.password).toBe("secret"); }); + + it("strips the newly added webhook/twilio/token sensitive keys", async () => { + const { stripSecrets } = await getCrypto(); + const result = stripSecrets({ + authHeader: "Bearer abc", + accountSid: "AC123", + authToken: "tok", + appToken: "app", + botToken: "bot", + accessToken: "acc", + serverUrl: "https://example.com", + }); + expect(result.authHeader).toBe(""); + expect(result.accountSid).toBe(""); + expect(result.authToken).toBe(""); + expect(result.appToken).toBe(""); + expect(result.botToken).toBe(""); + expect(result.accessToken).toBe(""); + expect(result.serverUrl).toBe("https://example.com"); + }); +}); + +describe("mergeSecrets", () => { + async function getCrypto() { + vi.resetModules(); + return import("@/lib/crypto"); + } + + it("keeps the existing secret when the incoming value is empty", async () => { + const { mergeSecrets } = await getCrypto(); + const result = mergeSecrets( + { host: "new-host", password: "" }, + { host: "old-host", password: "real-secret" } + ); + expect(result.host).toBe("new-host"); + expect(result.password).toBe("real-secret"); + }); + + it("restores a secret that is absent from incoming (DTO redacts the key)", async () => { + const { mergeSecrets } = await getCrypto(); + const result = mergeSecrets({ host: "h" }, { host: "h", refreshToken: "rt" }); + // The list DTO removes secret keys, so an untouched secret is omitted on + // re-submit and must be restored from the existing config. + expect(result.refreshToken).toBe("rt"); + }); + + it("does not restore an absent non-sensitive key", async () => { + const { mergeSecrets } = await getCrypto(); + const result = mergeSecrets({ host: "h" }, { host: "h", region: "eu" }); + expect(result.region).toBeUndefined(); + }); + + it("overwrites the existing secret when a non-empty value is supplied", async () => { + const { mergeSecrets } = await getCrypto(); + const result = mergeSecrets( + { clientSecret: "new-secret" }, + { clientSecret: "old-secret" } + ); + expect(result.clientSecret).toBe("new-secret"); + }); + + it("merges nested objects recursively", async () => { + const { mergeSecrets } = await getCrypto(); + const result = mergeSecrets( + { db: { host: "h", password: "" } }, + { db: { host: "old", password: "kept" } } + ); + expect(result.db.password).toBe("kept"); + expect(result.db.host).toBe("h"); + }); + + it("returns incoming verbatim when existing is not an object", async () => { + const { mergeSecrets } = await getCrypto(); + expect(mergeSecrets({ password: "" }, null)).toEqual({ password: "" }); + }); +}); + +describe("redactSecrets / getSecretStatus", () => { + async function getCrypto() { + vi.resetModules(); + return import("@/lib/crypto"); + } + + it("removes scalar secret keys entirely (not blanked to \"\")", async () => { + const { redactSecrets } = await getCrypto(); + const result = redactSecrets({ host: "h", password: "secret", clientSecret: "cs" }); + expect(result).toEqual({ host: "h" }); + expect("password" in result).toBe(false); + expect("clientSecret" in result).toBe(false); + }); + + it("recurses into nested objects and arrays", async () => { + const { redactSecrets } = await getCrypto(); + const result = redactSecrets({ db: { port: 5432, password: "x" }, list: [{ token: "t", id: 1 }] }); + expect(result.db).toEqual({ port: 5432 }); + expect(result.list[0]).toEqual({ id: 1 }); + }); + + it("reports which secrets are set via getSecretStatus", async () => { + const { getSecretStatus } = await getCrypto(); + const status = getSecretStatus({ + host: "h", + clientSecret: "cs", + refreshToken: "", + password: "pw", + }); + expect(status).toEqual({ clientSecret: true, refreshToken: false, password: true }); + expect("host" in status).toBe(false); + }); }); describe("encryptConfig / decryptConfig", () => { From 41631a9e3a456856b580e08436886e85cd8b474a Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 3 Jun 2026 22:38:56 +0200 Subject: [PATCH 04/15] Tie OAuth flows to credential profiles Move OAuth authorization and refresh-token storage from adapter configs to centralized credential profiles. API routes for Dropbox, Google Drive and OneDrive now accept credentialId (instead of adapterId), resolve clientId/clientSecret/refreshToken via the credential service, and write refresh tokens back into the profile. Server filesystem endpoints similarly read credentials from the credential profile. UI components and form sections were updated to pass credentialId, surface a selected profile via CredentialPicker, and show an "authorized" state using a new secretStatus flag. The credential service now exposes secretStatus (via crypto.getSecretStatus) so the frontend can detect which sensitive fields are set without exposing values; logging and state keys were updated accordingly. --- src/app/api/adapters/dropbox/auth/route.ts | 33 ++---- .../api/adapters/dropbox/callback/route.ts | 38 ++---- .../api/adapters/google-drive/auth/route.ts | 38 +++--- .../adapters/google-drive/callback/route.ts | 39 ++----- src/app/api/adapters/onedrive/auth/route.ts | 34 ++---- .../api/adapters/onedrive/callback/route.ts | 38 ++---- .../api/system/filesystem/dropbox/route.ts | 29 ++--- .../system/filesystem/google-drive/route.ts | 29 ++--- .../api/system/filesystem/onedrive/route.ts | 29 ++--- src/components/adapter/credential-picker.tsx | 12 ++ .../adapter/dropbox-folder-browser.tsx | 8 +- .../adapter/dropbox-oauth-button.tsx | 20 ++-- src/components/adapter/form-sections.tsx | 110 ++++++++---------- .../adapter/google-drive-folder-browser.tsx | 8 +- .../adapter/google-drive-oauth-button.tsx | 20 ++-- .../adapter/onedrive-folder-browser.tsx | 8 +- .../adapter/onedrive-oauth-button.tsx | 20 ++-- .../settings/credential-profile-dialog.tsx | 2 + src/lib/core/credentials.ts | 7 ++ src/services/auth/credential-service.ts | 20 +++- 20 files changed, 218 insertions(+), 324 deletions(-) diff --git a/src/app/api/adapters/dropbox/auth/route.ts b/src/app/api/adapters/dropbox/auth/route.ts index 5045b6e9..40a266c6 100644 --- a/src/app/api/adapters/dropbox/auth/route.ts +++ b/src/app/api/adapters/dropbox/auth/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { DropboxAuth } from "dropbox"; import { headers } from "next/headers"; -import prisma from "@/lib/prisma"; import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; import { getDecryptedCredentialData } from "@/services/auth/credential-service"; @@ -13,7 +12,8 @@ const log = logger.child({ route: "adapters/dropbox/auth" }); /** * POST /api/adapters/dropbox/auth * Generates the Dropbox OAuth authorization URL. - * Body: { adapterId: string } - The saved adapter config ID to authorize. + * Body: { credentialId: string } - The OAUTH credential profile to authorize. + * The resulting refresh token is written back into that profile. */ export async function POST(req: NextRequest) { const ctx = await getAuthContext(await headers()); @@ -22,29 +22,14 @@ export async function POST(req: NextRequest) { } try { - checkPermissionWithContext(ctx, PERMISSIONS.DESTINATIONS.WRITE); + checkPermissionWithContext(ctx, PERMISSIONS.CREDENTIALS.WRITE); - const { adapterId } = await req.json(); - if (!adapterId) { - return NextResponse.json({ error: "Missing adapterId" }, { status: 400 }); + const { credentialId } = await req.json(); + if (!credentialId) { + return NextResponse.json({ error: "Missing credentialId" }, { status: 400 }); } - // Load the adapter config to get clientId and clientSecret - const adapterConfig = await prisma.adapterConfig.findUnique({ - where: { id: adapterId }, - }); - - if (!adapterConfig || adapterConfig.adapterId !== "dropbox") { - return NextResponse.json({ error: "Adapter not found or not a Dropbox adapter" }, { status: 404 }); - } - - if (!adapterConfig.primaryCredentialId) { - return NextResponse.json({ error: "Assign an OAuth credential profile (with the app key + secret) before authorizing." }, { status: 400 }); - } - const profile = (await getDecryptedCredentialData( - adapterConfig.primaryCredentialId, - "OAUTH" - )) as OAuthData; + const profile = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData; if (!profile.clientId || !profile.clientSecret) { return NextResponse.json({ error: "App Key and App Secret are required" }, { status: 400 }); @@ -62,7 +47,7 @@ export async function POST(req: NextRequest) { const authUrl = await dbxAuth.getAuthenticationUrl( redirectUri, - adapterId, // state parameter for callback + credentialId, // state parameter for callback "code", "offline", // Request offline access to get refresh_token undefined, // scopes - use app-configured scopes @@ -70,7 +55,7 @@ export async function POST(req: NextRequest) { false ); - log.info("Generated Dropbox OAuth URL", { adapterId }); + log.info("Generated Dropbox OAuth URL", { credentialId }); return NextResponse.json({ success: true, data: { authUrl: String(authUrl) } }); } catch (error) { diff --git a/src/app/api/adapters/dropbox/callback/route.ts b/src/app/api/adapters/dropbox/callback/route.ts index f81e8701..6dbeb84c 100644 --- a/src/app/api/adapters/dropbox/callback/route.ts +++ b/src/app/api/adapters/dropbox/callback/route.ts @@ -1,5 +1,4 @@ import { NextRequest, NextResponse } from "next/server"; -import prisma from "@/lib/prisma"; import { getDecryptedCredentialData, updateCredentialProfile } from "@/services/auth/credential-service"; import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; @@ -11,8 +10,8 @@ const log = logger.child({ route: "adapters/dropbox/callback" }); /** * GET /api/adapters/dropbox/callback * Handles the OAuth callback from Dropbox. - * Exchanges auth code for tokens and stores the refresh token in the adapter config. - * Redirects back to the destinations page with success/error status. + * `state` is the OAUTH credential profile id; the refresh token is written back + * into that profile. Redirects back to the destinations page with status. */ export async function GET(req: NextRequest) { const origin = process.env.BETTER_AUTH_URL || req.nextUrl.origin; @@ -27,7 +26,7 @@ export async function GET(req: NextRequest) { } const code = req.nextUrl.searchParams.get("code"); - const state = req.nextUrl.searchParams.get("state"); // adapter config ID + const state = req.nextUrl.searchParams.get("state"); // OAUTH credential profile id const error = req.nextUrl.searchParams.get("error"); const errorDescription = req.nextUrl.searchParams.get("error_description"); @@ -47,27 +46,8 @@ export async function GET(req: NextRequest) { } try { - // Load the adapter config - const adapterConfig = await prisma.adapterConfig.findUnique({ - where: { id: state }, - }); - - if (!adapterConfig || adapterConfig.adapterId !== "dropbox") { - return NextResponse.redirect( - `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Adapter not found.")}` - ); - } - - if (!adapterConfig.primaryCredentialId) { - return NextResponse.redirect( - `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Assign an OAuth credential profile (with the app key + secret) before authorizing.")}` - ); - } - - const profile = (await getDecryptedCredentialData( - adapterConfig.primaryCredentialId, - "OAUTH" - )) as OAuthData; + // `state` is the OAUTH credential profile id. + const profile = (await getDecryptedCredentialData(state, "OAUTH")) as OAuthData; const redirectUri = `${origin}/api/adapters/dropbox/callback`; @@ -98,18 +78,18 @@ export async function GET(req: NextRequest) { const tokenData = await tokenRes.json(); if (!tokenData.refresh_token) { - log.warn("No refresh token received from Dropbox", { adapterId: state }); + log.warn("No refresh token received from Dropbox", { credentialId: state }); return NextResponse.redirect( `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("No refresh token received. Ensure the app has 'offline' access configured.")}` ); } - // Store the refresh token in the credential profile (not the adapter config). - await updateCredentialProfile(adapterConfig.primaryCredentialId, { + // Store the refresh token in the credential profile. + await updateCredentialProfile(state, { data: { clientId: profile.clientId, clientSecret: profile.clientSecret, refreshToken: tokenData.refresh_token } satisfies OAuthData, }); - log.info("Dropbox OAuth completed successfully", { adapterId: state }); + log.info("Dropbox OAuth completed successfully", { credentialId: state }); return NextResponse.redirect( `${origin}/dashboard/destinations?oauth=success&message=${encodeURIComponent("Dropbox authorized successfully!")}` diff --git a/src/app/api/adapters/google-drive/auth/route.ts b/src/app/api/adapters/google-drive/auth/route.ts index 1efffccb..1453b916 100644 --- a/src/app/api/adapters/google-drive/auth/route.ts +++ b/src/app/api/adapters/google-drive/auth/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { google } from "googleapis"; import { headers } from "next/headers"; -import prisma from "@/lib/prisma"; import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; import { getDecryptedCredentialData } from "@/services/auth/credential-service"; @@ -18,7 +17,11 @@ const SCOPES = [ /** * POST /api/adapters/google-drive/auth * Generates the Google OAuth authorization URL. - * Body: { adapterId: string } - The saved adapter config ID to authorize. + * Body: { credentialId: string } - The OAUTH credential profile to authorize. + * + * Authorization is tied to the credential profile (not a saved adapter): the + * resulting refresh token is written back into that profile, so any destination + * referencing it becomes authorized at once. */ export async function POST(req: NextRequest) { const ctx = await getAuthContext(await headers()); @@ -27,30 +30,15 @@ export async function POST(req: NextRequest) { } try { - checkPermissionWithContext(ctx, PERMISSIONS.DESTINATIONS.WRITE); + checkPermissionWithContext(ctx, PERMISSIONS.CREDENTIALS.WRITE); - const { adapterId } = await req.json(); - if (!adapterId) { - return NextResponse.json({ error: "Missing adapterId" }, { status: 400 }); + const { credentialId } = await req.json(); + if (!credentialId) { + return NextResponse.json({ error: "Missing credentialId" }, { status: 400 }); } - // Load the adapter config to get clientId and clientSecret - const adapterConfig = await prisma.adapterConfig.findUnique({ - where: { id: adapterId }, - }); - - if (!adapterConfig || adapterConfig.adapterId !== "google-drive") { - return NextResponse.json({ error: "Adapter not found or not a Google Drive adapter" }, { status: 404 }); - } - - // clientId + clientSecret both come from the OAUTH credential profile. - if (!adapterConfig.primaryCredentialId) { - return NextResponse.json({ error: "Assign an OAuth credential profile (with the client ID + secret) before authorizing." }, { status: 400 }); - } - const profile = (await getDecryptedCredentialData( - adapterConfig.primaryCredentialId, - "OAUTH" - )) as OAuthData; + // clientId + clientSecret come from the OAUTH credential profile. + const profile = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData; if (!profile.clientId || !profile.clientSecret) { return NextResponse.json({ error: "Client ID and Client Secret are required" }, { status: 400 }); @@ -70,10 +58,10 @@ export async function POST(req: NextRequest) { access_type: "offline", scope: SCOPES, prompt: "consent", // Force consent to always get refresh_token - state: adapterId, // Pass adapter config ID as state for callback + state: credentialId, // Pass credential profile ID as state for callback }); - log.info("Generated Google OAuth URL", { adapterId }); + log.info("Generated Google OAuth URL", { credentialId }); return NextResponse.json({ success: true, data: { authUrl } }); } catch (error) { diff --git a/src/app/api/adapters/google-drive/callback/route.ts b/src/app/api/adapters/google-drive/callback/route.ts index ca11da4d..8e7dd317 100644 --- a/src/app/api/adapters/google-drive/callback/route.ts +++ b/src/app/api/adapters/google-drive/callback/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; import { google } from "googleapis"; -import prisma from "@/lib/prisma"; import { getDecryptedCredentialData, updateCredentialProfile } from "@/services/auth/credential-service"; import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; @@ -12,7 +11,8 @@ const log = logger.child({ route: "adapters/google-drive/callback" }); /** * GET /api/adapters/google-drive/callback * Handles the OAuth callback from Google. - * Exchanges auth code for tokens and stores the refresh token in the adapter config. + * `state` is the OAUTH credential profile id; the refresh token is written back + * into that profile, so every destination referencing it becomes authorized. * Redirects back to the destinations page with success/error status. */ export async function GET(req: NextRequest) { @@ -28,7 +28,7 @@ export async function GET(req: NextRequest) { } const code = req.nextUrl.searchParams.get("code"); - const state = req.nextUrl.searchParams.get("state"); // adapter config ID + const state = req.nextUrl.searchParams.get("state"); // OAUTH credential profile id const error = req.nextUrl.searchParams.get("error"); // Handle user denial @@ -47,29 +47,8 @@ export async function GET(req: NextRequest) { } try { - // Load the adapter config - const adapterConfig = await prisma.adapterConfig.findUnique({ - where: { id: state }, - }); - - if (!adapterConfig || adapterConfig.adapterId !== "google-drive") { - return NextResponse.redirect( - `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Adapter not found.")}` - ); - } - - // clientId + clientSecret + refreshToken all live in the OAUTH credential - // profile referenced by the adapter. - if (!adapterConfig.primaryCredentialId) { - return NextResponse.redirect( - `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Assign an OAuth credential profile (with the client ID + secret) before authorizing.")}` - ); - } - - const profile = (await getDecryptedCredentialData( - adapterConfig.primaryCredentialId, - "OAUTH" - )) as OAuthData; + // `state` is the OAUTH credential profile id. Load its clientId + secret. + const profile = (await getDecryptedCredentialData(state, "OAUTH")) as OAuthData; const redirectUri = `${origin}/api/adapters/google-drive/callback`; @@ -83,18 +62,18 @@ export async function GET(req: NextRequest) { const { tokens } = await oauth2Client.getToken(code); if (!tokens.refresh_token) { - log.warn("No refresh token received from Google", { adapterId: state }); + log.warn("No refresh token received from Google", { credentialId: state }); return NextResponse.redirect( `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("No refresh token received. Please revoke app access in your Google Account settings and try again.")}` ); } - // Store the refresh token in the credential profile (not the adapter config). - await updateCredentialProfile(adapterConfig.primaryCredentialId, { + // Store the refresh token in the credential profile. + await updateCredentialProfile(state, { data: { clientId: profile.clientId, clientSecret: profile.clientSecret, refreshToken: tokens.refresh_token } satisfies OAuthData, }); - log.info("Google Drive OAuth completed successfully", { adapterId: state }); + log.info("Google Drive OAuth completed successfully", { credentialId: state }); return NextResponse.redirect( `${origin}/dashboard/destinations?oauth=success&message=${encodeURIComponent("Google Drive authorized successfully!")}` diff --git a/src/app/api/adapters/onedrive/auth/route.ts b/src/app/api/adapters/onedrive/auth/route.ts index c2c38bf9..67834fd3 100644 --- a/src/app/api/adapters/onedrive/auth/route.ts +++ b/src/app/api/adapters/onedrive/auth/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; import { headers } from "next/headers"; -import prisma from "@/lib/prisma"; import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; import { getDecryptedCredentialData } from "@/services/auth/credential-service"; @@ -18,7 +17,8 @@ const SCOPES = [ /** * POST /api/adapters/onedrive/auth * Generates the Microsoft OAuth authorization URL. - * Body: { adapterId: string } - The saved adapter config ID to authorize. + * Body: { credentialId: string } - The OAUTH credential profile to authorize. + * The resulting refresh token is written back into that profile. */ export async function POST(req: NextRequest) { const ctx = await getAuthContext(await headers()); @@ -27,30 +27,14 @@ export async function POST(req: NextRequest) { } try { - checkPermissionWithContext(ctx, PERMISSIONS.DESTINATIONS.WRITE); + checkPermissionWithContext(ctx, PERMISSIONS.CREDENTIALS.WRITE); - const { adapterId } = await req.json(); - if (!adapterId) { - return NextResponse.json({ error: "Missing adapterId" }, { status: 400 }); + const { credentialId } = await req.json(); + if (!credentialId) { + return NextResponse.json({ error: "Missing credentialId" }, { status: 400 }); } - // Load the adapter config to get clientId and clientSecret - const adapterConfig = await prisma.adapterConfig.findUnique({ - where: { id: adapterId }, - }); - - if (!adapterConfig || adapterConfig.adapterId !== "onedrive") { - return NextResponse.json({ error: "Adapter not found or not a OneDrive adapter" }, { status: 404 }); - } - - // clientId + clientSecret live in the assigned OAUTH profile. - if (!adapterConfig.primaryCredentialId) { - return NextResponse.json({ error: "Assign an OAuth credential profile (with the client ID + secret) before authorizing." }, { status: 400 }); - } - const profile = (await getDecryptedCredentialData( - adapterConfig.primaryCredentialId, - "OAUTH" - )) as OAuthData; + const profile = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData; if (!profile.clientId) { return NextResponse.json({ error: "Client ID is required" }, { status: 400 }); } @@ -66,9 +50,9 @@ export async function POST(req: NextRequest) { authUrl.searchParams.set("scope", SCOPES.join(" ")); authUrl.searchParams.set("response_mode", "query"); authUrl.searchParams.set("prompt", "consent"); // Force consent to always get refresh_token - authUrl.searchParams.set("state", adapterId); // Pass adapter config ID as state for callback + authUrl.searchParams.set("state", credentialId); // Pass credential profile ID as state for callback - log.info("Generated Microsoft OAuth URL", { adapterId }); + log.info("Generated Microsoft OAuth URL", { credentialId }); return NextResponse.json({ success: true, data: { authUrl: authUrl.toString() } }); } catch (error) { diff --git a/src/app/api/adapters/onedrive/callback/route.ts b/src/app/api/adapters/onedrive/callback/route.ts index b05a24f1..cec05ac9 100644 --- a/src/app/api/adapters/onedrive/callback/route.ts +++ b/src/app/api/adapters/onedrive/callback/route.ts @@ -1,5 +1,4 @@ import { NextRequest, NextResponse } from "next/server"; -import prisma from "@/lib/prisma"; import { getDecryptedCredentialData, updateCredentialProfile } from "@/services/auth/credential-service"; import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; @@ -11,8 +10,8 @@ const log = logger.child({ route: "adapters/onedrive/callback" }); /** * GET /api/adapters/onedrive/callback * Handles the OAuth callback from Microsoft. - * Exchanges auth code for tokens and stores the refresh token in the adapter config. - * Redirects back to the destinations page with success/error status. + * `state` is the OAUTH credential profile id; the refresh token is written back + * into that profile. Redirects back to the destinations page with status. */ export async function GET(req: NextRequest) { const origin = process.env.BETTER_AUTH_URL || req.nextUrl.origin; @@ -27,7 +26,7 @@ export async function GET(req: NextRequest) { } const code = req.nextUrl.searchParams.get("code"); - const state = req.nextUrl.searchParams.get("state"); // adapter config ID + const state = req.nextUrl.searchParams.get("state"); // OAUTH credential profile id const error = req.nextUrl.searchParams.get("error"); const errorDescription = req.nextUrl.searchParams.get("error_description"); @@ -47,27 +46,8 @@ export async function GET(req: NextRequest) { } try { - // Load the adapter config - const adapterConfig = await prisma.adapterConfig.findUnique({ - where: { id: state }, - }); - - if (!adapterConfig || adapterConfig.adapterId !== "onedrive") { - return NextResponse.redirect( - `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Adapter not found.")}` - ); - } - - if (!adapterConfig.primaryCredentialId) { - return NextResponse.redirect( - `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Assign an OAuth credential profile (with the client ID + secret) before authorizing.")}` - ); - } - - const profile = (await getDecryptedCredentialData( - adapterConfig.primaryCredentialId, - "OAUTH" - )) as OAuthData; + // `state` is the OAUTH credential profile id. + const profile = (await getDecryptedCredentialData(state, "OAUTH")) as OAuthData; const redirectUri = `${origin}/api/adapters/onedrive/callback`; @@ -98,18 +78,18 @@ export async function GET(req: NextRequest) { const tokenData = await tokenRes.json(); if (!tokenData.refresh_token) { - log.warn("No refresh token received from Microsoft", { adapterId: state }); + log.warn("No refresh token received from Microsoft", { credentialId: state }); return NextResponse.redirect( `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("No refresh token received. Ensure the app has 'offline_access' scope configured.")}` ); } - // Store the refresh token in the credential profile (not the adapter config). - await updateCredentialProfile(adapterConfig.primaryCredentialId, { + // Store the refresh token in the credential profile. + await updateCredentialProfile(state, { data: { clientId: profile.clientId, clientSecret: profile.clientSecret, refreshToken: tokenData.refresh_token } satisfies OAuthData, }); - log.info("OneDrive OAuth completed successfully", { adapterId: state }); + log.info("OneDrive OAuth completed successfully", { credentialId: state }); return NextResponse.redirect( `${origin}/dashboard/destinations?oauth=success&message=${encodeURIComponent("OneDrive authorized successfully!")}` diff --git a/src/app/api/system/filesystem/dropbox/route.ts b/src/app/api/system/filesystem/dropbox/route.ts index 9a81a872..c4f776db 100644 --- a/src/app/api/system/filesystem/dropbox/route.ts +++ b/src/app/api/system/filesystem/dropbox/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { Dropbox } from "dropbox"; -import prisma from "@/lib/prisma"; import { checkPermission } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; -import { resolveAdapterConfig } from "@/lib/adapters/config-resolver"; +import { getDecryptedCredentialData } from "@/services/auth/credential-service"; +import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; import { wrapError } from "@/lib/logging/errors"; @@ -14,12 +14,12 @@ const log = logger.child({ route: "system/filesystem/dropbox" }); * Browse Dropbox folders for the folder picker. * * Body: { - * adapterId: string, // saved Dropbox adapter config id - * folderPath?: string // Folder to list ("" or undefined = root) + * credentialId: string, // OAUTH credential profile id + * folderPath?: string // Folder to list ("" or undefined = root) * } * - * Credentials are resolved server-side from the adapter's OAUTH credential - * profile - they never travel through the client. + * Credentials are resolved server-side from the OAUTH credential profile - they + * never travel through the client. * * Returns the same shape as the local/remote filesystem API: * { success, data: { currentPath, currentName, parentPath, entries: [{ name, type, path }] } } @@ -29,22 +29,13 @@ export async function POST(req: NextRequest) { await checkPermission(PERMISSIONS.DESTINATIONS.READ); const body = await req.json(); - const { adapterId, folderPath } = body; + const { credentialId, folderPath } = body; - if (!adapterId) { - return NextResponse.json({ success: false, error: "Missing adapterId" }, { status: 400 }); + if (!credentialId) { + return NextResponse.json({ success: false, error: "Missing credentialId" }, { status: 400 }); } - const adapterRow = await prisma.adapterConfig.findUnique({ where: { id: adapterId } }); - if (!adapterRow || adapterRow.adapterId !== "dropbox") { - return NextResponse.json({ success: false, error: "Adapter not found" }, { status: 404 }); - } - - const config = (await resolveAdapterConfig(adapterRow)) as { - clientId?: string; - clientSecret?: string; - refreshToken?: string; - }; + const config = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData; if (!config?.clientId || !config?.clientSecret || !config?.refreshToken) { return NextResponse.json( diff --git a/src/app/api/system/filesystem/google-drive/route.ts b/src/app/api/system/filesystem/google-drive/route.ts index a652b1b5..4a84f6b1 100644 --- a/src/app/api/system/filesystem/google-drive/route.ts +++ b/src/app/api/system/filesystem/google-drive/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { google } from "googleapis"; -import prisma from "@/lib/prisma"; import { checkPermission } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; -import { resolveAdapterConfig } from "@/lib/adapters/config-resolver"; +import { getDecryptedCredentialData } from "@/services/auth/credential-service"; +import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; import { wrapError } from "@/lib/logging/errors"; @@ -14,12 +14,12 @@ const log = logger.child({ route: "system/filesystem/google-drive" }); * Browse Google Drive folders for the folder picker. * * Body: { - * adapterId: string, // saved Google Drive adapter config id - * folderId?: string // Folder to list (undefined = root) + * credentialId: string, // OAUTH credential profile id + * folderId?: string // Folder to list (undefined = root) * } * - * Credentials (clientSecret/refreshToken) are resolved server-side from the - * adapter's OAUTH credential profile - they never travel through the client. + * Credentials are resolved server-side from the OAUTH credential profile - they + * never travel through the client. * * Returns the same shape as the local/remote filesystem API: * { success, data: { currentPath, parentPath, entries: [{ name, type, path }] } } @@ -31,22 +31,13 @@ export async function POST(req: NextRequest) { await checkPermission(PERMISSIONS.DESTINATIONS.READ); const body = await req.json(); - const { adapterId, folderId } = body; + const { credentialId, folderId } = body; - if (!adapterId) { - return NextResponse.json({ success: false, error: "Missing adapterId" }, { status: 400 }); + if (!credentialId) { + return NextResponse.json({ success: false, error: "Missing credentialId" }, { status: 400 }); } - const adapterRow = await prisma.adapterConfig.findUnique({ where: { id: adapterId } }); - if (!adapterRow || adapterRow.adapterId !== "google-drive") { - return NextResponse.json({ success: false, error: "Adapter not found" }, { status: 404 }); - } - - const config = (await resolveAdapterConfig(adapterRow)) as { - clientId?: string; - clientSecret?: string; - refreshToken?: string; - }; + const config = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData; if (!config?.clientId || !config?.clientSecret || !config?.refreshToken) { return NextResponse.json( diff --git a/src/app/api/system/filesystem/onedrive/route.ts b/src/app/api/system/filesystem/onedrive/route.ts index d5b8b2a8..a5843adb 100644 --- a/src/app/api/system/filesystem/onedrive/route.ts +++ b/src/app/api/system/filesystem/onedrive/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { Client } from "@microsoft/microsoft-graph-client"; -import prisma from "@/lib/prisma"; import { checkPermission } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; -import { resolveAdapterConfig } from "@/lib/adapters/config-resolver"; +import { getDecryptedCredentialData } from "@/services/auth/credential-service"; +import type { OAuthData } from "@/lib/core/credentials"; import { logger } from "@/lib/logging/logger"; import { wrapError } from "@/lib/logging/errors"; @@ -17,12 +17,12 @@ const TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; * Browse OneDrive folders for the folder picker. * * Body: { - * adapterId: string, // saved OneDrive adapter config id - * folderPath?: string // Folder to list ("" or undefined = root) + * credentialId: string, // OAUTH credential profile id + * folderPath?: string // Folder to list ("" or undefined = root) * } * - * Credentials are resolved server-side from the adapter's OAUTH credential - * profile - they never travel through the client. + * Credentials are resolved server-side from the OAUTH credential profile - they + * never travel through the client. * * Returns the same shape as the local/remote filesystem API: * { success, data: { currentPath, currentName, parentPath, entries: [{ name, type, path }] } } @@ -32,22 +32,13 @@ export async function POST(req: NextRequest) { await checkPermission(PERMISSIONS.DESTINATIONS.READ); const body = await req.json(); - const { adapterId, folderPath } = body; + const { credentialId, folderPath } = body; - if (!adapterId) { - return NextResponse.json({ success: false, error: "Missing adapterId" }, { status: 400 }); + if (!credentialId) { + return NextResponse.json({ success: false, error: "Missing credentialId" }, { status: 400 }); } - const adapterRow = await prisma.adapterConfig.findUnique({ where: { id: adapterId } }); - if (!adapterRow || adapterRow.adapterId !== "onedrive") { - return NextResponse.json({ success: false, error: "Adapter not found" }, { status: 404 }); - } - - const config = (await resolveAdapterConfig(adapterRow)) as { - clientId?: string; - clientSecret?: string; - refreshToken?: string; - }; + const config = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData; if (!config?.clientId || !config?.clientSecret || !config?.refreshToken) { return NextResponse.json( diff --git a/src/components/adapter/credential-picker.tsx b/src/components/adapter/credential-picker.tsx index fcc9ce45..e5414da4 100644 --- a/src/components/adapter/credential-picker.tsx +++ b/src/components/adapter/credential-picker.tsx @@ -35,6 +35,8 @@ interface Props { /** Render label/help text inline. */ label?: string; description?: string; + /** Notified with the resolved profile object whenever the selection changes (incl. after load). */ + onSelectedProfile?: (profile: CredentialProfileSummary | null) => void; } const TYPE_BADGE: Record = { @@ -54,6 +56,7 @@ export function CredentialPicker({ onChange, label, description, + onSelectedProfile, }: Props) { const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); @@ -88,6 +91,15 @@ export function CredentialPicker({ }; const selected = profiles.find((p) => p.id === value); + + // Surface the resolved profile to the parent (e.g. so an OAuth form can read + // its `secretStatus` to know whether it's authorized). Keyed on the resolved + // id so it also fires once the profile list finishes loading. + useEffect(() => { + onSelectedProfile?.(selected ?? null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected?.id, selected?.updatedAt]); + const defaultLabel = slot === "ssh" ? "SSH Credential Profile" : "Credential Profile"; const finalLabel = label ?? defaultLabel; diff --git a/src/components/adapter/dropbox-folder-browser.tsx b/src/components/adapter/dropbox-folder-browser.tsx index 62fd9520..e805fef5 100644 --- a/src/components/adapter/dropbox-folder-browser.tsx +++ b/src/components/adapter/dropbox-folder-browser.tsx @@ -24,8 +24,8 @@ interface DropboxFolderBrowserProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (folderPath: string) => void; - /** Saved adapter config id; credentials are resolved server-side from its OAUTH profile. */ - adapterConfigId: string; + /** OAUTH credential profile id; credentials are resolved server-side from it. */ + credentialId: string; initialPath?: string; } @@ -38,7 +38,7 @@ export function DropboxFolderBrowser({ open, onOpenChange, onSelect, - adapterConfigId, + credentialId, initialPath, }: DropboxFolderBrowserProps) { const [currentPath, setCurrentPath] = useState(initialPath || ""); @@ -63,7 +63,7 @@ export function DropboxFolderBrowser({ const res = await fetch("/api/system/filesystem/dropbox", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ adapterId: adapterConfigId, folderPath }), + body: JSON.stringify({ credentialId, folderPath }), }); const json = await res.json(); diff --git a/src/components/adapter/dropbox-oauth-button.tsx b/src/components/adapter/dropbox-oauth-button.tsx index f4874232..920aed55 100644 --- a/src/components/adapter/dropbox-oauth-button.tsx +++ b/src/components/adapter/dropbox-oauth-button.tsx @@ -7,31 +7,31 @@ import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; interface DropboxOAuthButtonProps { - /** The saved adapter config ID (from database) */ - adapterId?: string; - /** Whether a refresh token already exists */ - hasRefreshToken?: boolean; + /** The OAUTH credential profile id to authorize */ + credentialId?: string; + /** Whether the profile already has a refresh token */ + authorized?: boolean; } /** * OAuth authorization button for Dropbox. - * Must only be shown AFTER the adapter config is saved (needs the DB ID). + * Authorizes the selected OAUTH credential profile - no saved destination needed. */ -export function DropboxOAuthButton({ adapterId, hasRefreshToken }: DropboxOAuthButtonProps) { +export function DropboxOAuthButton({ credentialId, authorized }: DropboxOAuthButtonProps) { const [isLoading, setIsLoading] = useState(false); - if (!adapterId) { + if (!credentialId) { return ( - Save the configuration first, then you can authorize with Dropbox. + Select or create an OAuth credential profile (with the app key + secret) first, then authorize with Dropbox. ); } - if (hasRefreshToken) { + if (authorized) { return ( @@ -58,7 +58,7 @@ export function DropboxOAuthButton({ adapterId, hasRefreshToken }: DropboxOAuthB const res = await fetch("/api/adapters/dropbox/auth", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ adapterId }), + body: JSON.stringify({ credentialId }), }); const data = await res.json(); diff --git a/src/components/adapter/form-sections.tsx b/src/components/adapter/form-sections.tsx index 36de7179..37dcde05 100644 --- a/src/components/adapter/form-sections.tsx +++ b/src/components/adapter/form-sections.tsx @@ -40,6 +40,7 @@ import { DropboxFolderBrowser } from "./dropbox-folder-browser"; import { OneDriveOAuthButton } from "./onedrive-oauth-button"; import { OneDriveFolderBrowser } from "./onedrive-folder-browser"; import { CredentialPicker } from "./credential-picker"; +import type { CredentialProfileSummary } from "@/components/settings/credential-profile-dialog"; import { AdapterConfig } from "./types"; interface CredentialPickerHostProps { @@ -66,7 +67,8 @@ function PrimaryCredentialPickerSlot({ adapter, primaryCredentialId, onPrimaryChange, -}: { adapter: AdapterDefinition } & CredentialPickerHostProps) { + onSelectedProfile, +}: { adapter: AdapterDefinition; onSelectedProfile?: (p: CredentialProfileSummary | null) => void } & CredentialPickerHostProps) { const required = adapter.credentials?.primary; if (!required || !onPrimaryChange) return null; return ( @@ -76,6 +78,7 @@ function PrimaryCredentialPickerSlot({ value={primaryCredentialId ?? null} onChange={onPrimaryChange} label="Credential Profile" + onSelectedProfile={onSelectedProfile} /> ); } @@ -658,7 +661,7 @@ function SshConfigSection({ adapter, sshAuthType, sshCredentialId, description } export function StorageFormContent({ adapter, - initialData, + initialData: _initialData, healthNotificationsDisabled, onHealthNotificationsDisabledChange, primaryCredentialId, @@ -676,21 +679,16 @@ export function StorageFormContent({ const isGoogleDrive = adapter.id === 'google-drive'; const isDropbox = adapter.id === 'dropbox'; const isOneDrive = adapter.id === 'onedrive'; - const isOAuthAdapter = isGoogleDrive || isDropbox || isOneDrive; - - // For OAuth adapters: filter out refreshToken from connection keys (auto-managed via OAuth) - const connectionKeys = isOAuthAdapter - ? STORAGE_CONNECTION_KEYS.filter(k => k !== 'refreshToken') - : STORAGE_CONNECTION_KEYS; - // For OAuth adapters: filter out refreshToken from config keys too - const configKeys = isOAuthAdapter - ? STORAGE_CONFIG_KEYS.filter(k => k !== 'refreshToken') - : STORAGE_CONFIG_KEYS; + const connectionKeys = STORAGE_CONNECTION_KEYS; + const configKeys = STORAGE_CONFIG_KEYS; - // Check if a refresh token is stored (for existing/authorized adapters). - // The list DTO redacts the value, so we rely on the `secretStatus` flag. - const hasRefreshToken = initialData?.secretStatus?.refreshToken === true; + // OAuth authorization now lives on the credential profile: the refresh token + // is stored there, not on the adapter. We read the selected profile's + // `secretStatus` to know whether it's authorized - so it reflects reality + // even before the destination itself is saved. + const [selectedProfile, setSelectedProfile] = useState(null); + const authorized = selectedProfile?.secretStatus?.refreshToken === true; return ( @@ -706,6 +704,7 @@ export function StorageFormContent({ adapter={adapter} primaryCredentialId={primaryCredentialId} onPrimaryChange={onPrimaryChange} + onSelectedProfile={setSelectedProfile} /> {(adapter.id === 'sftp' || adapter.id === 'rsync') ? (
@@ -729,29 +728,20 @@ export function StorageFormContent({ )}
) : isGoogleDrive ? ( -
- - -
+ ) : isDropbox ? ( -
- - -
+ ) : isOneDrive ? ( -
- - -
+ ) : ( )} @@ -762,20 +752,20 @@ export function StorageFormContent({ {isGoogleDrive ? ( ) : isDropbox ? ( ) : isOneDrive ? ( ) : hasRealConfigKeys ? ( <> @@ -870,12 +860,12 @@ export function GenericFormContent({ adapter, detectedVersion }: { adapter: Adap */ function GoogleDriveFolderField({ adapter: _adapter, - hasRefreshToken, - adapterConfigId, + authorized, + credentialId, }: { adapter: AdapterDefinition; - hasRefreshToken: boolean; - adapterConfigId?: string; + authorized: boolean; + credentialId?: string; }) { const { setValue, watch } = useFormContext(); const [isBrowserOpen, setIsBrowserOpen] = useState(false); @@ -884,7 +874,7 @@ function GoogleDriveFolderField({ // Secrets (clientSecret/refreshToken) live in the vault and are resolved // server-side by adapterId; the browser only needs the saved adapter id. - const canBrowse = hasRefreshToken && !!adapterConfigId; + const canBrowse = authorized && !!credentialId; return (
@@ -928,7 +918,7 @@ function GoogleDriveFolderField({ setValue("config.folderId", selectedId); setFolderName(selectedName); }} - adapterConfigId={adapterConfigId!} + credentialId={credentialId!} initialFolderId={folderId || undefined} /> )} @@ -942,18 +932,18 @@ function GoogleDriveFolderField({ */ function DropboxFolderField({ adapter: _adapter, - hasRefreshToken, - adapterConfigId, + authorized, + credentialId, }: { adapter: AdapterDefinition; - hasRefreshToken: boolean; - adapterConfigId?: string; + authorized: boolean; + credentialId?: string; }) { const { setValue, watch } = useFormContext(); const [isBrowserOpen, setIsBrowserOpen] = useState(false); const folderPath = watch("config.folderPath") || ""; - const canBrowse = hasRefreshToken && !!adapterConfigId; + const canBrowse = authorized && !!credentialId; return (
@@ -991,7 +981,7 @@ function DropboxFolderField({ onSelect={(selectedPath) => { setValue("config.folderPath", selectedPath); }} - adapterConfigId={adapterConfigId!} + credentialId={credentialId!} initialPath={folderPath || undefined} /> )} @@ -1005,18 +995,18 @@ function DropboxFolderField({ */ function OneDriveFolderField({ adapter: _adapter, - hasRefreshToken, - adapterConfigId, + authorized, + credentialId, }: { adapter: AdapterDefinition; - hasRefreshToken: boolean; - adapterConfigId?: string; + authorized: boolean; + credentialId?: string; }) { const { setValue, watch } = useFormContext(); const [isBrowserOpen, setIsBrowserOpen] = useState(false); const folderPath = watch("config.folderPath") || ""; - const canBrowse = hasRefreshToken && !!adapterConfigId; + const canBrowse = authorized && !!credentialId; return (
@@ -1054,7 +1044,7 @@ function OneDriveFolderField({ onSelect={(selectedPath) => { setValue("config.folderPath", selectedPath); }} - adapterConfigId={adapterConfigId!} + credentialId={credentialId!} initialPath={folderPath || undefined} /> )} diff --git a/src/components/adapter/google-drive-folder-browser.tsx b/src/components/adapter/google-drive-folder-browser.tsx index 4d9111b8..64f9896c 100644 --- a/src/components/adapter/google-drive-folder-browser.tsx +++ b/src/components/adapter/google-drive-folder-browser.tsx @@ -24,8 +24,8 @@ interface GoogleDriveFolderBrowserProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (folderId: string, folderName: string) => void; - /** Saved adapter config id; credentials are resolved server-side from its OAUTH profile. */ - adapterConfigId: string; + /** OAUTH credential profile id; credentials are resolved server-side from it. */ + credentialId: string; initialFolderId?: string; } @@ -38,7 +38,7 @@ export function GoogleDriveFolderBrowser({ open, onOpenChange, onSelect, - adapterConfigId, + credentialId, initialFolderId, }: GoogleDriveFolderBrowserProps) { const [currentFolderId, setCurrentFolderId] = useState(initialFolderId || "root"); @@ -68,7 +68,7 @@ export function GoogleDriveFolderBrowser({ const res = await fetch("/api/system/filesystem/google-drive", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ adapterId: adapterConfigId, folderId }), + body: JSON.stringify({ credentialId, folderId }), }); const json = await res.json(); diff --git a/src/components/adapter/google-drive-oauth-button.tsx b/src/components/adapter/google-drive-oauth-button.tsx index 463deae2..008f3af1 100644 --- a/src/components/adapter/google-drive-oauth-button.tsx +++ b/src/components/adapter/google-drive-oauth-button.tsx @@ -7,31 +7,31 @@ import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; interface GoogleDriveOAuthButtonProps { - /** The saved adapter config ID (from database) */ - adapterId?: string; - /** Whether a refresh token already exists */ - hasRefreshToken?: boolean; + /** The OAUTH credential profile id to authorize */ + credentialId?: string; + /** Whether the profile already has a refresh token */ + authorized?: boolean; } /** * OAuth authorization button for Google Drive. - * Must only be shown AFTER the adapter config is saved (needs the DB ID). + * Authorizes the selected OAUTH credential profile - no saved destination needed. */ -export function GoogleDriveOAuthButton({ adapterId, hasRefreshToken }: GoogleDriveOAuthButtonProps) { +export function GoogleDriveOAuthButton({ credentialId, authorized }: GoogleDriveOAuthButtonProps) { const [isLoading, setIsLoading] = useState(false); - if (!adapterId) { + if (!credentialId) { return ( - Save the configuration first, then you can authorize with Google. + Select or create an OAuth credential profile (with the client ID + secret) first, then authorize with Google. ); } - if (hasRefreshToken) { + if (authorized) { return ( @@ -58,7 +58,7 @@ export function GoogleDriveOAuthButton({ adapterId, hasRefreshToken }: GoogleDri const res = await fetch("/api/adapters/google-drive/auth", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ adapterId }), + body: JSON.stringify({ credentialId }), }); const data = await res.json(); diff --git a/src/components/adapter/onedrive-folder-browser.tsx b/src/components/adapter/onedrive-folder-browser.tsx index 5a0e3f53..a5afff11 100644 --- a/src/components/adapter/onedrive-folder-browser.tsx +++ b/src/components/adapter/onedrive-folder-browser.tsx @@ -24,8 +24,8 @@ interface OneDriveFolderBrowserProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (folderPath: string) => void; - /** Saved adapter config id; credentials are resolved server-side from its OAUTH profile. */ - adapterConfigId: string; + /** OAUTH credential profile id; credentials are resolved server-side from it. */ + credentialId: string; initialPath?: string; } @@ -38,7 +38,7 @@ export function OneDriveFolderBrowser({ open, onOpenChange, onSelect, - adapterConfigId, + credentialId, initialPath, }: OneDriveFolderBrowserProps) { const [currentPath, setCurrentPath] = useState(initialPath || ""); @@ -63,7 +63,7 @@ export function OneDriveFolderBrowser({ const res = await fetch("/api/system/filesystem/onedrive", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ adapterId: adapterConfigId, folderPath }), + body: JSON.stringify({ credentialId, folderPath }), }); const json = await res.json(); diff --git a/src/components/adapter/onedrive-oauth-button.tsx b/src/components/adapter/onedrive-oauth-button.tsx index 6742bebb..2ac93cf1 100644 --- a/src/components/adapter/onedrive-oauth-button.tsx +++ b/src/components/adapter/onedrive-oauth-button.tsx @@ -7,31 +7,31 @@ import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; interface OneDriveOAuthButtonProps { - /** The saved adapter config ID (from database) */ - adapterId?: string; - /** Whether a refresh token already exists */ - hasRefreshToken?: boolean; + /** The OAUTH credential profile id to authorize */ + credentialId?: string; + /** Whether the profile already has a refresh token */ + authorized?: boolean; } /** * OAuth authorization button for OneDrive. - * Must only be shown AFTER the adapter config is saved (needs the DB ID). + * Authorizes the selected OAUTH credential profile - no saved destination needed. */ -export function OneDriveOAuthButton({ adapterId, hasRefreshToken }: OneDriveOAuthButtonProps) { +export function OneDriveOAuthButton({ credentialId, authorized }: OneDriveOAuthButtonProps) { const [isLoading, setIsLoading] = useState(false); - if (!adapterId) { + if (!credentialId) { return ( - Save the configuration first, then you can authorize with Microsoft. + Select or create an OAuth credential profile (with the client ID + secret) first, then authorize with Microsoft. ); } - if (hasRefreshToken) { + if (authorized) { return ( @@ -58,7 +58,7 @@ export function OneDriveOAuthButton({ adapterId, hasRefreshToken }: OneDriveOAut const res = await fetch("/api/adapters/onedrive/auth", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ adapterId }), + body: JSON.stringify({ credentialId }), }); const data = await res.json(); diff --git a/src/components/settings/credential-profile-dialog.tsx b/src/components/settings/credential-profile-dialog.tsx index 76d3243a..c1a3a9c5 100644 --- a/src/components/settings/credential-profile-dialog.tsx +++ b/src/components/settings/credential-profile-dialog.tsx @@ -52,6 +52,8 @@ export interface CredentialProfileSummary { description: string | null; createdAt: string | Date; updatedAt: string | Date; + /** Which sensitive fields are set (e.g. OAUTH `refreshToken`) - no values. */ + secretStatus?: Record; } interface Props { diff --git a/src/lib/core/credentials.ts b/src/lib/core/credentials.ts index a517ea8e..d2b647fe 100644 --- a/src/lib/core/credentials.ts +++ b/src/lib/core/credentials.ts @@ -126,6 +126,13 @@ export interface CredentialProfileShape { description: string | null; createdAt: Date; updatedAt: Date; + /** + * Which sensitive fields of the (encrypted) payload hold a non-empty value, + * e.g. `{ clientSecret: true, refreshToken: false }`. Lets the UI tell whether + * an OAUTH profile has been authorized (refreshToken present) WITHOUT exposing + * any secret value. Never contains the values themselves. + */ + secretStatus?: Record; } /** diff --git a/src/services/auth/credential-service.ts b/src/services/auth/credential-service.ts index 2573a973..e2b68ea6 100644 --- a/src/services/auth/credential-service.ts +++ b/src/services/auth/credential-service.ts @@ -1,5 +1,5 @@ import prisma from "@/lib/prisma"; -import { encrypt, decrypt } from "@/lib/crypto"; +import { encrypt, decrypt, getSecretStatus } from "@/lib/crypto"; import { logger } from "@/lib/logging/logger"; import { ConflictError, NotFoundError, ValidationError, wrapError } from "@/lib/logging/errors"; import { @@ -34,6 +34,19 @@ function sanitize(profile: { }; } +/** + * Computes which sensitive payload fields are set, WITHOUT exposing values. + * Used so the UI can tell whether an OAUTH profile is authorized (has a + * refreshToken). Returns undefined if the payload can't be decrypted/parsed. + */ +function secretStatusOf(encryptedData: string): Record | undefined { + try { + return getSecretStatus(JSON.parse(decrypt(encryptedData))); + } catch { + return undefined; + } +} + /** * Creates a new credential profile. * Validates the payload against the type-specific schema before encrypting. @@ -90,7 +103,7 @@ export async function listCredentialProfiles( where: type ? { type } : undefined, orderBy: { createdAt: "desc" }, }); - return profiles.map(sanitize); + return profiles.map((p) => ({ ...sanitize(p), secretStatus: secretStatusOf(p.data) })); } /** @@ -111,6 +124,7 @@ export async function listCredentialProfilesWithCounts( }); return profiles.map((p) => ({ ...sanitize(p), + secretStatus: secretStatusOf(p.data), usageCount: p._count.primaryAdapters + p._count.sshAdapters, })); } @@ -123,7 +137,7 @@ export async function getCredentialProfile(id: string): Promise Date: Wed, 3 Jun 2026 22:59:48 +0200 Subject: [PATCH 05/15] Add popup OAuth flow and credential refresh Switch OAuth buttons (Google Drive, Dropbox, OneDrive) to open a popup for consent and handle results via postMessage; add onAuthorized callbacks. Add OAuthToastHandler logic to postMessage to opener and close the popup (or show toasts when run in-page). Expose a refreshKey on CredentialPicker and thread it through form sections to trigger re-fetch of credential profiles after successful OAuth (credentialRefreshKey incremented via onAuthorized). Also add polling fallback for closed popups, cleanup of event listeners, and minor loading-state fixes and imports. --- src/components/adapter/credential-picker.tsx | 5 ++- .../adapter/dropbox-oauth-button.tsx | 44 ++++++++++++++++-- src/components/adapter/form-sections.tsx | 14 +++++- .../adapter/google-drive-oauth-button.tsx | 45 +++++++++++++++++-- .../adapter/oauth-toast-handler.tsx | 13 ++++++ .../adapter/onedrive-oauth-button.tsx | 44 ++++++++++++++++-- 6 files changed, 150 insertions(+), 15 deletions(-) diff --git a/src/components/adapter/credential-picker.tsx b/src/components/adapter/credential-picker.tsx index e5414da4..32ab3cd2 100644 --- a/src/components/adapter/credential-picker.tsx +++ b/src/components/adapter/credential-picker.tsx @@ -37,6 +37,8 @@ interface Props { description?: string; /** Notified with the resolved profile object whenever the selection changes (incl. after load). */ onSelectedProfile?: (profile: CredentialProfileSummary | null) => void; + /** Increment to trigger a profiles re-fetch (e.g. after OAuth completes). */ + refreshKey?: number; } const TYPE_BADGE: Record = { @@ -57,6 +59,7 @@ export function CredentialPicker({ label, description, onSelectedProfile, + refreshKey, }: Props) { const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); @@ -83,7 +86,7 @@ export function CredentialPicker({ useEffect(() => { fetchProfiles(); - }, [fetchProfiles]); + }, [fetchProfiles, refreshKey]); const onCreated = (profile: CredentialProfileSummary) => { setProfiles((prev) => [profile, ...prev.filter((p) => p.id !== profile.id)]); diff --git a/src/components/adapter/dropbox-oauth-button.tsx b/src/components/adapter/dropbox-oauth-button.tsx index 920aed55..811aafd7 100644 --- a/src/components/adapter/dropbox-oauth-button.tsx +++ b/src/components/adapter/dropbox-oauth-button.tsx @@ -11,13 +11,15 @@ interface DropboxOAuthButtonProps { credentialId?: string; /** Whether the profile already has a refresh token */ authorized?: boolean; + /** Called when authorization completes successfully in the popup. */ + onAuthorized?: () => void; } /** * OAuth authorization button for Dropbox. * Authorizes the selected OAUTH credential profile - no saved destination needed. */ -export function DropboxOAuthButton({ credentialId, authorized }: DropboxOAuthButtonProps) { +export function DropboxOAuthButton({ credentialId, authorized, onAuthorized }: DropboxOAuthButtonProps) { const [isLoading, setIsLoading] = useState(false); if (!credentialId) { @@ -64,14 +66,48 @@ export function DropboxOAuthButton({ credentialId, authorized }: DropboxOAuthBut const data = await res.json(); if (data.success && data.data?.authUrl) { - // Redirect to Dropbox consent screen - window.location.href = data.data.authUrl; + const popup = window.open( + data.data.authUrl, + "dbackup_oauth", + "width=600,height=700,scrollbars=yes,resizable=yes" + ); + + if (!popup) { + // Popup blocked - fall back to full navigation. + window.location.href = data.data.authUrl; + return; + } + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== "oauth_complete") return; + window.removeEventListener("message", handleMessage); + clearInterval(pollClosed); + setIsLoading(false); + if (event.data.status === "success") { + toast.success(event.data.message); + onAuthorized?.(); + } else { + toast.error(event.data.message); + } + }; + + window.addEventListener("message", handleMessage); + + // Fallback: detect when the popup is closed without a message. + const pollClosed = setInterval(() => { + if (popup.closed) { + clearInterval(pollClosed); + window.removeEventListener("message", handleMessage); + setIsLoading(false); + } + }, 500); } else { toast.error(data.error || "Failed to start authorization"); + setIsLoading(false); } } catch { toast.error("Failed to start Dropbox authorization"); - } finally { setIsLoading(false); } } diff --git a/src/components/adapter/form-sections.tsx b/src/components/adapter/form-sections.tsx index 37dcde05..4e5bf759 100644 --- a/src/components/adapter/form-sections.tsx +++ b/src/components/adapter/form-sections.tsx @@ -1,5 +1,5 @@ import { useFormContext } from "react-hook-form"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; import { Tabs, @@ -68,7 +68,8 @@ function PrimaryCredentialPickerSlot({ primaryCredentialId, onPrimaryChange, onSelectedProfile, -}: { adapter: AdapterDefinition; onSelectedProfile?: (p: CredentialProfileSummary | null) => void } & CredentialPickerHostProps) { + refreshKey, +}: { adapter: AdapterDefinition; onSelectedProfile?: (p: CredentialProfileSummary | null) => void; refreshKey?: number } & CredentialPickerHostProps) { const required = adapter.credentials?.primary; if (!required || !onPrimaryChange) return null; return ( @@ -79,6 +80,7 @@ function PrimaryCredentialPickerSlot({ onChange={onPrimaryChange} label="Credential Profile" onSelectedProfile={onSelectedProfile} + refreshKey={refreshKey} /> ); } @@ -690,6 +692,10 @@ export function StorageFormContent({ const [selectedProfile, setSelectedProfile] = useState(null); const authorized = selectedProfile?.secretStatus?.refreshToken === true; + // Incremented after a successful popup OAuth to trigger a credential re-fetch. + const [credentialRefreshKey, setCredentialRefreshKey] = useState(0); + const handleOAuthAuthorized = useCallback(() => setCredentialRefreshKey((k) => k + 1), []); + return ( @@ -705,6 +711,7 @@ export function StorageFormContent({ primaryCredentialId={primaryCredentialId} onPrimaryChange={onPrimaryChange} onSelectedProfile={setSelectedProfile} + refreshKey={credentialRefreshKey} /> {(adapter.id === 'sftp' || adapter.id === 'rsync') ? (
@@ -731,16 +738,19 @@ export function StorageFormContent({ ) : isDropbox ? ( ) : isOneDrive ? ( ) : ( diff --git a/src/components/adapter/google-drive-oauth-button.tsx b/src/components/adapter/google-drive-oauth-button.tsx index 008f3af1..1419d828 100644 --- a/src/components/adapter/google-drive-oauth-button.tsx +++ b/src/components/adapter/google-drive-oauth-button.tsx @@ -11,13 +11,15 @@ interface GoogleDriveOAuthButtonProps { credentialId?: string; /** Whether the profile already has a refresh token */ authorized?: boolean; + /** Called when authorization completes successfully in the popup. */ + onAuthorized?: () => void; } /** * OAuth authorization button for Google Drive. * Authorizes the selected OAUTH credential profile - no saved destination needed. */ -export function GoogleDriveOAuthButton({ credentialId, authorized }: GoogleDriveOAuthButtonProps) { +export function GoogleDriveOAuthButton({ credentialId, authorized, onAuthorized }: GoogleDriveOAuthButtonProps) { const [isLoading, setIsLoading] = useState(false); if (!credentialId) { @@ -55,6 +57,7 @@ export function GoogleDriveOAuthButton({ credentialId, authorized }: GoogleDrive async function handleAuthorize() { setIsLoading(true); try { + const res = await fetch("/api/adapters/google-drive/auth", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -64,14 +67,48 @@ export function GoogleDriveOAuthButton({ credentialId, authorized }: GoogleDrive const data = await res.json(); if (data.success && data.data?.authUrl) { - // Redirect to Google consent screen - window.location.href = data.data.authUrl; + const popup = window.open( + data.data.authUrl, + "dbackup_oauth", + "width=600,height=700,scrollbars=yes,resizable=yes" + ); + + if (!popup) { + // Popup blocked - fall back to full navigation. + window.location.href = data.data.authUrl; + return; + } + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== "oauth_complete") return; + window.removeEventListener("message", handleMessage); + clearInterval(pollClosed); + setIsLoading(false); + if (event.data.status === "success") { + toast.success(event.data.message); + onAuthorized?.(); + } else { + toast.error(event.data.message); + } + }; + + window.addEventListener("message", handleMessage); + + // Fallback: detect when the popup is closed without a message. + const pollClosed = setInterval(() => { + if (popup.closed) { + clearInterval(pollClosed); + window.removeEventListener("message", handleMessage); + setIsLoading(false); + } + }, 500); } else { toast.error(data.error || "Failed to start authorization"); + setIsLoading(false); } } catch { toast.error("Failed to start Google authorization"); - } finally { setIsLoading(false); } } diff --git a/src/components/adapter/oauth-toast-handler.tsx b/src/components/adapter/oauth-toast-handler.tsx index 205be613..d49b439f 100644 --- a/src/components/adapter/oauth-toast-handler.tsx +++ b/src/components/adapter/oauth-toast-handler.tsx @@ -7,6 +7,9 @@ import { toast } from "sonner"; /** * Handles OAuth redirect query parameters (?oauth=success|error&message=...) * and displays a toast notification. Cleans up the URL after processing. + * + * When loaded inside a popup window (window.opener is set), it sends a + * postMessage to the opener and closes the popup instead of showing a toast. */ export function OAuthToastHandler() { const searchParams = useSearchParams(); @@ -17,6 +20,16 @@ export function OAuthToastHandler() { const message = searchParams.get("message"); if (oauthStatus && message) { + // When running in a popup, communicate back to the parent and close. + if (window.opener) { + window.opener.postMessage( + { type: "oauth_complete", status: oauthStatus, message }, + window.location.origin + ); + window.close(); + return; + } + if (oauthStatus === "success") { toast.success(message); } else if (oauthStatus === "error") { diff --git a/src/components/adapter/onedrive-oauth-button.tsx b/src/components/adapter/onedrive-oauth-button.tsx index 2ac93cf1..da6b39ca 100644 --- a/src/components/adapter/onedrive-oauth-button.tsx +++ b/src/components/adapter/onedrive-oauth-button.tsx @@ -11,13 +11,15 @@ interface OneDriveOAuthButtonProps { credentialId?: string; /** Whether the profile already has a refresh token */ authorized?: boolean; + /** Called when authorization completes successfully in the popup. */ + onAuthorized?: () => void; } /** * OAuth authorization button for OneDrive. * Authorizes the selected OAUTH credential profile - no saved destination needed. */ -export function OneDriveOAuthButton({ credentialId, authorized }: OneDriveOAuthButtonProps) { +export function OneDriveOAuthButton({ credentialId, authorized, onAuthorized }: OneDriveOAuthButtonProps) { const [isLoading, setIsLoading] = useState(false); if (!credentialId) { @@ -64,14 +66,48 @@ export function OneDriveOAuthButton({ credentialId, authorized }: OneDriveOAuthB const data = await res.json(); if (data.success && data.data?.authUrl) { - // Redirect to Microsoft consent screen - window.location.href = data.data.authUrl; + const popup = window.open( + data.data.authUrl, + "dbackup_oauth", + "width=600,height=700,scrollbars=yes,resizable=yes" + ); + + if (!popup) { + // Popup blocked - fall back to full navigation. + window.location.href = data.data.authUrl; + return; + } + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== "oauth_complete") return; + window.removeEventListener("message", handleMessage); + clearInterval(pollClosed); + setIsLoading(false); + if (event.data.status === "success") { + toast.success(event.data.message); + onAuthorized?.(); + } else { + toast.error(event.data.message); + } + }; + + window.addEventListener("message", handleMessage); + + // Fallback: detect when the popup is closed without a message. + const pollClosed = setInterval(() => { + if (popup.closed) { + clearInterval(pollClosed); + window.removeEventListener("message", handleMessage); + setIsLoading(false); + } + }, 500); } else { toast.error(data.error || "Failed to start authorization"); + setIsLoading(false); } } catch { toast.error("Failed to start Microsoft authorization"); - } finally { setIsLoading(false); } } From f36b7dc000f7dc53b893fb4d8eddfdc448401572 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 6 Jun 2026 10:49:39 +0200 Subject: [PATCH 06/15] Return adapter list DTO and expand sensitive keys Convert created/updated/cloned adapter responses to the adapter list DTO to avoid leaking sensitive fields by using toAdapterListItem in adapters POST, PUT and clone endpoints. Add legacy SSH-related keys (sshPassword, sshPrivateKey, sshPassphrase) to SENSITIVE_KEYS so they are treated as secrets. Add unit tests to ensure notification secrets (Telegram botToken and Discord webhookUrl) are not exposed and secretStatus flags are set. --- src/app/api/adapters/[id]/clone/route.ts | 3 ++- src/app/api/adapters/[id]/route.ts | 3 ++- src/app/api/adapters/route.ts | 2 +- src/lib/crypto/index.ts | 3 +++ tests/unit/lib/adapter-dto.test.ts | 30 ++++++++++++++++++++++++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/app/api/adapters/[id]/clone/route.ts b/src/app/api/adapters/[id]/clone/route.ts index d647dd8d..9b34feab 100644 --- a/src/app/api/adapters/[id]/clone/route.ts +++ b/src/app/api/adapters/[id]/clone/route.ts @@ -8,6 +8,7 @@ import { PERMISSIONS, Permission } from "@/lib/auth/permissions"; import { logger } from "@/lib/logging/logger"; import { wrapError, getErrorMessage } from "@/lib/logging/errors"; import { registerAdapters } from "@/lib/adapters"; +import { toAdapterListItem } from "@/lib/adapters/dto"; registerAdapters(); @@ -82,7 +83,7 @@ export async function POST( cloned.id ); - return NextResponse.json(cloned, { status: 201 }); + return NextResponse.json(toAdapterListItem(cloned), { status: 201 }); } catch (error: unknown) { log.error("Clone adapter error", { adapterId: params.id }, wrapError(error)); return NextResponse.json({ diff --git a/src/app/api/adapters/[id]/route.ts b/src/app/api/adapters/[id]/route.ts index 7239087a..67fdc4d4 100644 --- a/src/app/api/adapters/[id]/route.ts +++ b/src/app/api/adapters/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { encryptConfig, decryptConfig, mergeSecrets } from "@/lib/crypto"; +import { toAdapterListItem } from "@/lib/adapters/dto"; import { headers } from "next/headers"; import { auditService } from "@/services/audit-service"; import { AUDIT_ACTIONS, AUDIT_RESOURCES } from "@/lib/core/audit-types"; @@ -194,7 +195,7 @@ export async function PUT( ); } - return NextResponse.json(updatedAdapter); + return NextResponse.json(toAdapterListItem(updatedAdapter)); } catch (_error) { return NextResponse.json({ error: "Failed to update adapter" }, { status: 500 }); } diff --git a/src/app/api/adapters/route.ts b/src/app/api/adapters/route.ts index 28360903..1c1d92a4 100644 --- a/src/app/api/adapters/route.ts +++ b/src/app/api/adapters/route.ts @@ -135,7 +135,7 @@ export async function POST(req: NextRequest) { ); } - return NextResponse.json(newAdapter, { status: 201 }); + return NextResponse.json(toAdapterListItem(newAdapter), { status: 201 }); } catch (error: unknown) { log.error("Create adapter error", {}, wrapError(error)); return NextResponse.json({ error: getErrorMessage(error) || "Failed to create adapter" }, { status: 500 }); diff --git a/src/lib/crypto/index.ts b/src/lib/crypto/index.ts index a72d4dec..7231ce95 100644 --- a/src/lib/crypto/index.ts +++ b/src/lib/crypto/index.ts @@ -101,6 +101,9 @@ export const SENSITIVE_KEYS = [ 'uri', // MongoDB Connection String 'passphrase', // SSH Key Passphrase 'privateKey', // SSH Private Key + 'sshPassword', // SSH tunnel password (legacy inline SSH field) + 'sshPrivateKey', // SSH tunnel private key (legacy inline SSH field) + 'sshPassphrase', // SSH tunnel key passphrase (legacy inline SSH field) 'clientSecret', // OAuth Client Secret (Google Drive, etc.) 'refreshToken', // OAuth Refresh Token 'authHeader', // Generic Webhook Authorization header diff --git a/tests/unit/lib/adapter-dto.test.ts b/tests/unit/lib/adapter-dto.test.ts index f7c4c74c..a2a33b76 100644 --- a/tests/unit/lib/adapter-dto.test.ts +++ b/tests/unit/lib/adapter-dto.test.ts @@ -85,6 +85,36 @@ describe("toAdapterListItem (adapter list DTO)", () => { expect(cfg).toEqual({ host: "db.prod", port: 5432, username: "u" }); }); + it("never leaks a token-type notification secret (Telegram botToken)", async () => { + const { encryptConfig, toAdapterListItem } = await getModules(); + const stored = JSON.stringify( + encryptConfig({ botToken: "SECRET_BOT_TOKEN_SHOULD_NOT_LEAK", chatId: "99999" }) + ); + const dto = toAdapterListItem({ ...baseRow(stored), type: "notification", adapterId: "telegram" }); + const serialized = JSON.stringify(dto); + + expect(serialized).not.toContain("SECRET_BOT_TOKEN_SHOULD_NOT_LEAK"); + const cfg = JSON.parse(dto.config); + expect("botToken" in cfg).toBe(false); // secret key removed entirely + expect(cfg.chatId).toBe("99999"); // non-secret preserved + expect(dto.secretStatus.botToken).toBe(true); + }); + + it("never leaks a webhook-type notification secret (Discord webhookUrl)", async () => { + const { encryptConfig, toAdapterListItem } = await getModules(); + const stored = JSON.stringify( + encryptConfig({ webhookUrl: "https://discord.com/api/webhooks/SECRET_HOOK", username: "Backup Bot" }) + ); + const dto = toAdapterListItem({ ...baseRow(stored), type: "notification", adapterId: "discord" }); + const serialized = JSON.stringify(dto); + + expect(serialized).not.toContain("https://discord.com/api/webhooks/SECRET_HOOK"); + const cfg = JSON.parse(dto.config); + expect("webhookUrl" in cfg).toBe(false); // secret key removed entirely + expect(cfg.username).toBe("Backup Bot"); // non-secret preserved + expect(dto.secretStatus.webhookUrl).toBe(true); + }); + it("returns an empty config for unparseable stored config (no raw leak)", async () => { const { toAdapterListItem } = await getModules(); const dto = toAdapterListItem(baseRow("not-json")); From 7c29f1b121eb28dfcaf925f8784245a1713fdd90 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 6 Jun 2026 10:59:19 +0200 Subject: [PATCH 07/15] Add CLAUDE instruction includes; update changelog Add CLAUDE.md include files in the repo root, docs, src, src/lib/adapters, and tests to reference shared .github instruction snippets (copilot-instructions, docs.instructions, changelog.instructions, logic.instructions, ui.instructions, test.instructions). Update docs/changelog.md to add a vNEXT section documenting Docker image tags and supported platforms for the upcoming release. --- CLAUDE.md | 1 + docs/CLAUDE.md | 2 ++ docs/changelog.md | 11 +++++++++++ src/CLAUDE.md | 2 ++ src/lib/adapters/CLAUDE.md | 1 + tests/CLAUDE.md | 1 + 6 files changed, 18 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/CLAUDE.md create mode 100644 src/CLAUDE.md create mode 100644 src/lib/adapters/CLAUDE.md create mode 100644 tests/CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b21d16db --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@.github/copilot-instructions.md diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 00000000..8601095a --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1,2 @@ +@../.github/instructions/docs.instructions.md +@../.github/instructions/changelog.instructions.md diff --git a/docs/changelog.md b/docs/changelog.md index 8e3083ad..8847cc37 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,17 @@ All notable changes to DBackup are documented here. +## vNEXT +*Release: In Progress* + +### 🐳 Docker + +- **Image**: `skyfay/dbackup:vNEXT` +- **Also tagged as**: `latest`, `vNEXT` +- **CI Image**: `skyfay/dbackup:ci` +- **Platforms**: linux/amd64, linux/arm64 + + ## v2.5.1 - Security Update, Smart Recovery Improvements, and Multiple Bug Fixes *Released: June 2, 2026* diff --git a/src/CLAUDE.md b/src/CLAUDE.md new file mode 100644 index 00000000..73242990 --- /dev/null +++ b/src/CLAUDE.md @@ -0,0 +1,2 @@ +@../.github/instructions/logic.instructions.md +@../.github/instructions/ui.instructions.md diff --git a/src/lib/adapters/CLAUDE.md b/src/lib/adapters/CLAUDE.md new file mode 100644 index 00000000..b8472c89 --- /dev/null +++ b/src/lib/adapters/CLAUDE.md @@ -0,0 +1 @@ +@../../../.github/instructions/logic.instructions.md diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md new file mode 100644 index 00000000..29766729 --- /dev/null +++ b/tests/CLAUDE.md @@ -0,0 +1 @@ +@../.github/instructions/test.instructions.md From fe2cbc4cbda5e5601b12f94691f9df31a20226ba Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 6 Jun 2026 11:08:21 +0200 Subject: [PATCH 08/15] Update changelog with vNEXT security/OAuth notes Update docs/changelog.md for vNEXT to include a new security advisory (GHSA-cj5h-46h6-72wc) and describe related changes. Documents: credential profile support for WEBHOOK/OAUTH/TOKEN types, OAuth flow changes (credential assignment, popup windows, vault storage of tokens), adapter-side secret redaction via safe DTOs and added SENSITIVE_KEYS, mergeSecrets behavior to preserve existing secrets, UI placeholders for saved secret fields, and new unit/audit tests for secret handling. --- docs/changelog.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 8847cc37..19f5ef8b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,32 @@ All notable changes to DBackup are documented here. ## vNEXT *Release: In Progress* +> 🔒 **Security Update:** This release fixes a security vulnerability in DBackup's own code ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)). Update as soon as possible. + +### ✨ Features + +- **credentials**: Credential profiles now support `WEBHOOK` (Discord, Slack, Teams, Generic Webhook), `OAUTH` (Dropbox, Google Drive, OneDrive), and `TOKEN` (Twilio) types, allowing notification and storage secrets to be stored in the vault and resolved server-side. +- **OAuth**: OAuth authorization flows (Dropbox, Google Drive, OneDrive) now require an assigned credential profile. Tokens are stored in the vault and the credential picker refreshes automatically after authorization. +- **OAuth**: Authorization dialogs now open in a popup window instead of redirecting the current page. + +### 🔒 Security + +- **adapters**: All adapter endpoints (list, create, update, clone) now return a safe DTO that strips all sensitive fields and replaces them with a `secretStatus` map - decrypted secrets are no longer serialized to API responses. ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)) +- **adapters**: Notification webhook URLs and tokens (`webhookUrl`, `botToken`, `authToken`, `authHeader`, `appToken`, `accessToken`) and SSH keys (`sshPassword`, `sshPrivateKey`, `sshPassphrase`) added to `SENSITIVE_KEYS` and redacted in all DTO and strip operations. +- **OAuth**: Refresh tokens for Dropbox, Google Drive, and OneDrive are now stored exclusively in credential profiles instead of adapter configs. +- **adapters**: Adapter update (PUT) now preserves existing secrets via `mergeSecrets` - re-saving an adapter form without changing secret fields no longer overwrites stored credentials with empty values. + +### 🎨 Improvements + +- **adapters**: Secret fields in the adapter form show a "saved - leave blank to keep" placeholder when a value is already stored. +- **credentials**: Credential profile dialog extended to support creating `WEBHOOK` and `OAUTH` profile types. + +### 🧪 Tests + +- **adapters**: Added audit tests verifying no sensitive fields are returned by any adapter API endpoint. +- **adapters**: Added DTO unit tests verifying that notification secrets (Telegram `botToken`, Discord `webhookUrl`) are redacted and `secretStatus` flags are set correctly. +- **crypto**: Added unit tests for `stripSecrets`, `mergeSecrets`, and `getSecretStatus`. + ### 🐳 Docker - **Image**: `skyfay/dbackup:vNEXT` From 61e00060ea038dd8f92d5d90b998d23d5c0ea3ca Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 6 Jun 2026 11:22:42 +0200 Subject: [PATCH 09/15] Respect primaryOptional when validating credentials Only require a primary credential if the adapter defines a primary credential and does not mark it as optional. Previously any adapter with a `primary` credential field was treated as requiring a primary credential, which could incorrectly flag configs as missing credentials; now `primaryOptional` prevents that enforcement. --- src/lib/server/startup-checks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/startup-checks.ts b/src/lib/server/startup-checks.ts index c24716e1..aec684b0 100644 --- a/src/lib/server/startup-checks.ts +++ b/src/lib/server/startup-checks.ts @@ -40,7 +40,7 @@ export async function validateAdapterCredentials(): Promise { for (const cfg of configs) { const adapter = registry.get(cfg.adapterId); - const requiresPrimary = adapter?.credentials?.primary !== undefined; + const requiresPrimary = adapter?.credentials?.primary !== undefined && !adapter?.credentials?.primaryOptional; const isMissing = requiresPrimary && !cfg.primaryCredentialId; if (isMissing) { From e38b15fbc33af44620f332fddb791d72a6ebd0b8 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 6 Jun 2026 11:27:47 +0200 Subject: [PATCH 10/15] Credit contributor in changelog security entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an acknowledgment to the Security section of the changelog: appended “Thanks @YHalo-wyh” to the two adapter-related bullets about safe DTOs and sensitive key redaction. This documents the contributor for the GHSA-cj5h-46h6-72wc advisory changes. --- docs/changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 19f5ef8b..ca9a8ce5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,8 +15,8 @@ All notable changes to DBackup are documented here. ### 🔒 Security -- **adapters**: All adapter endpoints (list, create, update, clone) now return a safe DTO that strips all sensitive fields and replaces them with a `secretStatus` map - decrypted secrets are no longer serialized to API responses. ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)) -- **adapters**: Notification webhook URLs and tokens (`webhookUrl`, `botToken`, `authToken`, `authHeader`, `appToken`, `accessToken`) and SSH keys (`sshPassword`, `sshPrivateKey`, `sshPassphrase`) added to `SENSITIVE_KEYS` and redacted in all DTO and strip operations. +- **adapters**: All adapter endpoints (list, create, update, clone) now return a safe DTO that strips all sensitive fields and replaces them with a `secretStatus` map - decrypted secrets are no longer serialized to API responses. Thanks @YHalo-wyh ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)) +- **adapters**: Notification webhook URLs and tokens (`webhookUrl`, `botToken`, `authToken`, `authHeader`, `appToken`, `accessToken`) and SSH keys (`sshPassword`, `sshPrivateKey`, `sshPassphrase`) added to `SENSITIVE_KEYS` and redacted in all DTO and strip operations. Thanks @YHalo-wyh ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)) - **OAuth**: Refresh tokens for Dropbox, Google Drive, and OneDrive are now stored exclusively in credential profiles instead of adapter configs. - **adapters**: Adapter update (PUT) now preserves existing secrets via `mergeSecrets` - re-saving an adapter form without changing secret fields no longer overwrites stored credentials with empty values. From 77e80d7320bbd03656f68fc8aa01e522a992aad0 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 6 Jun 2026 11:34:47 +0200 Subject: [PATCH 11/15] Changelog: require Vault profiles for OAuth/Tokens Add a breaking-change note to the changelog: OAuth storage destinations (Dropbox, Google Drive, OneDrive) and token-based notification channels (Discord, Slack, Teams, Generic Webhook, Twilio) no longer store secrets inline and now require Vault credential profiles. Instructs users to create matching OAUTH, WEBHOOK, or TOKEN profiles and assign them via the adapter edit form; adapters without an assigned profile will fail connection tests and backup/notification jobs until migrated. --- docs/changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index ca9a8ce5..0ec42034 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,8 @@ All notable changes to DBackup are documented here. > 🔒 **Security Update:** This release fixes a security vulnerability in DBackup's own code ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)). Update as soon as possible. +> ⚠️ **Breaking:** OAuth storage destinations (Dropbox, Google Drive, OneDrive) and token-based notification channels (Discord, Slack, Teams, Generic Webhook, Twilio) no longer store secrets inline - they require a Vault credential profile to function. After updating, create a matching `OAUTH`, `WEBHOOK`, or `TOKEN` profile in the Security Vault and assign it to each affected adapter via the edit form. Adapters without an assigned profile will fail connection tests and backup/notification jobs until migrated. + ### ✨ Features - **credentials**: Credential profiles now support `WEBHOOK` (Discord, Slack, Teams, Generic Webhook), `OAUTH` (Dropbox, Google Drive, OneDrive), and `TOKEN` (Twilio) types, allowing notification and storage secrets to be stored in the vault and resolved server-side. From 5168bf79f8a8d7c6a8ab45f2bb3d5954a85bc819 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 6 Jun 2026 11:51:58 +0200 Subject: [PATCH 12/15] Auto-switch to raw key when no vault profiles When the encryption key resolution dialog opens, attempt to fetch vault profiles and default to the profile tab. If no profiles are returned or the fetch fails, automatically switch the dialog to the raw key tab. The change maps and stores fetched profiles, sets activeTab appropriately on open/error, and updates the changelog to mention the new behavior. --- docs/changelog.md | 1 + .../common/encryption-key-resolution-dialog.tsx | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 0ec42034..75d73996 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -26,6 +26,7 @@ All notable changes to DBackup are documented here. - **adapters**: Secret fields in the adapter form show a "saved - leave blank to keep" placeholder when a value is already stored. - **credentials**: Credential profile dialog extended to support creating `WEBHOOK` and `OAUTH` profile types. +- **encryption**: The key resolution dialog now automatically switches to the raw key tab when no vault profiles are available. ### 🧪 Tests diff --git a/src/components/common/encryption-key-resolution-dialog.tsx b/src/components/common/encryption-key-resolution-dialog.tsx index 5a691430..54e69f21 100644 --- a/src/components/common/encryption-key-resolution-dialog.tsx +++ b/src/components/common/encryption-key-resolution-dialog.tsx @@ -46,14 +46,19 @@ export function EncryptionKeyResolutionDialog({ const [rawKeyError, setRawKeyError] = useState(""); const [activeTab, setActiveTab] = useState<"profile" | "rawKey">("profile"); - // Fetch profiles when dialog opens + // Fetch profiles when dialog opens; auto-switch to raw key tab if vault is empty useEffect(() => { if (!open) return; + setActiveTab("profile"); getEncryptionProfiles().then((res) => { if (res.success && res.data) { - setProfiles(res.data.map((p: { id: string; name: string }) => ({ id: p.id, name: p.name }))); + const mapped = res.data.map((p: { id: string; name: string }) => ({ id: p.id, name: p.name })); + setProfiles(mapped); + if (mapped.length === 0) setActiveTab("rawKey"); + } else { + setActiveTab("rawKey"); } - }).catch(() => {}); + }).catch(() => { setActiveTab("rawKey"); }); }, [open]); const handleConfirm = () => { From d0918d099b06f17168d1b830d8f3b5aacfa9ad15 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 6 Jun 2026 11:58:51 +0200 Subject: [PATCH 13/15] Redact SMB passwords in errors/logs Add a sanitizeSmbError helper that masks the SMB password in Error.message and stack, and apply it across the SMB adapter (upload, download, list, delete, connection check, and directory listing) so passwords are not leaked in logs, thrown errors, or onLog callbacks. Also update the changelog to document that SMB passwords are now redacted from error messages and logs. --- docs/changelog.md | 1 + src/lib/adapters/storage/smb.ts | 33 +++++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 75d73996..c136578f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -17,6 +17,7 @@ All notable changes to DBackup are documented here. ### 🔒 Security +- **SMB**: Passwords are now redacted from error messages and logs when SMB connections fail. - **adapters**: All adapter endpoints (list, create, update, clone) now return a safe DTO that strips all sensitive fields and replaces them with a `secretStatus` map - decrypted secrets are no longer serialized to API responses. Thanks @YHalo-wyh ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)) - **adapters**: Notification webhook URLs and tokens (`webhookUrl`, `botToken`, `authToken`, `authHeader`, `appToken`, `accessToken`) and SSH keys (`sshPassword`, `sshPrivateKey`, `sshPassphrase`) added to `SENSITIVE_KEYS` and redacted in all DTO and strip operations. Thanks @YHalo-wyh ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)) - **OAuth**: Refresh tokens for Dropbox, Google Drive, and OneDrive are now stored exclusively in credential profiles instead of adapter configs. diff --git a/src/lib/adapters/storage/smb.ts b/src/lib/adapters/storage/smb.ts index baf580eb..24c39ea4 100644 --- a/src/lib/adapters/storage/smb.ts +++ b/src/lib/adapters/storage/smb.ts @@ -10,6 +10,15 @@ import { wrapError } from "@/lib/logging/errors"; const log = logger.child({ adapter: "smb" }); +function sanitizeSmbError(error: unknown, password?: string): Error { + const original = error instanceof Error ? error : new Error(String(error)); + if (!password) return original; + const sanitized = original.message.split(password).join("****"); + const out = new Error(sanitized); + out.stack = original.stack?.split(password).join("****"); + return out; +} + interface SMBConfig { address: string; username: string; @@ -93,8 +102,9 @@ async function performSmbUpload( if (onLog) onLog("SMB upload completed successfully", "info", "storage"); return true; } catch (error: unknown) { - log.error("SMB upload failed", { address: config.address, remotePath }, wrapError(error)); - if (onLog && error instanceof Error) onLog(`SMB upload failed: ${error.message}`, "error", "storage", error.stack); + const safe = sanitizeSmbError(error, config.password); + log.error("SMB upload failed", { address: config.address, remotePath }, wrapError(safe)); + if (onLog) onLog(`SMB upload failed: ${safe.message}`, "error", "storage", safe.stack); return false; } } @@ -133,8 +143,9 @@ export const SMBAdapter: StorageAdapter = { await client.getFile(source, localPath); return true; } catch (error: unknown) { - log.error("SMB download failed", { address: config.address, remotePath }, wrapError(error)); - if (onLog && error instanceof Error) onLog(`SMB download failed: ${error.message}`, "error", "storage", error.stack); + const safe = sanitizeSmbError(error, config.password); + log.error("SMB download failed", { address: config.address, remotePath }, wrapError(safe)); + if (onLog) onLog(`SMB download failed: ${safe.message}`, "error", "storage", safe.stack); return false; } }, @@ -180,7 +191,7 @@ export const SMBAdapter: StorageAdapter = { } catch (error: unknown) { // Root directory listing failure means the share is unreachable or inaccessible. // Propagate to trigger the DB fallback in the stats cache. - if (currentDir === startDir) throw error; + if (currentDir === startDir) throw sanitizeSmbError(error, config.password); // Sub-directory listing failure (e.g. permission denied on one folder): skip silently. return; } @@ -216,8 +227,9 @@ export const SMBAdapter: StorageAdapter = { await walk(startDir); return files; } catch (error: unknown) { - log.error("SMB list failed", { address: config.address, dir }, wrapError(error)); - throw error; + const safe = sanitizeSmbError(error, config.password); + log.error("SMB list failed", { address: config.address, dir }, wrapError(safe)); + throw safe; } }, @@ -229,7 +241,8 @@ export const SMBAdapter: StorageAdapter = { await client.deleteFile(target); return true; } catch (error: unknown) { - log.error("SMB delete failed", { address: config.address, remotePath }, wrapError(error)); + const safe = sanitizeSmbError(error, config.password); + log.error("SMB delete failed", { address: config.address, remotePath }, wrapError(safe)); return false; } }, @@ -262,8 +275,8 @@ export const SMBAdapter: StorageAdapter = { return { success: true, message: "Connection successful (Write/Delete verified)" }; } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { success: false, message: `SMB Connection failed: ${message}` }; + const safe = sanitizeSmbError(error, config.password); + return { success: false, message: `SMB Connection failed: ${safe.message}` }; } finally { // Always attempt remote cleanup. This guards against the edge case where // sendFile partially succeeded (file exists on the SMB share) but then From 844e26388a1a09538e29a3ce8338dfe3cc283630 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 6 Jun 2026 12:05:37 +0200 Subject: [PATCH 14/15] Fix secret-disclosure tests and dialog tab reset Update tests and UI behavior to match current secret-redaction logic and fix a lint issue. - tests: Adjust secret-disclosure unit tests to expect sensitive config keys to be deleted (undefined) instead of blank strings; remove an unused beforeEach import. - ui: Defer resetting the active tab in EncryptionKeyResolutionDialog until after async getEncryptionProfiles() completes and profiles are mapped, selecting "rawKey" only when no profiles exist. - docs: Add changelog entries describing the test fixes and the lint/behavior fix in the encryption dialog. --- .../encryption-key-resolution-dialog.tsx | 3 +-- .../adapters-route-secret-disclosure.test.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/components/common/encryption-key-resolution-dialog.tsx b/src/components/common/encryption-key-resolution-dialog.tsx index 54e69f21..5e749690 100644 --- a/src/components/common/encryption-key-resolution-dialog.tsx +++ b/src/components/common/encryption-key-resolution-dialog.tsx @@ -49,12 +49,11 @@ export function EncryptionKeyResolutionDialog({ // Fetch profiles when dialog opens; auto-switch to raw key tab if vault is empty useEffect(() => { if (!open) return; - setActiveTab("profile"); getEncryptionProfiles().then((res) => { if (res.success && res.data) { const mapped = res.data.map((p: { id: string; name: string }) => ({ id: p.id, name: p.name })); setProfiles(mapped); - if (mapped.length === 0) setActiveTab("rawKey"); + setActiveTab(mapped.length === 0 ? "rawKey" : "profile"); } else { setActiveTab("rawKey"); } diff --git a/tests/unit/lib/adapters-route-secret-disclosure.test.ts b/tests/unit/lib/adapters-route-secret-disclosure.test.ts index 3b2bf5ce..11aa0d50 100644 --- a/tests/unit/lib/adapters-route-secret-disclosure.test.ts +++ b/tests/unit/lib/adapters-route-secret-disclosure.test.ts @@ -4,7 +4,7 @@ * Verifies that GET /api/adapters never returns decrypted values for fields * in SENSITIVE_KEYS to callers who only have read/view permission. */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { NextRequest } from "next/server"; // ── Encryption key for AES-256-GCM (64 hex chars = 32 bytes) ───────────────── @@ -103,8 +103,8 @@ describe("GET /api/adapters – secret disclosure regression", () => { expect(config.bucket).toBe("my-bucket"); expect(config.endpoint).toBe("https://s3.example.com"); - expect(config.accessKeyId).toBe(""); - expect(config.secretAccessKey).toBe(""); + expect(config.accessKeyId).toBeUndefined(); + expect(config.secretAccessKey).toBeUndefined(); }); it("does not return decrypted clientSecret / refreshToken for OAuth storage adapters", async () => { @@ -126,8 +126,8 @@ describe("GET /api/adapters – secret disclosure regression", () => { expect(config.clientId).toBe("my-client-id"); expect(config.folderId).toBe("root"); - expect(config.clientSecret).toBe(""); - expect(config.refreshToken).toBe(""); + expect(config.clientSecret).toBeUndefined(); + expect(config.refreshToken).toBeUndefined(); }); it("does not return decrypted password / privateKey for database adapters", async () => { @@ -149,8 +149,8 @@ describe("GET /api/adapters – secret disclosure regression", () => { expect(config.host).toBe("db.internal"); expect(config.user).toBe("dbuser"); - expect(config.password).toBe(""); - expect(config.privateKey).toBe(""); + expect(config.password).toBeUndefined(); + expect(config.privateKey).toBeUndefined(); }); it("does not return decrypted webhookUrl / token for notification adapters", async () => { @@ -170,8 +170,8 @@ describe("GET /api/adapters – secret disclosure regression", () => { const config = JSON.parse(body[0].config); expect(config.channel).toBe("#alerts"); - expect(config.webhookUrl).toBe(""); - expect(config.token).toBe(""); + expect(config.webhookUrl).toBeUndefined(); + expect(config.token).toBeUndefined(); }); it("returns 400 when type parameter is missing", async () => { From a702bf3fe89e3d8ca55feb7d9c98ae38e3028956 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 6 Jun 2026 12:07:14 +0200 Subject: [PATCH 15/15] Bump project version to 2.6.0 and update changelog Release prep for v2.6.0: update version strings in package.json, docs/package.json, and both api-docs/public OpenAPI YAML files; update docs/changelog.md to announce v2.6.0 (Released: June 6, 2026) with security advisory reference (GHSA-cj5h-46h6-72wc) and updated Docker image tags. --- api-docs/openapi.yaml | 2 +- docs/changelog.md | 8 ++++---- docs/package.json | 2 +- package.json | 2 +- public/openapi.yaml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api-docs/openapi.yaml b/api-docs/openapi.yaml index dae40dd9..3ae76fd9 100644 --- a/api-docs/openapi.yaml +++ b/api-docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: DBackup API - version: 2.5.1 + version: 2.6.0 description: | REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention. diff --git a/docs/changelog.md b/docs/changelog.md index c136578f..3cdf097d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,8 +2,8 @@ All notable changes to DBackup are documented here. -## vNEXT -*Release: In Progress* +## v2.6.0 - Security Update, Vault Credential Profiles, OAuth Improvements, and Multiple Bug Fixes +*Released: June 6, 2026* > 🔒 **Security Update:** This release fixes a security vulnerability in DBackup's own code ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)). Update as soon as possible. @@ -37,8 +37,8 @@ All notable changes to DBackup are documented here. ### 🐳 Docker -- **Image**: `skyfay/dbackup:vNEXT` -- **Also tagged as**: `latest`, `vNEXT` +- **Image**: `skyfay/dbackup:v2.6.0` +- **Also tagged as**: `latest`, `v2` - **CI Image**: `skyfay/dbackup:ci` - **Platforms**: linux/amd64, linux/arm64 diff --git a/docs/package.json b/docs/package.json index 28251c8b..d2f3906c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "dbackup-docs", - "version": "2.5.1", + "version": "2.6.0", "private": true, "scripts": { "dev": "vitepress dev", diff --git a/package.json b/package.json index e6554090..5d1b5c49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dbackup", - "version": "2.5.1", + "version": "2.6.0", "private": true, "scripts": { "dev": "next dev", diff --git a/public/openapi.yaml b/public/openapi.yaml index 4f4048e9..ced1abba 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: DBackup API - version: 2.5.1 + version: 2.6.0 description: | REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention.