feat: add CSC AES/QES signing (v1 instance-wide config) (#2874)

Adds Cloud Signature Consortium (CSC) integration for AES/QES signing
against a configured TSP. v1 ships as instance-wide configuration via
environment variables, with per-envelope signature level selection,
license gating, and an OAuth-driven signing flow (capture + FIFO
signers, SAD session, blocking/in-progress recipient pages).

Includes signature level compatibility checks (role, signing order,
dictate next signer), envelope mutability assertions, Prisma migration
for signature level and CSC tables, and docs for the new signing
certificate options.
This commit is contained in:
Lucas Smith
2026-06-16 23:37:34 +10:00
committed by GitHub
parent 9b59f1a273
commit d5ce222482
103 changed files with 6524 additions and 77 deletions
@@ -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<ReturnType<typeof handleV2Loade
return <DocumentSigningAuthPageView email={data.recipientEmail} emailHasAccount={!!data.recipientHasAccount} />;
}
if ('csc' in data && data.csc?.state === 'blocked') {
return <CscRecipientBlockedPage code={data.csc.code} recipientToken={data.envelopeForSigning.recipient.token} />;
}
if ('csc' in data && data.csc?.state === 'signing-in-progress') {
return (
<CscRecipientSigningInProgressPage
sessionId={data.csc.sessionId}
recipientToken={data.envelopeForSigning.recipient.token}
/>
);
}
const { envelope, recipientSignature, recipient } = data.envelopeForSigning;
if (envelope.deletedAt || envelope.status === DocumentStatus.REJECTED) {