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/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/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..3cdf097d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,47 @@ All notable changes to DBackup are documented here. +## 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. + +> โš ๏ธ **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. +- **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 + +- **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. +- **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. +- **encryption**: The key resolution dialog now automatically switches to the raw key tab when no vault profiles are available. + +### ๐Ÿงช 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:v2.6.0` +- **Also tagged as**: `latest`, `v2` +- **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/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. 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/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 d290e0e3..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 } from "@/lib/crypto"; +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"; @@ -111,7 +112,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 +152,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) } : {}), @@ -180,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/dropbox/auth/route.ts b/src/app/api/adapters/dropbox/auth/route.ts index 32a00cfc..40a266c6 100644 --- a/src/app/api/adapters/dropbox/auth/route.ts +++ b/src/app/api/adapters/dropbox/auth/route.ts @@ -1,10 +1,10 @@ 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 { 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" }); @@ -12,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()); @@ -21,25 +22,16 @@ 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 }); - } - - const config = decryptConfig(JSON.parse(adapterConfig.config)); + const profile = (await getDecryptedCredentialData(credentialId, "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,14 +40,14 @@ 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, }); 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 @@ -63,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 dd74bb5b..6dbeb84c 100644 --- a/src/app/api/adapters/dropbox/callback/route.ts +++ b/src/app/api/adapters/dropbox/callback/route.ts @@ -1,6 +1,6 @@ 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"; @@ -10,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; @@ -26,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"); @@ -46,18 +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.")}` - ); - } - - const config = decryptConfig(JSON.parse(adapterConfig.config)); + // `state` is the OAUTH credential profile id. + const profile = (await getDecryptedCredentialData(state, "OAUTH")) as OAuthData; const redirectUri = `${origin}/api/adapters/dropbox/callback`; @@ -71,8 +61,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, }), }); @@ -88,28 +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.")}` ); } - // 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. + 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 9a825e89..1453b916 100644 --- a/src/app/api/adapters/google-drive/auth/route.ts +++ b/src/app/api/adapters/google-drive/auth/route.ts @@ -1,10 +1,10 @@ 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 { 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" }); @@ -17,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()); @@ -26,25 +30,17 @@ 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 }); - } - - const config = decryptConfig(JSON.parse(adapterConfig.config)); + // clientId + clientSecret come from the OAUTH credential profile. + const profile = (await getDecryptedCredentialData(credentialId, "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 +49,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 ); @@ -62,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 72c08093..8e7dd317 100644 --- a/src/app/api/adapters/google-drive/callback/route.ts +++ b/src/app/api/adapters/google-drive/callback/route.ts @@ -1,7 +1,7 @@ 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"; @@ -11,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) { @@ -27,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 @@ -46,24 +47,14 @@ 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.")}` - ); - } - - const config = decryptConfig(JSON.parse(adapterConfig.config)); + // `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`; const oauth2Client = new google.auth.OAuth2( - config.clientId, - config.clientSecret, + profile.clientId, + profile.clientSecret, redirectUri ); @@ -71,28 +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.")}` ); } - // 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. + 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 c52ebdba..67834fd3 100644 --- a/src/app/api/adapters/onedrive/auth/route.ts +++ b/src/app/api/adapters/onedrive/auth/route.ts @@ -1,9 +1,9 @@ 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 { 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" }); @@ -17,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()); @@ -26,26 +27,16 @@ 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 }); - } - - 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 }); + const profile = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData; + if (!profile.clientId) { + return NextResponse.json({ error: "Client ID is required" }, { status: 400 }); } // Build callback URL from the request origin @@ -53,15 +44,15 @@ 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(" ")); 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 d478af07..cec05ac9 100644 --- a/src/app/api/adapters/onedrive/callback/route.ts +++ b/src/app/api/adapters/onedrive/callback/route.ts @@ -1,6 +1,6 @@ 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"; @@ -10,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; @@ -26,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"); @@ -46,18 +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.")}` - ); - } - - const config = decryptConfig(JSON.parse(adapterConfig.config)); + // `state` is the OAUTH credential profile id. + const profile = (await getDecryptedCredentialData(state, "OAUTH")) as OAuthData; const redirectUri = `${origin}/api/adapters/onedrive/callback`; @@ -70,8 +60,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", }), @@ -88,28 +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.")}` ); } - // 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. + 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/adapters/route.ts b/src/app/api/adapters/route.ts index fcf1582c..1c1d92a4 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 } 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(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 }); @@ -153,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/app/api/system/filesystem/dropbox/route.ts b/src/app/api/system/filesystem/dropbox/route.ts index 81f50ba4..c4f776db 100644 --- a/src/app/api/system/filesystem/dropbox/route.ts +++ b/src/app/api/system/filesystem/dropbox/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { Dropbox } from "dropbox"; import { checkPermission } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; +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"; @@ -12,19 +14,28 @@ const log = logger.child({ route: "system/filesystem/dropbox" }); * Browse Dropbox folders for the folder picker. * * Body: { - * config: { clientId, clientSecret, refreshToken }, - * 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 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 { credentialId, folderPath } = body; + + if (!credentialId) { + return NextResponse.json({ success: false, error: "Missing credentialId" }, { status: 400 }); + } + + 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 3ba1c1d7..4a84f6b1 100644 --- a/src/app/api/system/filesystem/google-drive/route.ts +++ b/src/app/api/system/filesystem/google-drive/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { google } from "googleapis"; import { checkPermission } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; +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"; @@ -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 }, - * folderId?: string // Folder to list (undefined = root) + * credentialId: string, // OAUTH credential profile id + * folderId?: string // Folder to list (undefined = root) * } * + * 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 }] } } * @@ -23,10 +28,16 @@ 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 { credentialId, folderId } = body; + + if (!credentialId) { + return NextResponse.json({ success: false, error: "Missing credentialId" }, { status: 400 }); + } + + 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 3f335bb6..a5843adb 100644 --- a/src/app/api/system/filesystem/onedrive/route.ts +++ b/src/app/api/system/filesystem/onedrive/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { Client } from "@microsoft/microsoft-graph-client"; import { checkPermission } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; +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"; @@ -15,19 +17,28 @@ const TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; * Browse OneDrive folders for the folder picker. * * Body: { - * config: { clientId, clientSecret, refreshToken }, - * 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 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 { credentialId, folderPath } = body; + + if (!credentialId) { + return NextResponse.json({ success: false, error: "Missing credentialId" }, { status: 400 }); + } + + const config = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData; 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..32ab3cd2 100644 --- a/src/components/adapter/credential-picker.tsx +++ b/src/components/adapter/credential-picker.tsx @@ -35,6 +35,10 @@ 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; + /** Increment to trigger a profiles re-fetch (e.g. after OAuth completes). */ + refreshKey?: number; } const TYPE_BADGE: Record = { @@ -43,6 +47,8 @@ const TYPE_BADGE: Record = { ACCESS_KEY: "Access Key", TOKEN: "Token", SMTP: "SMTP", + WEBHOOK: "Webhook", + OAUTH: "OAuth", }; export function CredentialPicker({ @@ -52,6 +58,8 @@ export function CredentialPicker({ onChange, label, description, + onSelectedProfile, + refreshKey, }: Props) { const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); @@ -78,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)]); @@ -86,6 +94,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 788d25fe..e805fef5 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; - }; + /** OAUTH credential profile id; credentials are resolved server-side from it. */ + credentialId: string; initialPath?: string; } @@ -41,7 +38,7 @@ export function DropboxFolderBrowser({ open, onOpenChange, onSelect, - config, + credentialId, 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({ 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..811aafd7 100644 --- a/src/components/adapter/dropbox-oauth-button.tsx +++ b/src/components/adapter/dropbox-oauth-button.tsx @@ -7,31 +7,33 @@ 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; + /** Called when authorization completes successfully in the popup. */ + onAuthorized?: () => void; } /** * 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, onAuthorized }: 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,20 +60,54 @@ 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(); 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 794c4a66..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, @@ -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,9 @@ function PrimaryCredentialPickerSlot({ adapter, primaryCredentialId, onPrimaryChange, -}: { adapter: AdapterDefinition } & CredentialPickerHostProps) { + onSelectedProfile, + refreshKey, +}: { adapter: AdapterDefinition; onSelectedProfile?: (p: CredentialProfileSummary | null) => void; refreshKey?: number } & CredentialPickerHostProps) { const required = adapter.credentials?.primary; if (!required || !onPrimaryChange) return null; return ( @@ -76,6 +79,8 @@ function PrimaryCredentialPickerSlot({ value={primaryCredentialId ?? null} onChange={onPrimaryChange} label="Credential Profile" + onSelectedProfile={onSelectedProfile} + refreshKey={refreshKey} /> ); } @@ -372,7 +377,7 @@ export function DatabaseFormContent({ onPrimaryChange={onPrimaryChange} /> @@ -509,7 +514,7 @@ function SshAwareTabLayout({ onPrimaryChange={onPrimaryChange} /> @@ -552,7 +557,7 @@ function SshAwareTabLayout({ onPrimaryChange={onPrimaryChange} /> @@ -658,7 +663,7 @@ function SshConfigSection({ adapter, sshAuthType, sshCredentialId, description } export function StorageFormContent({ adapter, - initialData, + initialData: _initialData, healthNotificationsDisabled, onHealthNotificationsDisabledChange, primaryCredentialId, @@ -676,30 +681,20 @@ 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 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; + // 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; - // Watch full config for Google Drive folder browser - const config = watch("config"); + // 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 ( @@ -715,6 +710,8 @@ export function StorageFormContent({ adapter={adapter} primaryCredentialId={primaryCredentialId} onPrimaryChange={onPrimaryChange} + onSelectedProfile={setSelectedProfile} + refreshKey={credentialRefreshKey} /> {(adapter.id === 'sftp' || adapter.id === 'rsync') ? (
@@ -738,29 +735,23 @@ export function StorageFormContent({ )}
) : isGoogleDrive ? ( -
- - -
+ ) : isDropbox ? ( -
- - -
+ ) : isOneDrive ? ( -
- - -
+ ) : ( )} @@ -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, + authorized, + credentialId, }: { adapter: AdapterDefinition; - config: Record; - hasRefreshToken: boolean; + authorized: boolean; + credentialId?: 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 = authorized && !!credentialId; 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!, - }} + credentialId={credentialId!} initialFolderId={folderId || undefined} /> )} @@ -955,19 +942,18 @@ function GoogleDriveFolderField({ */ function DropboxFolderField({ adapter: _adapter, - config, - hasRefreshToken, + authorized, + credentialId, }: { adapter: AdapterDefinition; - config: Record; - hasRefreshToken: boolean; + authorized: boolean; + credentialId?: 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 = authorized && !!credentialId; 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!, - }} + credentialId={credentialId!} initialPath={folderPath || undefined} /> )} @@ -1023,19 +1005,18 @@ function DropboxFolderField({ */ function OneDriveFolderField({ adapter: _adapter, - config, - hasRefreshToken, + authorized, + credentialId, }: { adapter: AdapterDefinition; - config: Record; - hasRefreshToken: boolean; + authorized: boolean; + credentialId?: 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 = authorized && !!credentialId; 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!, - }} + credentialId={credentialId!} 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..64f9896c 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; - }; + /** OAUTH credential profile id; credentials are resolved server-side from it. */ + credentialId: string; initialFolderId?: string; } @@ -41,7 +38,7 @@ export function GoogleDriveFolderBrowser({ open, onOpenChange, onSelect, - config, + credentialId, 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({ 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..1419d828 100644 --- a/src/components/adapter/google-drive-oauth-button.tsx +++ b/src/components/adapter/google-drive-oauth-button.tsx @@ -7,31 +7,33 @@ 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; + /** Called when authorization completes successfully in the popup. */ + onAuthorized?: () => void; } /** * 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, onAuthorized }: 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 ( @@ -55,23 +57,58 @@ export function GoogleDriveOAuthButton({ adapterId, hasRefreshToken }: GoogleDri async function handleAuthorize() { setIsLoading(true); try { + 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(); 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-folder-browser.tsx b/src/components/adapter/onedrive-folder-browser.tsx index 8ab74295..a5afff11 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; - }; + /** OAUTH credential profile id; credentials are resolved server-side from it. */ + credentialId: string; initialPath?: string; } @@ -41,7 +38,7 @@ export function OneDriveFolderBrowser({ open, onOpenChange, onSelect, - config, + credentialId, 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({ 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..da6b39ca 100644 --- a/src/components/adapter/onedrive-oauth-button.tsx +++ b/src/components/adapter/onedrive-oauth-button.tsx @@ -7,31 +7,33 @@ 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; + /** Called when authorization completes successfully in the popup. */ + onAuthorized?: () => void; } /** * 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, onAuthorized }: 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,20 +60,54 @@ 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(); 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); } } 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/common/encryption-key-resolution-dialog.tsx b/src/components/common/encryption-key-resolution-dialog.tsx index 5a691430..5e749690 100644 --- a/src/components/common/encryption-key-resolution-dialog.tsx +++ b/src/components/common/encryption-key-resolution-dialog.tsx @@ -46,14 +46,18 @@ 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; 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); + setActiveTab(mapped.length === 0 ? "rawKey" : "profile"); + } else { + setActiveTab("rawKey"); } - }).catch(() => {}); + }).catch(() => { setActiveTab("rawKey"); }); }, [open]); const handleConfirm = () => { diff --git a/src/components/settings/credential-profile-dialog.tsx b/src/components/settings/credential-profile-dialog.tsx index 95c5e472..c1a3a9c5 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 { @@ -48,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 { @@ -68,6 +74,8 @@ const DEFAULTS: Record = { ACCESS_KEY: { accessKeyId: "", secretAccessKey: "" }, TOKEN: { token: "" }, SMTP: { user: "", password: "" }, + WEBHOOK: { url: "", authHeader: "" }, + OAUTH: { clientId: "", clientSecret: "" }, }; export function CredentialProfileDialog({ @@ -362,6 +370,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/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/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/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 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..d2b647fe 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). @@ -102,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/lib/crypto/index.ts b/src/lib/crypto/index.ts index a29dc8eb..7231ce95 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', @@ -101,8 +101,17 @@ 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 + 'accountSid', // Twilio Account SID + 'authToken', // Twilio Auth Token + 'appToken', // Gotify application token + 'botToken', // Telegram bot token + 'accessToken', // ntfy access token ]; @@ -177,3 +186,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/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) { 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 { + 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..a2a33b76 --- /dev/null +++ b/tests/unit/lib/adapter-dto.test.ts @@ -0,0 +1,124 @@ +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("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")); + expect(dto.config).toBe("{}"); + expect(dto.secretStatus).toEqual({}); + }); +}); 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..11aa0d50 --- /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, 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).toBeUndefined(); + expect(config.secretAccessKey).toBeUndefined(); + }); + + 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).toBeUndefined(); + expect(config.refreshToken).toBeUndefined(); + }); + + 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).toBeUndefined(); + expect(config.privateKey).toBeUndefined(); + }); + + 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).toBeUndefined(); + expect(config.token).toBeUndefined(); + }); + + 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); + }); +}); 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", () => {