From eb45d1e5a9d0b48c77ef5ca406adf0ce3ad4fd36 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 15 Jun 2026 15:58:40 +1000 Subject: [PATCH 01/40] fix: reconcile billing when stripe subscription is missing (#2988) Catch resource_missing in the subscription route, return null so the billing page still loads, and fire a sync to converge the stale row. --- .../enterprise-router/get-subscription.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/trpc/server/enterprise-router/get-subscription.ts b/packages/trpc/server/enterprise-router/get-subscription.ts index 9c7ffb4ea..f3368d792 100644 --- a/packages/trpc/server/enterprise-router/get-subscription.ts +++ b/packages/trpc/server/enterprise-router/get-subscription.ts @@ -1,7 +1,14 @@ import { getInternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans'; import { getSubscription } from '@documenso/ee/server-only/stripe/get-subscription'; +import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { Stripe } from '@documenso/lib/server-only/stripe'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import type { Logger } from 'pino'; import { authenticatedProcedure } from '../trpc'; import { ZGetSubscriptionRequestSchema } from './get-subscription.types'; @@ -26,9 +33,17 @@ export const getSubscriptionRoute = authenticatedProcedure } const [subscription, plans] = await Promise.all([ + // If the subscription is not found or there's an error, we return null to + // avoid failing the entire request. getSubscription({ organisationId, userId, + }).catch(async (e) => { + ctx.logger.error(`Failed to get subscription for organisation ${organisationId}`, e); + + await reconcileMissingStripeSubscription({ logger: ctx.logger, organisationId, userId, error: e }); + + return null; }), getInternalClaimPlans(), ]); @@ -38,3 +53,51 @@ export const getSubscriptionRoute = authenticatedProcedure plans, }; }); + +type ReconcileMissingStripeSubscriptionOptions = { + logger: Logger; + organisationId: string; + userId: number; + error: unknown; +}; + +/** + * When the Stripe subscription no longer exists (e.g. deleted by Stripe's + * test-mode retention policy, or removed manually), fire-and-forget a reconcile + * so the stale local subscription row and any billing banner converge on the + * next load. Reconcile failures must never break the read path that calls this. + */ +const reconcileMissingStripeSubscription = async ({ + logger, + organisationId, + userId, + error, +}: ReconcileMissingStripeSubscriptionOptions) => { + if (!(error instanceof Stripe.errors.StripeInvalidRequestError) || error.code !== 'resource_missing') { + return; + } + + const organisation = await prisma.organisation.findFirst({ + where: buildOrganisationWhereQuery({ + organisationId, + userId, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + select: { + customerId: true, + }, + }); + + if (!organisation?.customerId) { + return; + } + + void syncStripeCustomerSubscription({ + customerId: organisation.customerId, + }).catch((syncError) => { + logger.error( + `Failed to reconcile subscription after resource_missing for organisation ${organisationId}`, + syncError, + ); + }); +}; From 15549a675895384c74cb4b85365e50d0707fdabe Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 15 Jun 2026 16:02:18 +1000 Subject: [PATCH 02/40] fix: add early quota warning (#2986) ## Description - Add banners when plans near fair use limits - Automated email alerts to our support when nearing fair use limits --- .../organisation-quota-banner.tsx | 70 ++++++-- .../organisation-quota-banner.spec.ts | 169 ++++++++++++++++++ .../templates/organisation-limit-alert.tsx | 152 ++++++++++++++++ .../templates/organisation-limit-exceeded.tsx | 109 ----------- packages/lib/jobs/client.ts | 4 +- ...organisation-limit-alert-email.handler.ts} | 72 +++++--- .../send-organisation-limit-alert-email.ts | 34 ++++ .../send-organisation-limit-exceeded-email.ts | 34 ---- .../rate-limit/check-monthly-quota.ts | 31 ++-- .../rate-limit/compute-quota-flags.ts | 30 +++- .../rate-limit/get-quota-alert-kind.ts | 40 +++++ .../get-organisation-quota-flags.types.ts | 3 + 12 files changed, 550 insertions(+), 198 deletions(-) create mode 100644 packages/app-tests/e2e/organisations/organisation-quota-banner.spec.ts create mode 100644 packages/email/templates/organisation-limit-alert.tsx delete mode 100644 packages/email/templates/organisation-limit-exceeded.tsx rename packages/lib/jobs/definitions/emails/{send-organisation-limit-exceeded-email.handler.ts => send-organisation-limit-alert-email.handler.ts} (51%) create mode 100644 packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.ts delete mode 100644 packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.ts create mode 100644 packages/lib/server-only/rate-limit/get-quota-alert-kind.ts diff --git a/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx b/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx index c25f23bb3..b2a5e1a93 100644 --- a/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx +++ b/apps/remix/app/components/general/organisations/organisation-quota-banner.tsx @@ -3,6 +3,7 @@ import { SUPPORT_EMAIL } from '@documenso/lib/constants/app'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -38,12 +39,18 @@ export const OrganisationQuotaBanner = () => { quotaFlags?.isDocumentQuotaExceeded || quotaFlags?.isEmailQuotaExceeded || quotaFlags?.isApiQuotaExceeded, ); - // Every member of the organisation sees the banner when a quota is exhausted. + const isAnyQuotaNearing = Boolean( + quotaFlags?.isDocumentQuotaNearing || quotaFlags?.isEmailQuotaNearing || quotaFlags?.isApiQuotaNearing, + ); + + // Every member of the organisation sees the banner when a quota is exhausted or + // nearing its limit. When both states apply, "exceeded" wins for the banner copy + // and the dialog lists both exceeded and nearing items. // Note: Skipping free plan banner for now because their quota can incorrectly show as exceeded. if ( !organisation || !quotaFlags || - !isAnyQuotaExceeded || + (!isAnyQuotaExceeded && !isAnyQuotaNearing) || organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE ) { return null; @@ -51,17 +58,29 @@ export const OrganisationQuotaBanner = () => { return ( <> -
+
- Your organisation has exceeded a fair use limit + {isAnyQuotaExceeded ? ( + Your organisation has exceeded a fair use limit + ) : ( + Your organisation is approaching a fair use limit + )}
+
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx b/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx new file mode 100644 index 000000000..1aa091b35 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx @@ -0,0 +1,105 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Trans } from '@lingui/react/macro'; +import { AlertTriangleIcon, Loader2Icon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +export type CscRecipientSigningInProgressPageProps = { + sessionId: string; + recipientToken: string; +}; + +/** + * Rendered when the credential-scope OAuth callback has attached a SAD to the + * server-side `CscSession` and set the `csc_sad_session` cookie. The page + * auto-fires `enterprise.csc.signEnvelope` on mount and navigates to the + * completion page on success. On failure, it surfaces an error message and + * a retry CTA pointing at a fresh credential-scope OAuth round-trip. + */ +export const CscRecipientSigningInProgressPage = ({ + sessionId, + recipientToken, +}: CscRecipientSigningInProgressPageProps) => { + const { mutateAsync: signEnvelope } = trpc.enterprise.csc.signEnvelope.useMutation(); + + const [error, setError] = useState(null); + + // Ref rather than state for the fire-once guard. Refs mutate synchronously, + // so React StrictMode's double-invoke of the effect sees the updated value + // on the second pass and short-circuits. A useState guard would still let + // the second effect fire because the queued setState from the first run + // hasn't been committed yet when the second one reads it — that double-fire + // races two signEnvelope calls; whichever loses sees the SAD already + // consumed and flashes "Signing failed" before the winning call's + // navigation kicks in. + const hasFiredRef = useRef(false); + + useEffect(() => { + if (hasFiredRef.current) { + return; + } + + hasFiredRef.current = true; + + const run = async () => { + try { + await signEnvelope({ sessionId, recipientToken }); + + window.location.href = `/sign/${recipientToken}/complete`; + } catch (err) { + const parsed = AppError.parseError(err); + setError(parsed.code || AppErrorCode.UNKNOWN_ERROR); + } + }; + + void run(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const retryUrl = `/api/csc/oauth/authorize?scope=credential&session=${encodeURIComponent(sessionId)}`; + + return ( +
+ {error ? ( + <> + + +

+ Signing failed +

+ +

+ {error === AppErrorCode.CSC_TSP_TIMEOUT ? ( + The signing provider did not respond in time. Please retry. + ) : error === AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN ? ( + + Your signing authorisation expired before the signature could be applied. Please reauthorise to retry. + + ) : ( + Something went wrong while applying your signature. Please retry. + )} +

+ + + + ) : ( + <> + + +

+ Applying your signature +

+ +

+ Please don't close this tab. The signing provider is finalising your signature. +

+ + )} +
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index 3eea3753e..1979c63a2 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -27,7 +27,6 @@ import type { Field } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { useMemo, useState } from 'react'; -import { useNavigate } from 'react-router'; import { match, P } from 'ts-pattern'; import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover'; @@ -84,7 +83,6 @@ export const DocumentSigningPageViewV1 = ({ ? authUser.twoFactorEnabled && authUser.email === recipient.email : false; - const navigate = useNavigate(); const analytics = useAnalytics(); const [selectedSignerId, setSelectedSignerId] = useState(allRecipients?.[0]?.id); @@ -129,7 +127,7 @@ export const DocumentSigningPageViewV1 = ({ if (documentMeta?.redirectUrl) { window.location.href = documentMeta.redirectUrl; } else { - await navigate(`/sign/${recipient.token}/complete`); + window.location.href = `/sign/${recipient.token}/complete`; } }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx index 102238613..ba70ad048 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx @@ -17,7 +17,7 @@ import { msg } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { useNavigate, useSearchParams } from 'react-router'; +import { useSearchParams } from 'react-router'; import { z } from 'zod'; const ZRejectDocumentFormSchema = z.object({ @@ -41,7 +41,6 @@ export function DocumentSigningRejectDialog({ }: DocumentSigningRejectDialogProps) { const { t } = useLingui(); const { toast } = useToast(); - const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [isOpen, setIsOpen] = useState(false); @@ -74,7 +73,7 @@ export function DocumentSigningRejectDialog({ if (onRejected) { await onRejected(reason); } else { - await navigate(`/sign/${token}/rejected`); + window.location.href = `/sign/${token}/rejected`; } } catch (err) { toast({ diff --git a/apps/remix/app/components/general/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx index 9eb96e768..730ae630b 100644 --- a/apps/remix/app/components/general/document/document-page-view-button.tsx +++ b/apps/remix/app/components/general/document/document-page-view-button.tsx @@ -38,7 +38,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps }) .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( )) .with({ isComplete: false }, () => ( diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx index c62db2f11..2035e65b8 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx @@ -89,7 +89,7 @@ export const EnvelopeSignerCompleteDialog = () => { recipientDetails?: { name: string; email: string }, ) => { try { - await completeDocument({ + const result = await completeDocument({ token: recipient.token, documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), accessAuthOptions, @@ -97,6 +97,15 @@ export const EnvelopeSignerCompleteDialog = () => { ...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}), }); + // TSP envelopes can't be completed via the SES path; the mutation returns + // a credential-scope OAuth URL the recipient must follow to acquire a SAD + // before the sync sign mutation can run. Short-circuit here so the + // analytics / completion handlers don't run with a still-unsigned doc. + if (result.status === 'REDIRECT') { + window.location.href = result.redirectUrl; + return; + } + analytics.capture('App: Recipient has completed signing', { signerId: recipient.id, documentId: envelope.id, @@ -119,7 +128,7 @@ export const EnvelopeSignerCompleteDialog = () => { if (envelope.documentMeta.redirectUrl) { window.location.href = envelope.documentMeta.redirectUrl; } else { - await navigate(`/sign/${recipient.token}/complete`); + window.location.href = `/sign/${recipient.token}/complete`; } } catch (err) { const error = AppError.parseError(err); @@ -197,7 +206,7 @@ export const EnvelopeSignerCompleteDialog = () => { if (redirectUrl) { window.location.href = redirectUrl; } else { - await navigate(`/sign/${token}/complete`); + window.location.href = `/sign/${token}/complete`; } } catch (err) { console.log('err', err); diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx index a6e9a9edd..000b46780 100644 --- a/apps/remix/app/components/tables/documents-table-action-button.tsx +++ b/apps/remix/app/components/tables/documents-table-action-button.tsx @@ -66,7 +66,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr )) .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( )) .with({ isPending: true, isSigned: true }, () => ( diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx index 1555d54a2..1d09e2900 100644 --- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx @@ -105,7 +105,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT recipient?.role !== RecipientRole.CC && recipient?.role !== RecipientRole.ASSISTANT && ( - + {recipient?.role === RecipientRole.VIEWER && ( <> @@ -126,7 +126,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT Approve )} - + )} diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx index d0d6a9e21..f958da66d 100644 --- a/apps/remix/app/components/tables/inbox-table.tsx +++ b/apps/remix/app/components/tables/inbox-table.tsx @@ -17,7 +17,7 @@ import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } fr import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react'; import { DateTime } from 'luxon'; import { useMemo, useTransition } from 'react'; -import { Link, useSearchParams } from 'react-router'; +import { useSearchParams } from 'react-router'; import { match } from 'ts-pattern'; import { DocumentStatus } from '~/components/general/document/document-status'; @@ -200,7 +200,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) => }) .with({ isPending: true, isSigned: false }, () => ( )) .with({ isPending: true, isSigned: true }, () => ( diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx index e28947361..d35313fd8 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx @@ -1,7 +1,14 @@ import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { + buildClearCscBlockingErrorCookieHeader, + readCscBlockingErrorFromRequest, +} from '@documenso/ee/server-only/signing/csc/cookies/blocking-error-cookie'; +import { readCscSadSessionFromRequest } from '@documenso/ee/server-only/signing/csc/cookies/sad-session-cookie'; +import { readCscServiceSessionFromRequest } from '@documenso/ee/server-only/signing/csc/cookies/service-session-cookie'; import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; +import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; @@ -18,6 +25,7 @@ import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; +import { isTspEnvelope } from '@documenso/lib/types/signature-level'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { isRecipientExpired } from '@documenso/lib/utils/recipients'; import { prisma } from '@documenso/prisma'; @@ -30,6 +38,8 @@ import { getOptionalLoaderContext } from 'server/utils/get-loader-session'; import { match } from 'ts-pattern'; import { Header as AuthenticatedHeader } from '~/components/general/app-header'; +import { CscRecipientBlockedPage } from '~/components/general/document-signing/csc-recipient-blocked-page'; +import { CscRecipientSigningInProgressPage } from '~/components/general/document-signing/csc-recipient-signing-in-progress-page'; import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page'; import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider'; import { DocumentSigningPageViewV1 } from '~/components/general/document-signing/document-signing-page-view-v1'; @@ -257,6 +267,58 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => { recipientAccessAuth: derivedRecipientAccessAuth, }).catch(() => null); + // CSC / TSP routing. TSP envelopes have three terminal recipient-page + // states beyond the normal signing UI: + // 1. `blocked` — service-scope OAuth returned a hard error (set by the + // callback as a one-shot `csc_blocking_error` cookie). + // 2. `signing-in-progress` — credential-scope OAuth completed, SAD is + // attached server-side, page auto-fires the sync sign mutation. + // 3. pre-auth — no service token yet, kick the recipient into + // service-scope OAuth. + // The fourth state (service session valid, no SAD, no blocking error) falls + // through to the normal signing UI. + if (IS_INSTANCE_CSC_MODE() && isTspEnvelope(envelope)) { + const blockingError = await readCscBlockingErrorFromRequest(request); + + if (blockingError && blockingError.recipientToken === token) { + return { + isDocumentAccessValid: true, + envelopeForSigning, + csc: { state: 'blocked', code: blockingError.code } as const, + responseHeaders: { 'Set-Cookie': buildClearCscBlockingErrorCookieHeader() }, + } as const; + } + + const sadSessionId = await readCscSadSessionFromRequest(request); + + if (sadSessionId) { + const cscSession = await prisma.cscSession.findUnique({ + where: { id: sadSessionId }, + }); + + const isSadSessionValid = + cscSession !== null && + cscSession.recipientId === recipient.id && + cscSession.encryptedSad !== null && + cscSession.sadExpiresAt !== null && + cscSession.sadExpiresAt > new Date(); + + if (isSadSessionValid) { + return { + isDocumentAccessValid: true, + envelopeForSigning, + csc: { state: 'signing-in-progress', sessionId: sadSessionId } as const, + } as const; + } + } + + const serviceSessionToken = await readCscServiceSessionFromRequest(request); + + if (serviceSessionToken !== token) { + throw redirect(`/api/csc/oauth/authorize?scope=service&token=${encodeURIComponent(token)}`); + } + } + return { isDocumentAccessValid: true, envelopeForSigning, @@ -296,11 +358,22 @@ export async function loader(loaderArgs: Route.LoaderArgs) { if (foundRecipient.envelope.internalVersion === 2) { const payloadV2 = await handleV2Loader(loaderArgs); - return superLoaderJson({ - version: 2, - payload: payloadV2, - branding, - } as const); + // V2 payload may carry a one-shot `Set-Cookie` header (used to clear the + // CSC blocking-error cookie after the loader reads it). Forward it via + // the `superLoaderJson` response init so the browser actually applies the + // header. The field stays on the payload — it's just a `Max-Age=0` clear + // directive, not sensitive — and isn't read by any consumer. + const responseHeaders = + 'responseHeaders' in payloadV2 && payloadV2.responseHeaders ? payloadV2.responseHeaders : undefined; + + return superLoaderJson( + { + version: 2, + payload: payloadV2, + branding, + } as const, + responseHeaders ? { headers: responseHeaders } : undefined, + ); } const payloadV1 = await handleV1Loader(loaderArgs); @@ -430,6 +503,19 @@ const SigningPageV2 = ({ data }: { data: Awaited; } + if ('csc' in data && data.csc?.state === 'blocked') { + return ; + } + + if ('csc' in data && data.csc?.state === 'signing-in-progress') { + return ( + + ); + } + const { envelope, recipientSignature, recipient } = data.envelopeForSigning; if (envelope.deletedAt || envelope.status === DocumentStatus.REJECTED) { diff --git a/apps/remix/server/router.ts b/apps/remix/server/router.ts index 72e847ae0..19747d4b2 100644 --- a/apps/remix/server/router.ts +++ b/apps/remix/server/router.ts @@ -1,5 +1,6 @@ import { tsRestHonoApp } from '@documenso/api/hono'; import { auth } from '@documenso/auth/server'; +import { csc } from '@documenso/ee/server-only/signing/csc/hono'; import { jobsClient } from '@documenso/lib/jobs/client'; import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware'; @@ -111,6 +112,9 @@ app.route('/api/files', filesRoute); app.use('/api/ai/*', aiRateLimitMiddleware); app.route('/api/ai', aiRoute); +// CSC OAuth routes (mounted from @documenso/ee). +app.route('/api/csc', csc); + // API servers. app.route('/api/v1', tsRestHonoApp); app.use('/api/jobs/*', jobsClient.getApiHandler()); diff --git a/package-lock.json b/package-lock.json index ca4111950..2b85538ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "dependencies": { "@ai-sdk/google-vertex": "3.0.81", "@documenso/prisma": "*", - "@libpdf/core": "^0.3.6", + "@libpdf/core": "^0.4.0", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "@marsidev/react-turnstile": "^1.5.0", @@ -4661,16 +4661,16 @@ "license": "MIT" }, "node_modules/@libpdf/core": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.3.6.tgz", - "integrity": "sha512-VzRUXaDq+M9qrroKiipCgePK2mwKM3M6DY7B0yfXnxD4aYnUxD/nUtkcsHCBUUnJpkX9rWikdEhYa5vU8ZlReg==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.0.tgz", + "integrity": "sha512-G9nZRjf9DGDJaS/C23YWogk8akPM7O/6HfMslxVsKTKRbbbb+0szpQIetcGGUGRu7KtmBDmGDWCgz//DXSmq8A==", "license": "MIT", "dependencies": { "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "asn1js": "^3.0.10", - "lru-cache": "^11.4.0", + "lru-cache": "^11.5.1", "pako": "^2.1.0", "pkijs": "^3.4.0" }, @@ -4724,9 +4724,9 @@ } }, "node_modules/@libpdf/core/node_modules/lru-cache": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", - "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -30593,6 +30593,8 @@ "@aws-sdk/client-sesv2": "^3.998.0", "@documenso/lib": "*", "@documenso/prisma": "*", + "arctic": "^3.7.0", + "hono": "^4.12.14", "luxon": "^3.7.2", "react": "^18", "ts-pattern": "^5.9.0", diff --git a/package.json b/package.json index d2a0c4b1b..e2c01cdd6 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "dependencies": { "@ai-sdk/google-vertex": "3.0.81", "@documenso/prisma": "*", - "@libpdf/core": "^0.3.6", + "@libpdf/core": "^0.4.0", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "@prisma/extension-read-replicas": "^0.4.1", diff --git a/packages/ee/package.json b/packages/ee/package.json index 52a51512f..79cc3ba85 100644 --- a/packages/ee/package.json +++ b/packages/ee/package.json @@ -16,6 +16,8 @@ "@aws-sdk/client-sesv2": "^3.998.0", "@documenso/lib": "*", "@documenso/prisma": "*", + "arctic": "^3.7.0", + "hono": "^4.12.14", "luxon": "^3.7.2", "react": "^18", "ts-pattern": "^5.9.0", diff --git a/packages/ee/server-only/signing/csc/algorithm-resolver.ts b/packages/ee/server-only/signing/csc/algorithm-resolver.ts new file mode 100644 index 000000000..184e3edde --- /dev/null +++ b/packages/ee/server-only/signing/csc/algorithm-resolver.ts @@ -0,0 +1,347 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; + +import type { TCscCredentialsInfoResponse } from './client/types'; + +/** + * CSC QES V1 algorithm policy. + * + * Single OID-to-algorithm map + single helper that: + * - validates cert state (status, validity window) → `CSC_CERT_INVALID`, + * - validates the credential's key + algorithm against the spec's policy + * table (RSA ≥2048, ECDSA P-256/384/521, SHA-256/384/512) → + * `CSC_ALGORITHM_REFUSED`, + * - resolves a concrete `(signAlgo, hashAlgo)` OID pair for §11.9. + * + * Called at the service-scope OAuth callback (validation boundary) and + * re-called at sign time as a defence-in-depth pre-check. Persisted fields + * (`keyType` / `keyLenBits` / `digestAlgorithm` / `signAlgoOid`) round-trip + * through `CscCredential`. + */ + +export type CscKeyType = 'RSA' | 'ECDSA'; + +export type CscDigest = 'SHA-256' | 'SHA-384' | 'SHA-512'; + +export type CscEcdsaCurve = 'P-256' | 'P-384' | 'P-521'; + +export type CscAlgorithmPolicy = { + keyType: CscKeyType; + keyLenBits: number; + digestAlgorithm: CscDigest; + /** OID for `signatures/signHash.signAlgo` + persisted on `CscCredential`. */ + signAlgoOid: string; + /** OID for `signatures/signHash.hashAlgo`. */ + hashAlgoOid: string; + /** ECDSA named curve (informational; not separately persisted). */ + ecdsaCurve?: CscEcdsaCurve; +}; + +/** + * Default RSA digest when the TSP advertises only hash-agnostic RSA OIDs + * (plain `rsaEncryption` / RSASSA-PSS). SHA-256 matches the CSC §11.9 + * sample and is universally TSP-supported. + */ +const DEFAULT_RSA_DIGEST: CscDigest = 'SHA-256'; + +const HASH_OID_FOR_DIGEST: Record = { + 'SHA-256': '2.16.840.1.101.3.4.2.1', + 'SHA-384': '2.16.840.1.101.3.4.2.2', + 'SHA-512': '2.16.840.1.101.3.4.2.3', +}; + +/** + * Exposed lookup for the `signatures/signHash.hashAlgo` OID corresponding to a + * resolved {@link CscDigest}. Useful at sign time when the policy's + * `hashAlgoOid` field is not in scope (e.g. when reconstructing a + * `LibpdfSignerAlgo` from a persisted `CscCredential` row). + */ +export const hashOidForDigest = (digest: CscDigest): string => HASH_OID_FOR_DIGEST[digest]; + +const DIGEST_STRENGTH: Record = { + 'SHA-256': 256, + 'SHA-384': 384, + 'SHA-512': 512, +}; + +const STRONG_DIGEST_SET = new Set(['SHA-256', 'SHA-384', 'SHA-512']); + +type AlgoOidInfo = { family: 'RSA' | 'ECDSA'; boundDigest: CscDigest | 'SHA-1' | 'MD5' | null } | { family: 'DSA' }; + +/** + * Source-of-truth registry for `key.algo` entries (§11.5). Anything not + * listed is treated as unknown and skipped at policy evaluation. + */ +const KEY_ALGO_OID_REGISTRY: Record = { + // Hash-agnostic RSA — caller picks the hash via `hashAlgo`. + '1.2.840.113549.1.1.1': { family: 'RSA', boundDigest: null }, // rsaEncryption + '1.2.840.113549.1.1.10': { family: 'RSA', boundDigest: null }, // RSASSA-PSS + + // Hash-bound legacy RSA combos. + '1.2.840.113549.1.1.4': { family: 'RSA', boundDigest: 'MD5' }, // md5WithRSAEncryption + '1.2.840.113549.1.1.5': { family: 'RSA', boundDigest: 'SHA-1' }, // sha1WithRSAEncryption + '1.2.840.113549.1.1.11': { family: 'RSA', boundDigest: 'SHA-256' }, // sha256WithRSAEncryption + '1.2.840.113549.1.1.12': { family: 'RSA', boundDigest: 'SHA-384' }, // sha384WithRSAEncryption + '1.2.840.113549.1.1.13': { family: 'RSA', boundDigest: 'SHA-512' }, // sha512WithRSAEncryption + + // ECDSA with SHA-x (hash is always bound). + '1.2.840.10045.4.1': { family: 'ECDSA', boundDigest: 'SHA-1' }, // ecdsa-with-SHA1 + '1.2.840.10045.4.3.2': { family: 'ECDSA', boundDigest: 'SHA-256' }, + '1.2.840.10045.4.3.3': { family: 'ECDSA', boundDigest: 'SHA-384' }, + '1.2.840.10045.4.3.4': { family: 'ECDSA', boundDigest: 'SHA-512' }, + + // DSA — refused outright. + '1.2.840.10040.4.1': { family: 'DSA' }, + '1.2.840.10040.4.3': { family: 'DSA' }, // dsa-with-SHA1 +}; + +/** + * ECDSA named-curve OID registry. Policy verdict (allow/refuse) is decided + * by the resolver from the resolved curve name, not encoded here. + */ +const CURVE_OID_REGISTRY: Record = { + '1.2.840.10045.3.1.7': 'P-256', // secp256r1 + '1.3.132.0.34': 'P-384', // secp384r1 + '1.3.132.0.35': 'P-521', // secp521r1 + '1.2.840.10045.3.1.1': 'P-192', // secp192r1 + '1.3.132.0.33': 'P-224', // secp224r1 +}; + +/** + * Validate a CSC credential's cert + key/algorithm against V1 policy and + * resolve the `(signAlgo, hashAlgo)` OID pair used by `signatures/signHash`. + * + * Caller MUST fetch the credential with `certInfo: true` so `cert.validFrom` + * / `cert.validTo` are present. + * + * Throws: + * - `CSC_CERT_INVALID` for cert-state issues (status not `valid`, missing or + * malformed validity dates, current time outside the validity window). + * - `CSC_ALGORITHM_REFUSED` for key/algorithm policy failures (disabled key, + * missing `key.len`, RSA `< 2048`, ECDSA without an allowed curve, DSA, no + * acceptable digest advertised in `key.algo`). + */ +export const resolveCscAlgorithmPolicy = (credentialInfo: TCscCredentialsInfoResponse): CscAlgorithmPolicy => { + assertCertValid(credentialInfo.cert); + + if (credentialInfo.key.status !== 'enabled') { + throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, { + message: `CSC credential key status is '${credentialInfo.key.status}'.`, + }); + } + + if (credentialInfo.key.len === undefined) { + throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, { + message: 'CSC credential omits required key.len (REQUIRED per §11.5).', + }); + } + + const choice = pickAlgorithmChoice(credentialInfo.key.algo); + + if (choice.family === 'RSA') { + if (credentialInfo.key.len < 2048) { + throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, { + message: `CSC RSA credential keyLen ${credentialInfo.key.len} < 2048.`, + }); + } + + return { + keyType: 'RSA', + keyLenBits: credentialInfo.key.len, + digestAlgorithm: choice.digest, + signAlgoOid: choice.signAlgoOid, + hashAlgoOid: HASH_OID_FOR_DIGEST[choice.digest], + }; + } + + const curve = resolveEcdsaCurve(credentialInfo.key.curve); + + return { + keyType: 'ECDSA', + keyLenBits: credentialInfo.key.len, + digestAlgorithm: choice.digest, + signAlgoOid: choice.signAlgoOid, + hashAlgoOid: HASH_OID_FOR_DIGEST[choice.digest], + ecdsaCurve: curve, + }; +}; + +type AlgorithmChoice = { + family: 'RSA' | 'ECDSA'; + signAlgoOid: string; + digest: CscDigest; +}; + +/** + * Iterate the TSP's advertised `key.algo` OIDs, drop the policy-refused + * entries, and pick the strongest survivor. + * + * Precedence: ECDSA before RSA (smaller signatures, modern); within each + * family, strongest advertised digest first. Hash-agnostic RSA OIDs pair + * with {@link DEFAULT_RSA_DIGEST}. + */ +const pickAlgorithmChoice = (algoOids: readonly string[]): AlgorithmChoice => { + const candidates: AlgorithmChoice[] = []; + + for (const oid of algoOids) { + const info = KEY_ALGO_OID_REGISTRY[oid]; + + if (info === undefined) { + // Unknown OID — another entry in `key.algo` may still be acceptable. + continue; + } + + if (info.family === 'DSA') { + continue; + } + + if (info.boundDigest === null) { + candidates.push({ + family: info.family, + signAlgoOid: oid, + digest: DEFAULT_RSA_DIGEST, + }); + continue; + } + + if (STRONG_DIGEST_SET.has(info.boundDigest)) { + candidates.push({ + family: info.family, + signAlgoOid: oid, + digest: info.boundDigest as CscDigest, + }); + } + } + + if (candidates.length === 0) { + throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, { + message: `CSC credential advertises no policy-compliant key.algo OIDs (got: ${algoOids.join(', ') || ''}).`, + }); + } + + candidates.sort((a, b) => { + if (a.family !== b.family) { + return a.family === 'ECDSA' ? -1 : 1; + } + + return DIGEST_STRENGTH[b.digest] - DIGEST_STRENGTH[a.digest]; + }); + + return candidates[0]; +}; + +const resolveEcdsaCurve = (curveOid: string | undefined): CscEcdsaCurve => { + if (curveOid === undefined || curveOid === '') { + throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, { + message: 'CSC ECDSA credential omits required key.curve.', + }); + } + + const named = CURVE_OID_REGISTRY[curveOid]; + + if (named === 'P-256' || named === 'P-384' || named === 'P-521') { + return named; + } + + const detail = named ? `, named=${named}` : ''; + + throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, { + message: `CSC ECDSA credential uses refused curve (oid=${curveOid}${detail}).`, + }); +}; + +const assertCertValid = (cert: TCscCredentialsInfoResponse['cert']): void => { + if (cert.status !== undefined && cert.status !== 'valid') { + throw new AppError(AppErrorCode.CSC_CERT_INVALID, { + message: `CSC credential certificate status is '${cert.status}'.`, + }); + } + + if (!cert.validFrom || !cert.validTo) { + throw new AppError(AppErrorCode.CSC_CERT_INVALID, { + message: 'CSC credential certificate omits validFrom/validTo (malformed).', + }); + } + + const validFromMs = parseGeneralizedTime(cert.validFrom); + const validToMs = parseGeneralizedTime(cert.validTo); + + if (validFromMs === null || validToMs === null) { + throw new AppError(AppErrorCode.CSC_CERT_INVALID, { + message: `CSC credential certificate validity dates are malformed (validFrom=${cert.validFrom}, validTo=${cert.validTo}).`, + }); + } + + const now = Date.now(); + + if (now < validFromMs) { + throw new AppError(AppErrorCode.CSC_CERT_INVALID, { + message: `CSC credential certificate is not yet valid (validFrom=${cert.validFrom}).`, + }); + } + + if (now > validToMs) { + throw new AppError(AppErrorCode.CSC_CERT_INVALID, { + message: `CSC credential certificate has expired (validTo=${cert.validTo}).`, + }); + } +}; + +/** + * Parse an X.509 GeneralizedTime string (`YYYYMMDDHHMMSSZ`) into epoch ms. + * Strict — returns null on any deviation from the §11.5 example format. + */ +const parseGeneralizedTime = (value: string): number | null => { + const matched = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(value); + + if (matched === null) { + return null; + } + + const [, y, mo, d, h, mi, s] = matched; + + const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)); + + return Number.isNaN(ms) ? null : ms; +}; + +/** + * Subset of libpdf's `Signer` interface fields derived from a `CscAlgorithmPolicy`. + * Used by `CscCaptureSigner` / `CscFifoSigner` to satisfy libpdf's signer + * contract without re-deriving the mapping at each call site. `keyLenBits` + * is carried through so the capture-signer can size its placeholder output + * appropriately for the chosen key. + */ +export type LibpdfSignerAlgo = { + keyType: 'RSA' | 'EC'; + signatureAlgorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA'; + digestAlgorithm: CscDigest; + keyLenBits: number; +}; + +/** + * Translate a `CscAlgorithmPolicy` (CSC §11.5 OIDs) into libpdf's `Signer` + * algorithm tuple. RSASSA-PSS is detected by the `signAlgoOid`; everything + * else maps directly from `keyType` + `digestAlgorithm`. + */ +export const policyToLibpdfSignerAlgo = (policy: CscAlgorithmPolicy): LibpdfSignerAlgo => { + if (policy.keyType === 'ECDSA') { + return { + keyType: 'EC', + signatureAlgorithm: 'ECDSA', + digestAlgorithm: policy.digestAlgorithm, + keyLenBits: policy.keyLenBits, + }; + } + + // RSA — distinguish PKCS1-v1.5 from PSS by the resolved sign-algo OID. + // RSASSA-PSS OID: '1.2.840.113549.1.1.10'. + const signatureAlgorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' = + policy.signAlgoOid === '1.2.840.113549.1.1.10' ? 'RSA-PSS' : 'RSASSA-PKCS1-v1_5'; + + return { + keyType: 'RSA', + signatureAlgorithm, + digestAlgorithm: policy.digestAlgorithm, + keyLenBits: policy.keyLenBits, + }; +}; diff --git a/packages/ee/server-only/signing/csc/cert-chain.ts b/packages/ee/server-only/signing/csc/cert-chain.ts new file mode 100644 index 000000000..1967fdf1f --- /dev/null +++ b/packages/ee/server-only/signing/csc/cert-chain.ts @@ -0,0 +1,122 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; + +/** + * Length-prefixed X.509 chain for `CscCredential.certCache`. Schema column is + * `Bytes`; this gives a self-describing binary that round-trips without + * base64 inflation. Format: u32 BE cert count, then per-cert u32 BE byte + * length + raw DER bytes. + * + * Encoding inputs come from `cscCredentialsInfo.cert.certificates`, which the + * CSC §11.5 spec defines as an array of base64-encoded DER X.509 certificates + * (leaf-first). The encoder decodes each base64 entry once at persistence + * time; the decoder is the symmetric inverse used at sign time. + * + * Pure functions, no I/O. + */ + +const BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; + +/** + * Encode a leaf-first chain of base64-encoded DER certs into the + * length-prefixed binary form persisted on `CscCredential.certCache`. + * + * Throws `INVALID_REQUEST` when the input is empty or any entry is not valid + * base64. + */ +export const encodeCscCertChain = (certs: string[]): Uint8Array => { + if (certs.length === 0) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC certificate chain encoding requires at least one certificate.', + }); + } + + const derBuffers: Uint8Array[] = []; + let totalDerBytes = 0; + + for (const entry of certs) { + if (entry.length === 0 || !BASE64_REGEX.test(entry)) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC certificate chain entry is not valid base64.', + }); + } + + const der = Buffer.from(entry, 'base64'); + + if (der.length === 0) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC certificate chain entry decoded to zero bytes.', + }); + } + + derBuffers.push(der); + totalDerBytes += der.length; + } + + // 4 bytes for the count + 4 bytes per-cert length prefix + raw DER bytes. + const totalLength = 4 + derBuffers.length * 4 + totalDerBytes; + const out = new Uint8Array(totalLength); + const view = new DataView(out.buffer, out.byteOffset, out.byteLength); + + view.setUint32(0, derBuffers.length, false); + + let offset = 4; + + for (const der of derBuffers) { + view.setUint32(offset, der.length, false); + offset += 4; + out.set(der, offset); + offset += der.length; + } + + return out; +}; + +/** + * Decode a length-prefixed cert chain back into an array of DER cert byte + * arrays. Inverse of {@link encodeCscCertChain}. + * + * Throws `INVALID_REQUEST` when the buffer is truncated or any per-cert + * length prefix runs off the end of the buffer. + */ +export const decodeCscCertChain = (bytes: Uint8Array): Uint8Array[] => { + if (bytes.byteLength < 4) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC certificate chain buffer is too short to contain a count prefix.', + }); + } + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const count = view.getUint32(0, false); + + const result: Uint8Array[] = []; + let offset = 4; + + for (let i = 0; i < count; i++) { + if (offset + 4 > bytes.byteLength) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC certificate chain buffer truncated at length prefix.', + }); + } + + const length = view.getUint32(offset, false); + offset += 4; + + if (length === 0 || offset + length > bytes.byteLength) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC certificate chain buffer truncated within certificate body.', + }); + } + + // Slice copies the underlying bytes so callers can't mutate the source. + result.push(bytes.slice(offset, offset + length)); + offset += length; + } + + if (offset !== bytes.byteLength) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC certificate chain buffer has trailing bytes after declared chain end.', + }); + } + + return result; +}; diff --git a/packages/ee/server-only/signing/csc/ciphers.ts b/packages/ee/server-only/signing/csc/ciphers.ts new file mode 100644 index 000000000..7bb58f26e --- /dev/null +++ b/packages/ee/server-only/signing/csc/ciphers.ts @@ -0,0 +1,51 @@ +import { symmetricDecrypt, symmetricEncrypt } from '@documenso/lib/universal/crypto'; +import { requireEnv } from '@documenso/lib/utils/env'; +import { bytesToHex, hexToBytes } from '@noble/ciphers/utils'; + +/** + * Bytes-based wrappers around {@link symmetricEncrypt} / {@link symmetricDecrypt} + * for the two CSC secrets stored on Prisma `Bytes` columns: + * + * - `CscCredential.serviceTokenCiphertext` — service-scope OAuth access token. + * - `CscSession.encryptedSad` — credential-scope SAD. + * + * Both use the primary `DOCUMENSO_ENCRYPTION_KEY` (same key family as 2FA + * secrets, OIDC client secrets, DKIM private keys). The underlying cipher + * returns hex; we round-trip through `bytesToHex` / `hexToBytes` so the + * persisted bytes are the raw XChaCha20-Poly1305 ciphertext (nonce + tag + + * payload), not a hex-string-as-bytes inflation. + */ + +/** + * Encrypt a CSC plaintext secret (service token or SAD) for persistence. + * Throws `MISSING_ENV_VAR` on missing encryption key — encryption can't + * otherwise fail. + */ +export const encryptCscToken = (plaintext: string): Uint8Array => { + const key = requireEnv('NEXT_PRIVATE_ENCRYPTION_KEY'); + + const hex = symmetricEncrypt({ key, data: plaintext }); + + return hexToBytes(hex); +}; + +/** + * Decrypt a CSC ciphertext back to its UTF-8 plaintext. Returns `null` on + * any cipher-level failure (key rotation, payload tamper, row corruption) + * so the caller can map to a domain-appropriate AppError — typically + * re-auth for service tokens, `CSC_SAD_EXPIRED_PRE_SIGN` for SADs. + * + * A missing key throws (config error, must surface loudly) and is *not* + * folded into the null return. + */ +export const decryptCscToken = (ciphertext: Uint8Array): string | null => { + const key = requireEnv('NEXT_PRIVATE_ENCRYPTION_KEY'); + + try { + const buf = symmetricDecrypt({ key, data: bytesToHex(ciphertext) }); + + return Buffer.from(buf).toString('utf-8'); + } catch { + return null; + } +}; diff --git a/packages/ee/server-only/signing/csc/client/credentials.ts b/packages/ee/server-only/signing/csc/client/credentials.ts new file mode 100644 index 000000000..ae4c5b7d0 --- /dev/null +++ b/packages/ee/server-only/signing/csc/client/credentials.ts @@ -0,0 +1,122 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; + +import { cscJsonPost, joinCscUrl } from './http'; +import { + type TCscCredentialsInfoRequest, + type TCscCredentialsInfoResponse, + type TCscCredentialsListRequest, + type TCscCredentialsListResponse, + ZCscCredentialsInfoResponseSchema, + ZCscCredentialsListResponseSchema, +} from './types'; + +type CscCredentialsListOptions = TCscCredentialsListRequest & { + baseUrl: string; + /** Service-scope bearer token (CSC §11.4 + §11.9). */ + accessToken: string; + signal?: AbortSignal; +}; + +/** + * `credentials/list` (§11.4) — list the credentialIDs the bearer token's user + * owns at the TSP. + * + * Throws `CSC_CREDENTIAL_LIST_EMPTY` when the TSP returns a successful + * response with zero credentials — the recipient needs to enrol with the TSP + * before they can sign. Other failures throw `CSC_REQUEST_FAILED`. + * + * `userID` MUST be omitted when the service authorization is user-specific + * (true for OAuth `service` scope, which is V1's only flow). The spec rejects + * the call with `invalid_request` if both are present. + */ +export const cscCredentialsList = async (opts: CscCredentialsListOptions): Promise => { + const { baseUrl, accessToken, signal, userID, maxResults, pageToken, clientData } = opts; + + const body: Record = {}; + + if (userID !== undefined) { + body.userID = userID; + } + + if (maxResults !== undefined) { + body.maxResults = maxResults; + } + + if (pageToken !== undefined) { + body.pageToken = pageToken; + } + + if (clientData !== undefined) { + body.clientData = clientData; + } + + const response = await cscJsonPost( + { + url: joinCscUrl({ baseUrl, path: 'credentials/list' }), + body, + accessToken, + signal, + }, + ZCscCredentialsListResponseSchema, + ); + + if (response.credentialIDs.length === 0) { + throw new AppError(AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY, { + message: + 'CSC provider returned no credentials for the authenticated user. Recipient must enrol with the TSP before signing.', + }); + } + + return response; +}; + +type CscCredentialsInfoOptions = TCscCredentialsInfoRequest & { + baseUrl: string; + /** Service-scope bearer token. */ + accessToken: string; + signal?: AbortSignal; +}; + +/** + * `credentials/info` (§11.5) — fetch credential metadata: key algorithm tuple, + * X.509 certificate chain, authorization mode, multisign capacity. + * + * Returns the parsed response verbatim. Cert validity, algorithm policy, and + * SCAL semantics are enforced by `csc/algorithm-resolver.ts` — that lives + * outside the client because it's domain logic, not transport. + */ +export const cscCredentialsInfo = async (opts: CscCredentialsInfoOptions): Promise => { + const { baseUrl, accessToken, signal, credentialID, certificates, certInfo, authInfo, lang, clientData } = opts; + + const body: Record = { credentialID }; + + if (certificates !== undefined) { + body.certificates = certificates; + } + + if (certInfo !== undefined) { + body.certInfo = certInfo; + } + + if (authInfo !== undefined) { + body.authInfo = authInfo; + } + + if (lang !== undefined) { + body.lang = lang; + } + + if (clientData !== undefined) { + body.clientData = clientData; + } + + return await cscJsonPost( + { + url: joinCscUrl({ baseUrl, path: 'credentials/info' }), + body, + accessToken, + signal, + }, + ZCscCredentialsInfoResponseSchema, + ); +}; diff --git a/packages/ee/server-only/signing/csc/client/http.ts b/packages/ee/server-only/signing/csc/client/http.ts new file mode 100644 index 000000000..631aa0471 --- /dev/null +++ b/packages/ee/server-only/signing/csc/client/http.ts @@ -0,0 +1,170 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { z } from 'zod'; + +import { ZCscErrorResponseSchema } from './types'; + +const LEADING_SLASHES_REGEX = /^\/+/; +const TRAILING_SLASHES_REGEX = /\/+$/; + +/** + * Low-level fetch wrapper for the JSON-bodied CSC API methods (§7.1 mandates + * `Content-Type: application/json` for all API requests). + * + * OAuth 2.0 endpoints (`oauth2/token`, `oauth2/revoke`) use + * `application/x-www-form-urlencoded` per RFC 6749 and are handled by the + * `arctic` library — see `oauth.ts` in this directory. + * + * Normalises CSC error responses (§10.1: `{ error, error_description }`) + * into {@link AppError}s carrying the upstream HTTP status in + * {@link AppError.statusCode}, so callers can discriminate without + * re-parsing the body. + */ + +type JoinUrlInput = { + baseUrl: string; + path: string; +}; + +/** + * Join a CSC base URL with a path segment. Strips trailing/leading slashes so + * `joinCscUrl({ baseUrl: 'https://x/csc/v1/', path: '/credentials/list' })` + * yields `https://x/csc/v1/credentials/list`. + */ +export const joinCscUrl = ({ baseUrl, path }: JoinUrlInput): string => { + const cleanBaseUrl = baseUrl.replace(TRAILING_SLASHES_REGEX, ''); // Strip trailing slashes from base URL. + const cleanPath = path.replace(LEADING_SLASHES_REGEX, ''); // Strip leading slashes from path. + + const url = new URL(cleanPath, `${cleanBaseUrl}/`); + + return url.toString(); +}; + +type CscRequestErrorOptions = { + url: string; + status: number; + cscError?: { error: string; error_description?: string }; + cause?: unknown; + errorCode?: string; +}; + +const buildCscRequestError = ({ + url, + status, + cscError, + cause, + errorCode = AppErrorCode.CSC_REQUEST_FAILED, +}: CscRequestErrorOptions): AppError => { + const causeMessage = cause instanceof Error ? cause.message : undefined; + + const parts: string[] = [`CSC request to ${url} failed (HTTP ${status})`]; + + if (cscError) { + parts.push(cscError.error_description ? `${cscError.error}: ${cscError.error_description}` : cscError.error); + } + + if (causeMessage) { + parts.push(causeMessage); + } + + return new AppError(errorCode, { + message: parts.join(' — '), + statusCode: status, + }); +}; + +/** + * Best-effort parse of a CSC error body. Returns `undefined` on non-JSON or + * schema mismatch so the caller still surfaces the HTTP status without + * masking it. + */ +const readCscErrorBody = async ( + response: Response, +): Promise<{ error: string; error_description?: string } | undefined> => { + try { + const json = await response.json(); + const parsed = ZCscErrorResponseSchema.safeParse(json); + + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } +}; + +type CscJsonPostOptions = { + /** Fully-qualified endpoint URL (use {@link joinCscUrl} to build it). */ + url: string; + /** Decoded JSON body; serialised via `JSON.stringify`. */ + body: Record; + /** Bearer access token. Omit for unauthenticated calls (e.g. `info`). */ + accessToken?: string; + /** Override the AppError code thrown on failure. Defaults to `CSC_REQUEST_FAILED`. */ + errorCode?: string; + /** + * Optional `AbortSignal` so callers can enforce their own deadlines + * (e.g. the 15s sign-time sync timeout). + */ + signal?: AbortSignal; +}; + +/** + * POST a JSON body to a CSC API endpoint and parse the response against the + * supplied Zod schema. + * + * Throws {@link AppError} on: + * - network/transport error (fetch threw) + * - non-2xx HTTP response (with CSC error body folded into the message) + * - malformed JSON response + * - schema validation failure + */ +export const cscJsonPost = async (opts: CscJsonPostOptions, responseSchema: z.ZodSchema): Promise => { + const { url, body, accessToken, errorCode, signal } = opts; + + let response: Response; + + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + body: JSON.stringify(body), + signal, + }); + } catch (cause) { + throw buildCscRequestError({ url, status: 0, cause, errorCode }); + } + + if (!response.ok) { + const cscError = await readCscErrorBody(response); + + throw buildCscRequestError({ + url, + status: response.status, + cscError, + errorCode, + }); + } + + let json: unknown; + + try { + json = await response.json(); + } catch (cause) { + throw buildCscRequestError({ url, status: response.status, cause, errorCode }); + } + + const parsed = responseSchema.safeParse(json); + + if (!parsed.success) { + throw buildCscRequestError({ + url, + status: response.status, + cause: parsed.error, + errorCode, + }); + } + + return parsed.data; +}; diff --git a/packages/ee/server-only/signing/csc/client/index.ts b/packages/ee/server-only/signing/csc/client/index.ts new file mode 100644 index 000000000..6e84c1a82 --- /dev/null +++ b/packages/ee/server-only/signing/csc/client/index.ts @@ -0,0 +1,32 @@ +/** + * CSC v1.0.4.0 HTTP client. Stateless function wrappers — one per endpoint, + * grouped by spec section. Bring your own base URL(s) and bearer token. + * + * Endpoint coverage (V1 scope): + * - §11.1 info → {@link cscInfo} + * - §11.4 credentials/list → {@link cscCredentialsList} + * - §11.5 credentials/info → {@link cscCredentialsInfo} + * - §11.9 signatures/signHash → {@link cscSignHash} + * - §11.10 signatures/timestamp → {@link cscTimestamp} + * - §8.3.2 oauth2/authorize → {@link buildCscServiceScopeAuthorizeUrl}, + * {@link buildCscCredentialScopeAuthorizeUrl} + * - §8.3.3 oauth2/token → {@link exchangeCscAuthorizationCode}, + * {@link refreshCscServiceToken} + * - §8.3.4 oauth2/revoke → {@link revokeCscToken} + * + * Out of scope for V1 (intentionally excluded; we use OAuth + single-sig): + * - §11.2 auth/login (HTTP Basic) + * - §11.3 auth/revoke (HTTP Basic) + * - §11.6 credentials/authorize (alternative to OAuth credential scope) + * - §11.7 credentials/extendTransaction + * - §11.8 credentials/sendOTP + * + * OAuth is delegated to `arctic` (same library `packages/auth/` uses). + */ + +export * from './credentials'; +export * from './http'; +export * from './info'; +export * from './oauth'; +export * from './signatures'; +export * from './types'; diff --git a/packages/ee/server-only/signing/csc/client/info.ts b/packages/ee/server-only/signing/csc/client/info.ts new file mode 100644 index 000000000..39195ed6b --- /dev/null +++ b/packages/ee/server-only/signing/csc/client/info.ts @@ -0,0 +1,42 @@ +import { AppErrorCode } from '@documenso/lib/errors/app-error'; + +import { cscJsonPost, joinCscUrl } from './http'; +import { type TCscInfoRequest, type TCscInfoResponse, ZCscInfoResponseSchema } from './types'; + +type CscInfoOptions = TCscInfoRequest & { + /** + * Base URI of the CSC service (e.g. `https://service.example.org/csc/v1`). + * Per §7.2, `info` is mounted relative to the service base URI; the OAuth + * base URI returned in `oauth2` is discovered from this call. + */ + baseUrl: string; + signal?: AbortSignal; +}; + +/** + * `info` (§11.1) — discovery method every CSC-conformant TSP MUST implement. + * + * Used at startup to: + * + * 1. Learn the OAuth 2.0 base URI (`oauth2`) for subsequent token / revoke + * calls. Per §11.1, this MAY differ from the API base URI. + * 2. Enumerate supported methods (`methods`) so the caller can fail fast + * when a required endpoint is absent. + * 3. Surface `signatures/timestamp` capability for the B-LTA seal step. + * + * Unauthenticated — `info` requires no bearer token. Failures throw + * `CSC_PROVIDER_INFO_FAILED` per the spec's startup-discovery error code. + */ +export const cscInfo = async (opts: CscInfoOptions): Promise => { + const { baseUrl, lang, signal } = opts; + + return await cscJsonPost( + { + url: joinCscUrl({ baseUrl, path: 'info' }), + body: lang ? { lang } : {}, + errorCode: AppErrorCode.CSC_PROVIDER_INFO_FAILED, + signal, + }, + ZCscInfoResponseSchema, + ); +}; diff --git a/packages/ee/server-only/signing/csc/client/oauth.ts b/packages/ee/server-only/signing/csc/client/oauth.ts new file mode 100644 index 000000000..7935ddc68 --- /dev/null +++ b/packages/ee/server-only/signing/csc/client/oauth.ts @@ -0,0 +1,321 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { + ArcticFetchError, + CodeChallengeMethod, + generateCodeVerifier, + generateState, + OAuth2Client, + OAuth2RequestError, + type OAuth2Tokens, + UnexpectedErrorResponseBodyError, + UnexpectedResponseError, +} from 'arctic'; + +import { joinCscUrl } from './http'; + +/** + * OAuth 2.0 surface for the CSC v1.0.4.0 protocol (§8.3.2 authorize, + * §8.3.3 token, §8.3.4 revoke). + * + * Backed by `arctic` — the same library `packages/auth/` uses for sign-in + * OAuth — so PKCE + state generation, token parsing, and revocation share a + * proven implementation. CSC-specific extension parameters (`credentialID`, + * `numSignatures`, `hash`, `description`, `account_token`, `clientData`, + * `lang` — §8.3.2) layer on top of the returned `URL` via + * `searchParams.set()`. + * + * Non-standard CSC bits arctic doesn't model directly: + * - `token_type === 'SAD'` for credential-scope responses (§8.3.3). Read from + * `tokens.tokenType()` which sources from raw `data`. + * - SAD is single-use and short-lived per spec; no refresh_token is issued + * for the credential scope. Callers SHOULD NOT call `refreshAccessToken` + * with a SAD. + * + * Re-exports `generateState` and `generateCodeVerifier` for callers that + * persist these in the OAuth-flow cookie. + */ + +export { generateCodeVerifier, generateState }; + +// ─── Client construction ───────────────────────────────────────────────────── + +type CreateCscOAuthClientOptions = { + clientId: string; + clientSecret: string; + redirectUri: string; +}; + +/** + * Construct an `OAuth2Client` bound to the CSC TSP's OAuth registration. The + * three values come from the env (`NEXT_PRIVATE_SIGNING_CSC_OAUTH_*`). + * Stateless — instantiate per request or cache at the transport singleton + * level; arctic's client carries no per-call state. + */ +export const createCscOAuthClient = ({ + clientId, + clientSecret, + redirectUri, +}: CreateCscOAuthClientOptions): OAuth2Client => { + return new OAuth2Client(clientId, clientSecret, redirectUri); +}; + +// ─── Authorize URL builders (§8.3.2) ───────────────────────────────────────── + +type AuthorizeUrlBaseOptions = { + client: OAuth2Client; + /** + * The TSP's OAuth base URI as returned by `info.oauth2` (§11.1). The + * `oauth2/authorize` path is joined on; per §8.3.2 NOTE 1 this can live on + * a different host from the API base URI. + */ + oauthBaseUrl: string; + /** Opaque CSRF token; see {@link generateState}. Caller persists it. */ + state: string; + /** PKCE verifier; see {@link generateCodeVerifier}. Caller persists it. */ + codeVerifier: string; + /** Preferred response language (§11.1 `lang` parameter). */ + lang?: string; + /** + * Arbitrary application-defined string echoed back at callback. WARNING per + * §8.3.2: this is forwarded verbatim to the TSP; never put secrets here. + */ + clientData?: string; +}; + +const applyCscAuthorizeExtras = (url: URL, opts: { lang?: string; clientData?: string }): URL => { + if (opts.lang) { + url.searchParams.set('lang', opts.lang); + } + + if (opts.clientData) { + url.searchParams.set('clientData', opts.clientData); + } + + return url; +}; + +/** + * Build the `oauth2/authorize` URL for the **service** scope. Recipient + * follows this URL to authenticate at the TSP and grant access to list + * credentials + fetch credential info. + */ +export const buildCscServiceScopeAuthorizeUrl = (opts: AuthorizeUrlBaseOptions): URL => { + const { client, oauthBaseUrl, state, codeVerifier, lang, clientData } = opts; + + const url = client.createAuthorizationURLWithPKCE( + joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/authorize' }), + state, + CodeChallengeMethod.S256, + codeVerifier, + ['service'], + ); + + return applyCscAuthorizeExtras(url, { lang, clientData }); +}; + +type CredentialScopeAuthorizeOptions = AuthorizeUrlBaseOptions & { + /** Target credential (§8.3.2 — REQUIRED for credential scope). */ + credentialId: string; + /** Number of signatures this SAD will authorise (§8.3.2 — REQUIRED). */ + numSignatures: number; + /** + * Standard-base64-encoded hash values the SAD will be bound to. REQUIRED for + * SCAL2 credentials (§8.3.2). The builder converts each value to base64url + * before joining with `,` per the spec — §8.3.2 mandates base64url for the + * `hash` URL parameter, but the rest of the codebase (and the + * `signatures/signHash` JSON body per §11.9) uses standard base64. Callers + * pass what `Buffer.from(...).toString('base64')` produces. + */ + hashes: string[]; + /** Human-readable transaction description shown on the TSP's SCA page. */ + description?: string; + /** Optional restricted-access token (JWT) some TSPs require (§8.3.2). */ + accountToken?: string; +}; + +/** + * Convert a standard-base64 string to base64url (RFC 4648 §5). The CSC §8.3.2 + * `hash` URL parameter requires base64url; TSPs reject standard base64 even + * after percent-decoding because `+`, `/`, and `=` are invalid base64url + * characters. JSON-body fields (§11.9 `signatures/signHash`) keep standard + * base64. + */ +const toBase64Url = (standardBase64: string): string => + standardBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + +/** + * Build the `oauth2/authorize` URL for the **credential** scope. The TSP + * binds the issued SAD to `hashes` so it can only sign those exact digests. + * + * Hash ordering in the SAD is independent of the order passed to + * `signatures/signHash` (§8.3.2) — the TSP matches by hash value, not + * position. + */ +export const buildCscCredentialScopeAuthorizeUrl = (opts: CredentialScopeAuthorizeOptions): URL => { + const { + client, + oauthBaseUrl, + state, + codeVerifier, + credentialId, + numSignatures, + hashes, + description, + accountToken, + lang, + clientData, + } = opts; + + const url = client.createAuthorizationURLWithPKCE( + joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/authorize' }), + state, + CodeChallengeMethod.S256, + codeVerifier, + ['credential'], + ); + + url.searchParams.set('credentialID', credentialId); + url.searchParams.set('numSignatures', String(numSignatures)); + url.searchParams.set('hash', hashes.map(toBase64Url).join(',')); + + if (description) { + url.searchParams.set('description', description); + } + + if (accountToken) { + url.searchParams.set('account_token', accountToken); + } + + return applyCscAuthorizeExtras(url, { lang, clientData }); +}; + +// ─── Token exchange (§8.3.3) ───────────────────────────────────────────────── + +type ExchangeCodeOptions = { + client: OAuth2Client; + /** OAuth base URI from `info.oauth2`. `oauth2/token` is joined on. */ + oauthBaseUrl: string; + /** Authorization code from the callback's `code` query param. */ + code: string; + /** Same PKCE verifier passed to the authorize URL builder. */ + codeVerifier: string; + signal?: AbortSignal; +}; + +/** + * Exchange an authorization code for an access token. Used for both scopes; + * the response shape differs only in `token_type`: + * + * - service scope: `token_type === 'Bearer'`, optional `refresh_token`. + * - credential scope: `token_type === 'SAD'`, single-use, no refresh_token. + * + * Inspect `tokens.tokenType()` (or `tokens.data` for raw access) to + * discriminate. + */ +export const exchangeCscAuthorizationCode = async (opts: ExchangeCodeOptions): Promise => { + const { client, oauthBaseUrl, code, codeVerifier } = opts; + + try { + return await client.validateAuthorizationCode( + joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/token' }), + code, + codeVerifier, + ); + } catch (err) { + throw mapArcticError(err, 'oauth2/token'); + } +}; + +type RefreshServiceTokenOptions = { + client: OAuth2Client; + oauthBaseUrl: string; + /** Service-scope refresh token from a prior token exchange. */ + refreshToken: string; + signal?: AbortSignal; +}; + +/** + * Refresh a service-scope access token. Credential-scope SADs are NOT + * refreshable per §8.3.3 — only service scope issues refresh tokens. + * + * Scopes passed as `['service']` to keep the refresh narrow; the TSP may + * ignore the scope parameter on refresh per RFC 6749 §6. + */ +export const refreshCscServiceToken = async (opts: RefreshServiceTokenOptions): Promise => { + const { client, oauthBaseUrl, refreshToken } = opts; + + try { + return await client.refreshAccessToken(joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/token' }), refreshToken, [ + 'service', + ]); + } catch (err) { + throw mapArcticError(err, 'oauth2/token'); + } +}; + +// ─── Revoke (§8.3.4) ───────────────────────────────────────────────────────── + +type RevokeTokenOptions = { + client: OAuth2Client; + oauthBaseUrl: string; + /** Access token or refresh token to revoke. */ + token: string; + signal?: AbortSignal; +}; + +/** + * Revoke a CSC OAuth token. Per §8.3.4, revoking a refresh token also + * invalidates every access token derived from the same grant; revoking an + * access token only invalidates that access token. + * + * `204 No Content` on success; arctic resolves the promise. Failures + * surface as `CSC_REQUEST_FAILED` via {@link mapArcticError}. + */ +export const revokeCscToken = async (opts: RevokeTokenOptions): Promise => { + const { client, oauthBaseUrl, token } = opts; + + try { + await client.revokeToken(joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/revoke' }), token); + } catch (err) { + throw mapArcticError(err, 'oauth2/revoke'); + } +}; + +// ─── Error normalisation ──────────────────────────────────────────────────── + +/** + * Translate arctic's typed exception hierarchy into AppErrors consistent with + * the rest of the CSC client (see http.ts). Preserves the HTTP status when + * arctic surfaces it. + */ +const mapArcticError = (err: unknown, endpoint: string): AppError => { + if (err instanceof OAuth2RequestError) { + return new AppError(AppErrorCode.CSC_REQUEST_FAILED, { + message: `CSC ${endpoint} rejected: ${err.code}${err.description ? ` — ${err.description}` : ''}`, + }); + } + + if (err instanceof ArcticFetchError) { + return new AppError(AppErrorCode.CSC_REQUEST_FAILED, { + message: `CSC ${endpoint} fetch failed: ${err.message}`, + }); + } + + if (err instanceof UnexpectedResponseError) { + return new AppError(AppErrorCode.CSC_REQUEST_FAILED, { + message: `CSC ${endpoint} returned unexpected HTTP ${err.status}`, + statusCode: err.status, + }); + } + + if (err instanceof UnexpectedErrorResponseBodyError) { + return new AppError(AppErrorCode.CSC_REQUEST_FAILED, { + message: `CSC ${endpoint} returned HTTP ${err.status} with unparseable body`, + statusCode: err.status, + }); + } + + return new AppError(AppErrorCode.CSC_REQUEST_FAILED, { + message: `CSC ${endpoint} failed: ${err instanceof Error ? err.message : String(err)}`, + }); +}; diff --git a/packages/ee/server-only/signing/csc/client/signatures.ts b/packages/ee/server-only/signing/csc/client/signatures.ts new file mode 100644 index 000000000..5253bc622 --- /dev/null +++ b/packages/ee/server-only/signing/csc/client/signatures.ts @@ -0,0 +1,111 @@ +import { cscJsonPost, joinCscUrl } from './http'; +import { + type TCscSignHashRequest, + type TCscSignHashResponse, + type TCscTimestampRequest, + type TCscTimestampResponse, + ZCscSignHashResponseSchema, + ZCscTimestampResponseSchema, +} from './types'; + +type CscSignHashOptions = TCscSignHashRequest & { + baseUrl: string; + /** Service-scope bearer token. The SAD (in the body) is the credential-scope grant. */ + accessToken: string; + signal?: AbortSignal; +}; + +/** + * `signatures/signHash` (§11.9) — submit one or more pre-computed hashes for + * the TSP to sign with the credential identified by `credentialID`. + * + * Authorisation is two-layered: + * - The service-scope bearer token authenticates the API call itself. + * - The credential-scope SAD (in the JSON body) authorises the specific + * hashes — the TSP rejects with `invalid_request` ("Hash is not authorized + * by the SAD") if any hash in the array wasn't bound at SAD issuance. + * + * The returned `signatures` array is position-ordered with `hash` per §11.9. + * Callers SHALL preserve order when mapping responses back to PDF embed + * slots (the fifoSigner relies on this). + */ +export const cscSignHash = async (opts: CscSignHashOptions): Promise => { + const { baseUrl, accessToken, signal, credentialID, SAD, hash, hashAlgo, signAlgo, signAlgoParams, clientData } = + opts; + + const body: Record = { + credentialID, + SAD, + hash, + signAlgo, + }; + + if (hashAlgo !== undefined) { + body.hashAlgo = hashAlgo; + } + + if (signAlgoParams !== undefined) { + body.signAlgoParams = signAlgoParams; + } + + if (clientData !== undefined) { + body.clientData = clientData; + } + + return await cscJsonPost( + { + url: joinCscUrl({ baseUrl, path: 'signatures/signHash' }), + body, + accessToken, + signal, + }, + ZCscSignHashResponseSchema, + ); +}; + +type CscTimestampOptions = TCscTimestampRequest & { + baseUrl: string; + /** + * Service-scope bearer token. Per §11.10 the timestamp endpoint may or may + * not require auth depending on TSP policy; the spec is silent. We send the + * token unconditionally because all known TSPs gate this endpoint. + */ + accessToken: string; + signal?: AbortSignal; +}; + +/** + * `signatures/timestamp` (§11.10) — request an RFC 3161 / RFC 5816 time-stamp + * token for a pre-computed hash. Driven by {@link CscTspTimestampAuthority} + * at sign time, when {@link resolveCscSignTimeTsa} selects the TSP source + * (TSP advertises `signatures/timestamp` in `info.methods`). The bearer is + * the current recipient's own service-scope token. Seal-time archival + * timestamps do not go through this endpoint — they use the env-configured + * RFC 3161 TSA directly. + * + * If `nonce` is supplied, the TSP MUST round-trip it in the token — we leave + * verification to LibPDF / our TSA helper, not this client. + */ +export const cscTimestamp = async (opts: CscTimestampOptions): Promise => { + const { baseUrl, accessToken, signal, hash, hashAlgo, nonce, clientData } = opts; + + const body: Record = { hash, hashAlgo }; + + if (nonce !== undefined) { + body.nonce = nonce; + } + + if (clientData !== undefined) { + body.clientData = clientData; + } + + return await cscJsonPost( + { + url: joinCscUrl({ baseUrl, path: 'signatures/timestamp' }), + body, + accessToken, + signal, + }, + ZCscTimestampResponseSchema, + ); +}; diff --git a/packages/ee/server-only/signing/csc/client/types.ts b/packages/ee/server-only/signing/csc/client/types.ts new file mode 100644 index 000000000..2106ba75c --- /dev/null +++ b/packages/ee/server-only/signing/csc/client/types.ts @@ -0,0 +1,179 @@ +import { z } from 'zod'; + +/** + * Zod schemas + types for every CSC v1.0.4.0 request/response shape the V1 + * client touches. Field names mirror the spec exactly. Unknown fields are + * silently dropped (Zod default `.strip()`); we don't `.passthrough()` to + * keep parsed objects narrow. + * + * Out-of-scope endpoints (`auth/login`, `auth/revoke`, `credentials/authorize`, + * `credentials/extendTransaction`, `credentials/sendOTP`) intentionally have + * no schemas here — V1 uses OAuth + sequential single-signature flows only. + */ + +// ─── §10.1 common error envelope ───────────────────────────────────────────── + +export const ZCscErrorResponseSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}); + +export type TCscErrorResponse = z.infer; + +// ─── §11.1 info ────────────────────────────────────────────────────────────── + +export const ZCscInfoRequestSchema = z.object({ + lang: z.string().optional(), +}); + +export type TCscInfoRequest = z.infer; + +export const ZCscInfoResponseSchema = z.object({ + specs: z.string(), + name: z.string(), + logo: z.string(), + region: z.string(), + lang: z.string(), + description: z.string(), + authType: z.array(z.string()), + // REQUIRED Conditional — present when authType includes `oauth2code` / + // `oauth2client`, or when any credential supports `oauth2code` authMode. + // We always need it for V1, but keeping the schema permissive matches the + // spec; absence is detected at the call site. + oauth2: z.string().optional(), + methods: z.array(z.string()), +}); + +export type TCscInfoResponse = z.infer; + +// ─── §11.4 credentials/list ────────────────────────────────────────────────── + +export const ZCscCredentialsListRequestSchema = z.object({ + // OAuth2 user-specific service auth → userID MUST be omitted (§11.4 NOTE 1). + userID: z.string().optional(), + maxResults: z.number().int().positive().optional(), + pageToken: z.string().optional(), + clientData: z.string().optional(), +}); + +export type TCscCredentialsListRequest = z.infer; + +export const ZCscCredentialsListResponseSchema = z.object({ + credentialIDs: z.array(z.string()), + nextPageToken: z.string().optional(), +}); + +export type TCscCredentialsListResponse = z.infer; + +// ─── §11.5 credentials/info ────────────────────────────────────────────────── + +export const ZCscCredentialsInfoRequestSchema = z.object({ + credentialID: z.string(), + certificates: z.enum(['none', 'single', 'chain']).optional(), + certInfo: z.boolean().optional(), + authInfo: z.boolean().optional(), + lang: z.string().optional(), + clientData: z.string().optional(), +}); + +export type TCscCredentialsInfoRequest = z.infer; + +export const ZCscCredentialsInfoKeySchema = z.object({ + status: z.enum(['enabled', 'disabled']), + algo: z.array(z.string()), + // REQUIRED per §11.5 but kept optional here so the algorithm-resolver can + // surface absence as a typed `CSC_ALGORITHM_REFUSED` (matching the spec's + // policy table) instead of a generic transport schema failure. + len: z.number().int().positive().optional(), + // REQUIRED Conditional for ECDSA per §11.5; absence handled by the resolver. + curve: z.string().optional(), +}); + +export const ZCscCredentialsInfoCertSchema = z.object({ + status: z.enum(['valid', 'expired', 'revoked', 'suspended']).optional(), + certificates: z.array(z.string()).optional(), + issuerDN: z.string().optional(), + serialNumber: z.string().optional(), + subjectDN: z.string().optional(), + validFrom: z.string().optional(), + validTo: z.string().optional(), +}); + +export const ZCscCredentialsInfoPinSchema = z.object({ + presence: z.enum(['true', 'false', 'optional']), + format: z.enum(['A', 'N']).optional(), + label: z.string().optional(), + description: z.string().optional(), +}); + +export const ZCscCredentialsInfoOtpSchema = z.object({ + presence: z.enum(['true', 'false', 'optional']), + type: z.enum(['offline', 'online']).optional(), + format: z.enum(['A', 'N']).optional(), + label: z.string().optional(), + description: z.string().optional(), + ID: z.string().optional(), + provider: z.string().optional(), +}); + +export const ZCscCredentialsInfoResponseSchema = z.object({ + description: z.string().optional(), + key: ZCscCredentialsInfoKeySchema, + cert: ZCscCredentialsInfoCertSchema, + authMode: z.enum(['implicit', 'explicit', 'oauth2code']), + SCAL: z.enum(['1', '2']).optional(), + PIN: ZCscCredentialsInfoPinSchema.optional(), + OTP: ZCscCredentialsInfoOtpSchema.optional(), + multisign: z.number().int().min(1), + lang: z.string().optional(), +}); + +export type TCscCredentialsInfoResponse = z.infer; + +// ─── §11.9 signatures/signHash ─────────────────────────────────────────────── + +export const ZCscSignHashRequestSchema = z.object({ + credentialID: z.string(), + SAD: z.string(), + // Base64-encoded raw message digests. + hash: z.array(z.string()).nonempty(), + // REQUIRED Conditional — OID of the hash algorithm. Omit only when implied + // by signAlgo (per §11.9). The caller decides. + hashAlgo: z.string().optional(), + signAlgo: z.string(), + // REQUIRED Conditional for algorithms like RSASSA-PSS. + signAlgoParams: z.string().optional(), + clientData: z.string().optional(), +}); + +export type TCscSignHashRequest = z.infer; + +export const ZCscSignHashResponseSchema = z.object({ + // Position-ordered Base64-encoded signed hashes matching the input order. + signatures: z.array(z.string()).nonempty(), +}); + +export type TCscSignHashResponse = z.infer; + +// ─── §11.10 signatures/timestamp ───────────────────────────────────────────── + +export const ZCscTimestampRequestSchema = z.object({ + hash: z.string(), + hashAlgo: z.string(), + // Hex-encoded random; SHALL round-trip in the timestamp token when supplied. + nonce: z.string().optional(), + clientData: z.string().optional(), +}); + +export type TCscTimestampRequest = z.infer; + +export const ZCscTimestampResponseSchema = z.object({ + // Base64-encoded RFC 3161 (with RFC 5816 update) time-stamp token. + timestamp: z.string(), +}); + +export type TCscTimestampResponse = z.infer; + +// OAuth 2.0 token + revoke shapes are handled by the `arctic` library — see +// `oauth.ts` in this directory. Arctic exposes `OAuth2Tokens` (with `.data` +// available for non-standard CSC fields like `token_type === 'SAD'`). diff --git a/packages/ee/server-only/signing/csc/cookies/blocking-error-cookie.ts b/packages/ee/server-only/signing/csc/cookies/blocking-error-cookie.ts new file mode 100644 index 000000000..7b44382d1 --- /dev/null +++ b/packages/ee/server-only/signing/csc/cookies/blocking-error-cookie.ts @@ -0,0 +1,120 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { Context } from 'hono'; +import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; +import { parseSigned, serialize } from 'hono/utils/cookie'; +import { z } from 'zod'; + +import { CSC_BLOCKING_ERROR_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared'; + +/** + * `csc_blocking_error` — one-shot surface for service-scope OAuth callback + * failures the recipient can't self-resolve (empty credential list, invalid + * cert, refused algorithm, etc.). The `/sign/{token}` loader reads + clears + * it on next visit so no error state rides on URL query params. + */ + +const CSC_BLOCKING_ERROR_MAX_AGE_SECONDS = 60 * 10; // 10 minutes — matches the other short-lived CSC cookies. + +export const ZCscBlockingErrorPayloadSchema = z.object({ + /** `AppErrorCode` value, e.g. `'CSC_CREDENTIAL_LIST_EMPTY'`. */ + code: z.string().min(1), + /** Recipient token from `/sign/{token}`; loader scopes the error to its recipient. */ + recipientToken: z.string().min(1), +}); + +export type TCscBlockingErrorPayload = z.infer; + +type SetCscBlockingErrorCookieOptions = { + c: Context; + payload: TCscBlockingErrorPayload; +}; + +export const setCscBlockingErrorCookie = async (options: SetCscBlockingErrorCookieOptions): Promise => { + const { c, payload } = options; + + await setSignedCookie(c, CSC_BLOCKING_ERROR_COOKIE_NAME, JSON.stringify(payload), getCscCookieSecret(), { + ...cscCookieBaseOptions, + maxAge: CSC_BLOCKING_ERROR_MAX_AGE_SECONDS, + }); +}; + +/** + * Read + validate the blocking-error cookie. Returns `null` when absent or + * signature-invalid; throws `INVALID_REQUEST` when signed-but-malformed + * (tamper-shaped, mirroring `oauth-flow-cookie.ts`). + */ +export const getCscBlockingErrorCookie = async (c: Context): Promise => { + const raw = await getSignedCookie(c, getCscCookieSecret(), CSC_BLOCKING_ERROR_COOKIE_NAME); + + if (!raw) { + return null; + } + + let parsedJson: unknown; + + try { + parsedJson = JSON.parse(raw); + } catch { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC blocking error cookie payload is not valid JSON.', + }); + } + + const result = ZCscBlockingErrorPayloadSchema.safeParse(parsedJson); + + if (!result.success) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC blocking error cookie payload failed schema validation.', + }); + } + + return result.data; +}; + +export const clearCscBlockingErrorCookie = (c: Context): void => { + deleteCookie(c, CSC_BLOCKING_ERROR_COOKIE_NAME, cscCookieBaseOptions); +}; + +/** + * Remix-compatible reader: parses + HMAC-verifies the blocking-error cookie + * from a raw `Cookie` header on a standard `Request`. Returns `null` when + * absent, signature-invalid, or payload-malformed (no throw — the loader + * only uses the cookie advisorily, so a bad cookie shouldn't break the page). + */ +export const readCscBlockingErrorFromRequest = async (request: Request): Promise => { + const cookieHeader = request.headers.get('cookie'); + + if (!cookieHeader) { + return null; + } + + const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_BLOCKING_ERROR_COOKIE_NAME); + + const value = parsed[CSC_BLOCKING_ERROR_COOKIE_NAME]; + + if (typeof value !== 'string') { + return null; + } + + try { + const json = JSON.parse(value); + + const result = ZCscBlockingErrorPayloadSchema.safeParse(json); + + return result.success ? result.data : null; + } catch { + return null; + } +}; + +/** + * Serialised `Set-Cookie` header value that expires the cookie immediately. + * Use in a Remix loader's response headers to clear the cookie after the + * loader reads it once. + */ +export const buildClearCscBlockingErrorCookieHeader = (): string => { + return serialize(CSC_BLOCKING_ERROR_COOKIE_NAME, '', { + ...cscCookieBaseOptions, + maxAge: 0, + }); +}; diff --git a/packages/ee/server-only/signing/csc/cookies/oauth-flow-cookie.ts b/packages/ee/server-only/signing/csc/cookies/oauth-flow-cookie.ts new file mode 100644 index 000000000..37eea9323 --- /dev/null +++ b/packages/ee/server-only/signing/csc/cookies/oauth-flow-cookie.ts @@ -0,0 +1,85 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { Context } from 'hono'; +import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; +import { z } from 'zod'; + +import { CSC_OAUTH_FLOW_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared'; + +/** + * `csc_oauth_flow` — single-round-trip carrier across `/api/csc/oauth/authorize` + * → TSP → `/api/csc/oauth/callback`. Holds the PKCE verifier + state plus the + * Documenso-side context (`recipientToken`, optional `sessionId`) the + * callback needs to resume the right signing flow. + * + * JSON-encoded inside a single signed cookie; structurally validated on read + * so a tampered or stale shape can't smuggle bad state into the callback. + */ + +const CSC_OAUTH_FLOW_MAX_AGE_SECONDS = 60 * 10; // 10 minutes — matches /api/auth/oauth/* convention. + +export const ZCscOAuthFlowPayloadSchema = z.object({ + /** `'service'` for the first round-trip, `'credential'` for the SAD round-trip. */ + scope: z.enum(['service', 'credential']), + /** Arctic-generated CSRF token; re-validated against `?state` at callback. */ + state: z.string().min(1), + /** Arctic-generated PKCE verifier (RFC 7636); paired with the URL's `code_challenge`. */ + codeVerifier: z.string().min(1), + /** Recipient signing token from `/sign/{token}`; threads recipient identity through the round-trip. */ + recipientToken: z.string().min(1), + /** CSC session id — present only on `credential`-scope flows (set at prep). */ + sessionId: z.string().min(1).optional(), +}); + +export type TCscOAuthFlowPayload = z.infer; + +type SetCscOAuthFlowCookieOptions = { + c: Context; + payload: TCscOAuthFlowPayload; +}; + +export const setCscOAuthFlowCookie = async (options: SetCscOAuthFlowCookieOptions): Promise => { + const { c, payload } = options; + + await setSignedCookie(c, CSC_OAUTH_FLOW_COOKIE_NAME, JSON.stringify(payload), getCscCookieSecret(), { + ...cscCookieBaseOptions, + maxAge: CSC_OAUTH_FLOW_MAX_AGE_SECONDS, + }); +}; + +/** + * Read + validate the OAuth-flow cookie. Returns `null` when the cookie is + * absent or the signature is invalid; throws `INVALID_REQUEST` when the + * payload is structurally bad (signed but malformed JSON / schema mismatch), + * since that's tamper-shaped, not a normal missing-cookie case. + */ +export const getCscOAuthFlowCookie = async (c: Context): Promise => { + const raw = await getSignedCookie(c, getCscCookieSecret(), CSC_OAUTH_FLOW_COOKIE_NAME); + + if (!raw) { + return null; + } + + let parsedJson: unknown; + + try { + parsedJson = JSON.parse(raw); + } catch { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC OAuth flow cookie payload is not valid JSON.', + }); + } + + const result = ZCscOAuthFlowPayloadSchema.safeParse(parsedJson); + + if (!result.success) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'CSC OAuth flow cookie payload failed schema validation.', + }); + } + + return result.data; +}; + +export const clearCscOAuthFlowCookie = (c: Context): void => { + deleteCookie(c, CSC_OAUTH_FLOW_COOKIE_NAME, cscCookieBaseOptions); +}; diff --git a/packages/ee/server-only/signing/csc/cookies/sad-session-cookie.ts b/packages/ee/server-only/signing/csc/cookies/sad-session-cookie.ts new file mode 100644 index 000000000..6c4df0d67 --- /dev/null +++ b/packages/ee/server-only/signing/csc/cookies/sad-session-cookie.ts @@ -0,0 +1,61 @@ +import type { Context } from 'hono'; +import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; +import { parseSigned } from 'hono/utils/cookie'; + +import { CSC_SAD_SESSION_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared'; + +/** + * `csc_sad_session` — HMAC-signed `CscSession` cuid. Set after the + * credential-scope OAuth callback exchanges code → SAD; pointed at the + * server-side session row that owns the SAD + the prep-time item hashes. + * + * Lifetime mirrors the TSP-asserted SAD expiry (`sadExpiresAt`) so the cookie + * cannot outlive its server-side authorisation. Cleared by the sync sign + * mutation on success; otherwise decays naturally with the browser TTL. + */ + +type SetCscSadSessionCookieOptions = { + c: Context; + sessionId: string; + /** Mirror of `CscSession.sadExpiresAt`; cookie expires no later than the SAD. */ + expiresAt: Date; +}; + +export const setCscSadSessionCookie = async (options: SetCscSadSessionCookieOptions): Promise => { + const { c, sessionId, expiresAt } = options; + + await setSignedCookie(c, CSC_SAD_SESSION_COOKIE_NAME, sessionId, getCscCookieSecret(), { + ...cscCookieBaseOptions, + expires: expiresAt, + }); +}; + +export const getCscSadSessionCookie = async (c: Context): Promise => { + const value = await getSignedCookie(c, getCscCookieSecret(), CSC_SAD_SESSION_COOKIE_NAME); + + // `getSignedCookie` returns `false` on signature mismatch, `undefined` when + // the cookie is absent. Both collapse to `null` for the caller's sake. + return value ? value : null; +}; + +export const clearCscSadSessionCookie = (c: Context): void => { + deleteCookie(c, CSC_SAD_SESSION_COOKIE_NAME, cscCookieBaseOptions); +}; + +/** + * Remix-compatible reader: parses + HMAC-verifies the SAD-session cookie + * from a raw `Cookie` header on a standard `Request`. Mirrors + * `getCscSadSessionCookie` but works outside Hono's `Context`. + */ +export const readCscSadSessionFromRequest = async (request: Request): Promise => { + const cookieHeader = request.headers.get('cookie'); + + if (!cookieHeader) { + return null; + } + + const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_SAD_SESSION_COOKIE_NAME); + const value = parsed[CSC_SAD_SESSION_COOKIE_NAME]; + + return typeof value === 'string' ? value : null; +}; diff --git a/packages/ee/server-only/signing/csc/cookies/service-session-cookie.ts b/packages/ee/server-only/signing/csc/cookies/service-session-cookie.ts new file mode 100644 index 000000000..5a8267d78 --- /dev/null +++ b/packages/ee/server-only/signing/csc/cookies/service-session-cookie.ts @@ -0,0 +1,65 @@ +import type { Context } from 'hono'; +import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; +import { parseSigned } from 'hono/utils/cookie'; + +import { CSC_SERVICE_SESSION_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared'; + +/** + * `csc_service_session` — recipient-scoped attestation that this browser just + * completed a service-scope OAuth round-trip for ``. The + * `/sign/{token}` loader compares the cookie value against the path token; on + * match it skips re-auth, breaking the redirect loop that would otherwise + * occur when the TSP silently re-grants from its cached SCA session. + * + * Covers the long-lived T1→T3 window (recipient on the signing page filling + * fields, before clicking Sign). `csc_sad_session` covers the much shorter + * T4→T5 window (active signing transaction); the two are complementary, not + * substitutes. + * + * TTL = TSP-asserted service-scope `expires_in` so the trust window can never + * outlive the underlying access token. + */ + +type SetCscServiceSessionCookieOptions = { + c: Context; + recipientToken: string; + /** TSP service-scope `expires_in` in seconds. Mirrored as the cookie max-age. */ + ttlSeconds: number; +}; + +export const setCscServiceSessionCookie = async (options: SetCscServiceSessionCookieOptions): Promise => { + const { c, recipientToken, ttlSeconds } = options; + + await setSignedCookie(c, CSC_SERVICE_SESSION_COOKIE_NAME, recipientToken, getCscCookieSecret(), { + ...cscCookieBaseOptions, + maxAge: ttlSeconds, + }); +}; + +export const getCscServiceSessionCookie = async (c: Context): Promise => { + const value = await getSignedCookie(c, getCscCookieSecret(), CSC_SERVICE_SESSION_COOKIE_NAME); + + return value ? value : null; +}; + +export const clearCscServiceSessionCookie = (c: Context): void => { + deleteCookie(c, CSC_SERVICE_SESSION_COOKIE_NAME, cscCookieBaseOptions); +}; + +/** + * Remix-compatible reader: parses + HMAC-verifies the service-session cookie + * from a raw `Cookie` header on a standard `Request`. Mirrors + * `getCscServiceSessionCookie` but works outside Hono's `Context`. + */ +export const readCscServiceSessionFromRequest = async (request: Request): Promise => { + const cookieHeader = request.headers.get('cookie'); + + if (!cookieHeader) { + return null; + } + + const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_SERVICE_SESSION_COOKIE_NAME); + const value = parsed[CSC_SERVICE_SESSION_COOKIE_NAME]; + + return typeof value === 'string' ? value : null; +}; diff --git a/packages/ee/server-only/signing/csc/cookies/shared.ts b/packages/ee/server-only/signing/csc/cookies/shared.ts new file mode 100644 index 000000000..3280c0c2a --- /dev/null +++ b/packages/ee/server-only/signing/csc/cookies/shared.ts @@ -0,0 +1,46 @@ +import { formatSecureCookieName, getCookieDomain, useSecureCookies } from '@documenso/lib/constants/auth'; +import { requireEnv } from '@documenso/lib/utils/env'; + +/** + * Shared HMAC secret + base attribute set for the CSC cookies. + * + * `NEXTAUTH_SECRET` is reused so signed-cookie verification stays uniform + * across the auth + CSC surfaces. The `sameSite` conditional matches + * `sessionCookieOptions` in `@documenso/auth` so a future embedding flow + * (CSC inside an `