diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml new file mode 100644 index 0000000000..717164c07e --- /dev/null +++ b/.github/workflows/build-docker.yml @@ -0,0 +1,53 @@ +name: Build and Push Crove Sign Docker Image + +on: + push: + branches: + - dev + - main + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + name: Build & Push GHCR Container + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} + type=sha,format=short + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./docker/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/apps/remix/server/api/webhooks/dos-webhook.ts b/apps/remix/server/api/webhooks/dos-webhook.ts new file mode 100644 index 0000000000..2e871b81a7 --- /dev/null +++ b/apps/remix/server/api/webhooks/dos-webhook.ts @@ -0,0 +1,37 @@ +import { handleDosWebhookEvent } from '@documenso/lib/server-only/dos-id/handle-dos-webhook'; +import { verifyDosWebhookSignature } from '@documenso/lib/server-only/dos-id/verify-dos-signature'; +import { env } from '@documenso/lib/utils/env'; +import { Hono } from 'hono'; + +export const dosWebhookRoute = new Hono() + .post('/dos-org-sync', async (c) => { + const signatureHeader = c.req.header('x-dos-signature') ?? null; + const webhookSecret = env('CROVE_DOS_WEBHOOK_SECRET') || env('NEXT_PRIVATE_DOS_WEBHOOK_SECRET') || ''; + + const rawBody = await c.req.text(); + + if (webhookSecret) { + const isValid = verifyDosWebhookSignature({ + rawBody, + signatureHeader, + secret: webhookSecret, + }); + + if (!isValid) { + return c.json({ success: false, message: 'Invalid webhook signature' }, 401); + } + } + + try { + const payload = JSON.parse(rawBody); + const result = await handleDosWebhookEvent(payload); + + return c.json(result, result.success ? 200 : 400); + } catch (error) { + console.error('[DOS Webhook] Error processing payload:', error); + return c.json( + { success: false, message: error instanceof Error ? error.message : 'Internal Server Error' }, + 500, + ); + } + }); diff --git a/apps/remix/server/router.ts b/apps/remix/server/router.ts index 83681fba9b..4e21436a45 100644 --- a/apps/remix/server/router.ts +++ b/apps/remix/server/router.ts @@ -27,6 +27,7 @@ import type { Logger } from 'pino'; import { aiRoute } from './api/ai/route'; import { downloadRoute } from './api/download/download'; import { filesRoute } from './api/files/files'; +import { dosWebhookRoute } from './api/webhooks/dos-webhook'; import { type AppContext, appContext } from './context'; import { appMiddleware } from './middleware'; import { securityHeadersMiddleware } from './security-headers'; @@ -116,6 +117,9 @@ app.route('/api/ai', aiRoute); // CSC OAuth routes (mounted from @documenso/ee). app.route('/api/csc', csc); +// DOS.Me Webhook endpoints. +app.route('/api/webhooks', dosWebhookRoute); + // API servers. app.route('/api/v1', tsRestHonoApp); app.use('/api/jobs/*', jobsClient.getApiHandler()); diff --git a/docker/Dockerfile b/docker/Dockerfile index 0b5923a053..d3229842f2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -42,6 +42,7 @@ WORKDIR /app ENV HUSKY 0 ENV DOCKER_OUTPUT 1 ENV NEXT_TELEMETRY_DISABLED 1 +ENV NODE_OPTIONS="--max-old-space-size=4096" # Encryption keys ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE" diff --git a/docker/compose.crove-server.yml b/docker/compose.crove-server.yml new file mode 100644 index 0000000000..dcbe7bba06 --- /dev/null +++ b/docker/compose.crove-server.yml @@ -0,0 +1,28 @@ +name: crove-sign + +services: + crove-sign: + image: documenso/documenso:v2.17.0 + container_name: crove-sign + restart: always + env_file: + - .env + volumes: + - ./migrations:/app/packages/prisma/migrations:ro + ports: + - "127.0.0.1:4008:3000" + networks: + - default + - postiz-network + healthcheck: + test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))\""] + interval: 15s + timeout: 10s + retries: 5 + start_period: 30s + +networks: + default: + postiz-network: + name: postiz-docker-compose_postiz-network + external: true diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000000..b94ab9a2dd --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,241 @@ +# Kiến Trúc Hệ Thống Crove Sign (Crove OS) + +Tài liệu mô tả kiến trúc kỹ thuật, luồng dữ liệu, hạ tầng mạng, cơ sở dữ liệu và cơ chế tích hợp định danh (Identity & SSO) của **Crove Sign** trong hệ sinh thái **Crove OS / DOS.Me**. + +--- + +## 1. Tổng Quan Hệ Thống + +**Crove Sign** là dịch vụ ký tài liệu số điện tử (e-Signature Engine) của hệ sinh thái Crove, được xây dựng dựa trên core Documenso v2.17.0 (React Router v7 / Remix + Hono + Prisma + PDF Signing Engine). + +``` + ┌────────────────────────────────────────┐ + │ End Users / Browser │ + └───────────────────┬────────────────────┘ + │ HTTPS (sign.crove.com) + ▼ + ┌────────────────────────────────────────┐ + │ Cloudflare Edge (Zero Trust) │ + │ Tunnel: Crove-GCP │ + └───────────────────┬────────────────────┘ + │ QUIC / HTTP2 + ▼ + ┌──────────────────────────────────────────────────────────────────────────────────┐ + │ GCP Compute Engine: crove-server (Project: crove-os | Zone: asia-southeast1-b) │ + │ │ + │ ┌───────────────────────┐ Docker Network: crove_postiz-network │ + │ │ crove-cloudflared ├────────────────────────────┐ │ + │ │ (Cloudflare Connector)│ │ │ + │ └───────────────────────┘ ▼ │ + │ ┌─────────────────────┐ │ + │ │ crove-sign │ │ + │ │ (Documenso v2.17.0) │ │ + │ │ Port: 3000 (4008) │ │ + │ └──────────┬──────────┘ │ + └────────────────────────────────────────────────────────┼─────────────────────────┘ + │ + ┌─────────────────────────────────────┴─────────────────────────┐ + │ │ + ▼ (OIDC Discovery / Token / UserInfo) ▼ (Postgres Session Pooler) +┌────────────────────────────────────────┐ ┌────────────────────────────────────────┐ +│ DOS ID / Supabase Auth │ │ Supabase Managed Postgres │ +│ (Auth & Single Sign-On) │ │ (Schema: sign - 163 tables) │ +│ https://id.dos.me / auth/v1 │ │ aws-1-ap-southeast-1.pooler...:5432 │ +└────────────────────────────────────────┘ └────────────────────────────────────────┘ +``` + +--- + +## 2. Hạ Tầng & Mạng (Infrastructure & Network) + +### 2.1. Máy Chủ Ứng Dụng (Cloud VM) +- **GCP Project**: `crove-os` (Project Number: `352034351652`, Organization: Tingee). +- **Instance**: `crove-server` (`asia-southeast1-b`). +- **Cấu hình**: `e2-standard-2` (2 vCPU, 8GB RAM), 50GB Boot Disk. +- **Docker Compose Stack**: Đặt tại `/opt/crove/sign/docker-compose.yml`, expose local port `127.0.0.1:4008:3000`. + +### 2.2. Định Tuyến & Tên Miền (Cloudflare Zero Trust) +- **Tên miền công khai**: `https://sign.crove.com`. +- **Cloudflare Tunnel**: `Crove-GCP` (Tunnel ID: `41d183ca-1507-4092-a2e5-a5bd988282ee`). +- **Ingress Rule**: + ```yaml + - hostname: sign.crove.com + service: http://crove-sign:3000 + originRequest: + httpHostHeader: sign.crove.com + ``` +- **Docker Network**: Container `crove-sign` gắn vào `crove_postiz-network` để `crove-cloudflared` phân giải DNS nội bộ trực tiếp qua service name `crove-sign`. + +--- + +## 3. Kiến Trúc Cơ Sở Dữ Liệu (Database Architecture) + +Crove Sign sử dụng chung cụm PostgreSQL quản trị bởi Supabase (`gulptwduchsjcsbndmua`) nhưng được **cô lập hoàn toàn ở cấp độ Schema (Schema-level Multi-Tenancy)**: + +| Thông số | Giá trị | +| :--- | :--- | +| **Schema Name** | `sign` (Độc lập với `public`, `cal`, `post`, `crm`, `dosai`, `dosafe`) | +| **Prisma Migrations** | 163 migrations áp dụng thành công trong schema `sign` | +| **Connection Endpoint** | `aws-1-ap-southeast-1.pooler.supabase.com:5432` | +| **Connection String** | `postgresql://postgres.gulptwduchsjcsbndmua:@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres?schema=sign&sslmode=no-verify` | +| **Cơ chế mở rộng ID** | Tích hợp hàm `sign.nanoid()` và `sign.nanoid_optimized()` sử dụng extension `extensions.pgcrypto` | + +### 3.1. Các bảng dữ liệu cốt lõi +- `sign.User`: Thông tin người dùng e-sign (map theo `email` hoặc `sub` từ DOS ID). +- `sign.Account`: Liên kết tài khoản OAuth/OIDC với `provider = 'oidc'`, `providerAccountId = sub`. +- `sign.Organisation` & `sign.OrganisationMember`: Tổ chức, quyền hạn thành viên, branding, seat limits. +- `sign.Team` & `sign.TeamMember`: Nhóm làm việc trong tổ chức. +- `sign.Envelope` & `sign.EnvelopeItem`: Tài liệu ký (PDF), trạng thái hoàn thành, audit trail. +- `sign.Recipient` & `sign.Field` & `sign.Signature`: Người nhận, tọa độ các ô ký/text/date và chữ ký số. + +--- + +## 4. Định Danh & Xác Thực (Authentication & SSO) + +Crove Sign triển khai mô hình **SSO-First** tập trung về **DOS.Me ID** qua giao thức chuẩn **OpenID Connect (OIDC)**: + +### 4.1. Thông số Cấu Hình OIDC +- **Provider**: DOS.Me ID (Supabase Auth OpenID Connect Provider). +- **Well-Known Discovery**: `https://gulptwduchsjcsbndmua.supabase.co/auth/v1/.well-known/openid-configuration`. +- **Client ID**: `18790ccb-4d71-48cd-ad24-aee5f3ced3da` (OAuth Client `Crove`). +- **Token Endpoint Auth Method**: `client_secret_basic` (Authorization Basic header). +- **Scopes**: `openid profile email offline_access`. +- **Redirect / Callback URI**: `https://sign.crove.com/api/auth/callback/oidc`. +- **Prompt**: `consent`. + +### 4.2. Luồng Đăng Nhập (OIDC Authorization Code Flow with PKCE) + +``` +User -> Browser Crove Sign (App) DOS.Me ID (Supabase Auth) + │ │ │ + ├──── Bấm "DOS.Me ID" ─────────>│ │ + │ ├─ Tạo state & PKCE code_verifier ───>│ + │ ├─ Redirect sang /oauth/authorize ────┤ + │<── Redirect 302 ──────────────┤ │ + │ │ + ├──── Đăng nhập / Cấp quyền trên id.dos.me ──────────────────────────>│ + │ │ + │<── Callback 302 về /api/auth/callback/oidc?code=...&state=... ──────┤ + │ │ + ├──── Gửi code & state ────────>│ │ + │ ├─ Gửi Token Request (Basic Auth) ───>│ + │ │<─ Trả về Access Token + ID Token ───┤ + │ │ │ + │ ├─ Decode ID Token (sub, email, name) │ + │ ├─ Upsert User & Account in DB (sign) │ + │ ├─ Tạo Session Cookie │ + │<── Redirect 302 về Dashboard ─┤ │ +``` + +### 4.3. Chính sách SSO-First (Cài đặt môi trường) +```ini +# Vô hiệu hóa form đăng ký/đăng nhập local bằng mật khẩu: +NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=true +NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=true + +# Giữ trang signin hiển thị nút DOS.Me ID & Passkey: +NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=true +NEXT_PRIVATE_OIDC_SKIP_VERIFY=true +NEXT_PRIVATE_OIDC_PROVIDER_LABEL="DOS.Me ID" +``` + +--- + +## 5. Kiến Trúc Đồng Bộ Tổ Chức (Organization Synchronization) + +Hệ sinh thái Crove OS áp dụng mô hình **Hybrid Organization Sync** để quản lý đa tổ chức nhất quán: + +``` + ┌───────────────────────────────┐ + │ DOS.Me Core Workspace │ + │ (public.organizations / roles)│ + └───────────────┬───────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ + ▼ (JIT / Claims) ▼ (OIDC Claims) ▼ (JIT / Webhook) + ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ + │ Crove Sign │ │ Crove Post │ │ Crove CRM │ + │ (schema: sign) │ │ (schema: post) │ │ (schema: core) │ + └──────────────────┘ └──────────────────┘ └──────────────────┘ +``` + +1. **Just-In-Time (JIT) Provisioning**: + - Khi người dùng đăng nhập lần đầu qua DOS.Me ID, Crove Sign tự động tạo bản ghi `User` và `Personal Organisation` + `Personal Team`. + - Nếu email đã tồn tại trước đó, hệ thống thực hiện **Account Linking** tự động liên kết `providerAccountId` (`sub`) vào user đó. +2. **Organization Claims Sync**: + - ID Token / UserInfo mang thông tin `organization_id` và `role` từ DOS.Me. + - Callback Auth tiến hành cập nhật/tạo tổ chức doanh nghiệp tương ứng và gán vai trò (`ADMIN` hoặc `MEMBER`) trong schema `sign`. +3. **Webhook Lifecycle (Phase 2)**: + - Lắng nghe sự kiện `organization.created`, `organization.member_added`, `organization.member_removed` từ DOS.Me để đồng bộ trạng thái thành viên theo thời gian thực. + +--- + +## 6. Danh Mục Biến Môi Trường Sản Xuất (`/opt/crove/sign/.env`) + +```ini +PORT=3000 +NEXTAUTH_SECRET="" +NEXT_PRIVATE_ENCRYPTION_KEY="" +NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="" + +# App URLs +NEXT_PUBLIC_WEBAPP_URL="https://sign.crove.com" +NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" + +# Database Connection (Supabase Session Pooler - schema: sign) +NEXT_PRIVATE_DATABASE_URL="postgresql://postgres.gulptwduchsjcsbndmua:@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres?schema=sign&sslmode=no-verify" +NEXT_PRIVATE_DIRECT_DATABASE_URL="postgresql://postgres.gulptwduchsjcsbndmua:@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres?schema=sign&sslmode=no-verify" + +# Storage & Mail +NEXT_PUBLIC_UPLOAD_TRANSPORT="database" +NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" +NEXT_PRIVATE_SMTP_HOST="127.0.0.1" +NEXT_PRIVATE_SMTP_PORT=25 +NEXT_PRIVATE_SMTP_FROM_NAME="Crove Sign" +NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@crove.com" + +# DOS.Me OIDC SSO +NEXT_PRIVATE_OIDC_WELL_KNOWN="https://gulptwduchsjcsbndmua.supabase.co/auth/v1/.well-known/openid-configuration" +NEXT_PRIVATE_OIDC_CLIENT_ID="18790ccb-4d71-48cd-ad24-aee5f3ced3da" +NEXT_PRIVATE_OIDC_CLIENT_SECRET="" +NEXT_PRIVATE_OIDC_PROVIDER_LABEL="DOS.Me ID" +NEXT_PRIVATE_OIDC_SKIP_VERIFY=true +NEXT_PRIVATE_OIDC_PROMPT="consent" + +# SSO-First Access Controls +NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=true +NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=true +NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=true +``` + +--- + +## 7. Quy Trình Vận Hành & Khôi Phục (Operations & Runbook) + +### 7.1. Khởi động / Khởi động lại dịch vụ +```bash +cd /opt/crove/sign +sudo docker compose up -d +sudo docker compose restart +``` + +### 7.2. Kiểm tra trạng thái & Healthcheck +```bash +# Kiểm tra container status +docker ps --filter name=crove-sign + +# Kiểm tra log ứng dụng +docker logs --tail 50 crove-sign + +# Kiểm tra endpoint sức khỏe +curl -s http://127.0.0.1:4008/api/health +``` + +### 7.3. Cập nhật Ingress Tunnel khi cần +- File cấu hình Cloudflare Tunnel: `/opt/crove/tunnel/config.yml`. +- Sau khi chỉnh sửa, khởi động lại connector: + ```bash + cd /opt/crove + sudo docker compose -f docker-compose.prod.yaml restart cloudflared + ``` diff --git a/packages/auth/server/lib/utils/handle-oauth-callback-url.ts b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts index 5bae1f940d..27cf1241a6 100644 --- a/packages/auth/server/lib/utils/handle-oauth-callback-url.ts +++ b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts @@ -5,6 +5,7 @@ import { isSignupEnabledForProvider, } from '@documenso/lib/constants/auth'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { syncDosProfileAndOrgs, type DosOrgClaim } from '@documenso/lib/server-only/dos-id/sync-dos-profile'; import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains'; import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user'; import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account'; @@ -31,7 +32,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti const requestMeta = c.get('requestMetadata'); - const { email, name, sub, accessToken, accessTokenExpiresAt, idToken, redirectPath } = await validateOauth({ + const { email, name, sub, accessToken, accessTokenExpiresAt, idToken, redirectPath, avatarUrl, organizations } = await validateOauth({ c, clientOptions, }); @@ -57,6 +58,17 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti // Directly log in user if account already exists. if (existingAccount) { + // Sync profile and organizations on each login + await syncDosProfileAndOrgs({ + userId: existingAccount.user.id, + email, + name, + avatarUrl, + organizations, + }).catch((err) => { + console.error('[DOS ID] Error syncing profile on login:', err); + }); + await onAuthorize({ userId: existingAccount.user.id }, c); return c.redirect(redirectPath, 302); @@ -114,6 +126,17 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti } }); + // Sync profile and organizations on account link + await syncDosProfileAndOrgs({ + userId: userWithSameEmail.id, + email, + name, + avatarUrl, + organizations, + }).catch((err) => { + console.error('[DOS ID] Error syncing profile on link:', err); + }); + await onAuthorize({ userId: userWithSameEmail.id }, c); return c.redirect(redirectPath, 302); @@ -179,6 +202,17 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti console.error(err); }); + // Sync profile and organizations for new user + await syncDosProfileAndOrgs({ + userId: createdUser.id, + email, + name, + avatarUrl, + organizations, + }).catch((err) => { + console.error('[DOS ID] Error syncing profile on signup:', err); + }); + await onAuthorize({ userId: createdUser.id }, c); return c.redirect(redirectPath, 302); @@ -238,6 +272,9 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => { const email = claims.email; const name = claims.name; const sub = claims.sub; + const avatarUrl = (typeof claims.picture === 'string' ? claims.picture : typeof claims.avatar_url === 'string' ? claims.avatar_url : null) as string | null; + const rawOrgs = (claims.organizations || claims.orgs || (claims.user_metadata as Record | undefined)?.organizations || (claims.app_metadata as Record | undefined)?.organizations) as DosOrgClaim[] | undefined; + const organizations = Array.isArray(rawOrgs) ? rawOrgs : undefined; if (typeof email !== 'string') { throw new AppError(AuthenticationErrorCode.InvalidRequest, { @@ -271,5 +308,7 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => { accessTokenExpiresAt, idToken, redirectPath, + avatarUrl, + organizations, }; }; diff --git a/packages/lib/server-only/dos-id/handle-dos-webhook.ts b/packages/lib/server-only/dos-id/handle-dos-webhook.ts new file mode 100644 index 0000000000..539f930ba6 --- /dev/null +++ b/packages/lib/server-only/dos-id/handle-dos-webhook.ts @@ -0,0 +1,270 @@ +import { prisma } from '@documenso/prisma'; +import { OrganisationGroupType } from '@prisma/client'; + +import { generateDatabaseId } from '../../universal/id'; +import { deleteOrganisation } from '../organisation/delete-organisation'; +import { + mapDosRoleToOrgRole, + syncOrganisationForUser, + syncUserAvatarFromUrl, +} from './sync-dos-profile'; + +export type DosWebhookPayload = { + event: string; + data?: Record; + // Support flat payload formats as well + org_id?: string; + name?: string; + slug?: string; + owner_id?: string; + owner_email?: string; + avatar_url?: string | null; + org_name?: string; + user_id?: string; + user_email?: string; + role?: string; + email?: string; + display_name?: string; + [key: string]: unknown; +}; + +export const handleDosWebhookEvent = async (payload: DosWebhookPayload): Promise<{ success: boolean; message: string }> => { + const event = (payload.event || '').toLowerCase(); + const data = (payload.data && typeof payload.data === 'object' ? payload.data : payload) as Record; + + switch (event) { + case 'organization.created': + case 'org.created': { + const orgId = (data.org_id || data.id) as string | undefined; + const name = (data.name || data.slug || 'Organization') as string; + const slug = (data.slug || orgId) as string | undefined; + const ownerEmail = (data.owner_email || data.email) as string | undefined; + + if (!ownerEmail) { + return { success: false, message: 'Missing owner_email in payload' }; + } + + let owner = await prisma.user.findFirst({ + where: { email: ownerEmail.toLowerCase() }, + }); + + if (!owner) { + owner = await prisma.user.create({ + data: { + email: ownerEmail.toLowerCase(), + name: (data.owner_name as string) || ownerEmail.split('@')[0], + emailVerified: new Date(), + }, + }); + } + + await syncOrganisationForUser({ + userId: owner.id, + org: { + org_id: orgId, + name, + slug, + role: 'ADMIN', + avatar_url: data.avatar_url as string | undefined, + }, + }); + + return { success: true, message: 'Organization created successfully' }; + } + + case 'organization.updated': + case 'org.updated': { + const orgId = (data.org_id || data.id) as string | undefined; + const slug = data.slug as string | undefined; + const name = data.name as string | undefined; + + const org = await prisma.organisation.findFirst({ + where: { + OR: [ + ...(orgId ? [{ id: orgId }] : []), + ...(slug ? [{ url: slug }] : []), + ], + }, + }); + + if (!org) { + return { success: false, message: 'Organization not found' }; + } + + await prisma.organisation.update({ + where: { id: org.id }, + data: { + ...(name ? { name } : {}), + ...(slug ? { url: slug } : {}), + }, + }); + + return { success: true, message: 'Organization updated successfully' }; + } + + case 'organization.deleted': + case 'org.deleted': { + const orgId = (data.org_id || data.id) as string | undefined; + const slug = data.slug as string | undefined; + + const org = await prisma.organisation.findFirst({ + where: { + OR: [ + ...(orgId ? [{ id: orgId }] : []), + ...(slug ? [{ url: slug }] : []), + ], + }, + include: { + teams: { select: { id: true } }, + subscription: { select: { planId: true } }, + }, + }); + + if (!org) { + return { success: false, message: 'Organization not found for deletion' }; + } + + await deleteOrganisation({ + organisation: { + id: org.id, + teams: org.teams, + subscription: org.subscription, + }, + }); + + return { success: true, message: 'Organization deleted successfully' }; + } + + case 'organization.member_added': + case 'org.member_added': { + const orgId = (data.org_id || data.id) as string | undefined; + const userEmail = (data.user_email || data.email) as string | undefined; + const role = data.role as string | undefined; + + if (!userEmail || !orgId) { + return { success: false, message: 'Missing user_email or org_id' }; + } + + const org = await prisma.organisation.findFirst({ + where: { id: orgId }, + include: { groups: true }, + }); + + if (!org) { + return { success: false, message: 'Organization not found' }; + } + + let user = await prisma.user.findFirst({ + where: { email: userEmail.toLowerCase() }, + }); + + if (!user) { + user = await prisma.user.create({ + data: { + email: userEmail.toLowerCase(), + name: (data.user_name as string) || userEmail.split('@')[0], + emailVerified: new Date(), + }, + }); + } + + const orgRole = mapDosRoleToOrgRole(role); + const targetGroup = org.groups.find( + (group) => + group.type === OrganisationGroupType.INTERNAL_ORGANISATION && group.organisationRole === orgRole, + ); + + const existingMember = await prisma.organisationMember.findUnique({ + where: { + userId_organisationId: { + userId: user.id, + organisationId: org.id, + }, + }, + }); + + if (!existingMember && targetGroup) { + await prisma.organisationMember.create({ + data: { + id: generateDatabaseId('member'), + userId: user.id, + organisationId: org.id, + organisationGroupMembers: { + create: { + id: generateDatabaseId('group_member'), + groupId: targetGroup.id, + }, + }, + }, + }); + } + + return { success: true, message: 'Member added successfully' }; + } + + case 'organization.member_removed': + case 'org.member_removed': { + const orgId = (data.org_id || data.id) as string | undefined; + const userEmail = (data.user_email || data.email) as string | undefined; + + if (!userEmail || !orgId) { + return { success: false, message: 'Missing user_email or org_id' }; + } + + const user = await prisma.user.findFirst({ + where: { email: userEmail.toLowerCase() }, + }); + + if (!user) { + return { success: true, message: 'User not found, nothing to remove' }; + } + + await prisma.organisationMember + .delete({ + where: { + userId_organisationId: { + userId: user.id, + organisationId: orgId, + }, + }, + }) + .catch(() => null); + + return { success: true, message: 'Member removed successfully' }; + } + + case 'user.updated': { + const email = (data.email || data.user_email) as string | undefined; + const displayName = (data.display_name || data.name) as string | undefined; + const avatarUrl = data.avatar_url as string | undefined; + + if (!email) { + return { success: false, message: 'Missing email in user.updated' }; + } + + const user = await prisma.user.findFirst({ + where: { email: email.toLowerCase() }, + }); + + if (!user) { + return { success: true, message: 'User not found in sign schema' }; + } + + if (displayName && displayName !== user.name) { + await prisma.user.update({ + where: { id: user.id }, + data: { name: displayName }, + }); + } + + if (avatarUrl) { + await syncUserAvatarFromUrl(user.id, avatarUrl); + } + + return { success: true, message: 'User updated successfully' }; + } + + default: + return { success: true, message: `Ignored unhandled event: ${event}` }; + } +}; diff --git a/packages/lib/server-only/dos-id/sync-dos-profile.ts b/packages/lib/server-only/dos-id/sync-dos-profile.ts new file mode 100644 index 0000000000..1acce5511c --- /dev/null +++ b/packages/lib/server-only/dos-id/sync-dos-profile.ts @@ -0,0 +1,292 @@ +import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim'; +import { prisma } from '@documenso/prisma'; +import { OrganisationGroupType, OrganisationMemberRole, OrganisationType, Prisma } from '@prisma/client'; + +import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations'; +import { INTERNAL_CLAIM_ID } from '../../types/subscription'; +import { generateDatabaseId, prefixedId } from '../../universal/id'; +import { optimiseAvatar } from '../../utils/images/avatar'; +import { generateDefaultOrganisationSettings } from '../../utils/organisations'; +import { createOrganisationClaimUpsertData } from '../organisation/create-organisation'; +import { createTeam } from '../team/create-team'; + +export type DosOrgClaim = { + org_id?: string; + id?: string; + name?: string; + slug?: string; + role?: string; + avatar_url?: string | null; + owner_id?: string; + owner_email?: string; +}; + +export type SyncDosUserOptions = { + userId: number; + email: string; + name?: string | null; + avatarUrl?: string | null; + organizations?: DosOrgClaim[]; +}; + +/** + * Downloads and sets a user avatar from an external URL if provided. + */ +export const syncUserAvatarFromUrl = async (userId: number, avatarUrl: string): Promise => { + try { + const response = await fetch(avatarUrl, { + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + return null; + } + + const arrayBuffer = await response.arrayBuffer(); + const base64Bytes = Buffer.from(arrayBuffer).toString('base64'); + const optimisedBuffer = await optimiseAvatar(base64Bytes); + + const avatarImage = await prisma.avatarImage.create({ + data: { + bytes: optimisedBuffer.toString('base64'), + }, + }); + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { avatarImageId: true }, + }); + + const oldAvatarId = user?.avatarImageId; + + await prisma.user.update({ + where: { id: userId }, + data: { + avatarImageId: avatarImage.id, + }, + }); + + if (oldAvatarId) { + await prisma.avatarImage + .delete({ + where: { id: oldAvatarId }, + }) + .catch(() => null); + } + + return avatarImage.id; + } catch (error) { + console.error(`[DOS ID] Failed to sync avatar for user ${userId}:`, error); + return null; + } +}; + +/** + * Maps DOS ID role string to Documenso OrganisationMemberRole enum. + */ +export const mapDosRoleToOrgRole = (role?: string): OrganisationMemberRole => { + const normalized = (role ?? '').toUpperCase().trim(); + + if (normalized === 'OWNER' || normalized === 'ADMIN') { + return OrganisationMemberRole.ADMIN; + } + + if (normalized === 'MANAGER') { + return OrganisationMemberRole.MANAGER; + } + + return OrganisationMemberRole.MEMBER; +}; + +/** + * Provision or join an organization for a user given DOS ID claims. + */ +export const syncOrganisationForUser = async ({ + userId, + org, +}: { + userId: number; + org: DosOrgClaim; +}) => { + const rawOrgId = org.org_id || org.id; + const orgName = org.name || org.slug || 'Organization'; + const orgUrl = org.slug || rawOrgId || prefixedId('org'); + const orgIdToUse = rawOrgId || prefixedId('org'); + const memberRole = mapDosRoleToOrgRole(org.role); + + // Check if organization already exists in sign schema + const existingOrg = await prisma.organisation.findFirst({ + where: { + OR: [{ id: orgIdToUse }, { url: orgUrl }], + }, + include: { + groups: true, + members: { + where: { userId }, + }, + }, + }); + + if (existingOrg) { + const isAlreadyMember = existingOrg.members.length > 0; + + if (!isAlreadyMember) { + const targetGroup = existingOrg.groups.find( + (group) => + group.type === OrganisationGroupType.INTERNAL_ORGANISATION && group.organisationRole === memberRole, + ); + + if (targetGroup) { + await prisma.organisationMember.create({ + data: { + id: generateDatabaseId('member'), + userId, + organisationId: existingOrg.id, + organisationGroupMembers: { + create: { + id: generateDatabaseId('group_member'), + groupId: targetGroup.id, + }, + }, + }, + }); + } + } + + return existingOrg; + } + + // Create new organization with free claim + const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE); + + const newOrg = await prisma.$transaction(async (tx) => { + const organisationSetting = await tx.organisationGlobalSettings.create({ + data: { + ...generateDefaultOrganisationSettings(), + defaultRecipients: Prisma.DbNull, + id: generateDatabaseId('org_setting'), + }, + }); + + const organisationClaim = await tx.organisationClaim.create({ + data: { + id: generateDatabaseId('org_claim'), + originalSubscriptionClaimId: freeSubscriptionClaim.id, + ...createOrganisationClaimUpsertData(freeSubscriptionClaim), + }, + }); + + const organisationAuthenticationPortal = await tx.organisationAuthenticationPortal.create({ + data: { + id: generateDatabaseId('org_sso'), + enabled: false, + clientId: '', + clientSecret: '', + wellKnownUrl: '', + }, + }); + + const createdOrg = await tx.organisation.create({ + data: { + id: orgIdToUse, + name: orgName, + type: OrganisationType.ORGANISATION, + url: orgUrl, + ownerUserId: userId, + organisationGlobalSettingsId: organisationSetting.id, + organisationClaimId: organisationClaim.id, + organisationAuthenticationPortalId: organisationAuthenticationPortal.id, + groups: { + create: ORGANISATION_INTERNAL_GROUPS.map((group) => ({ + ...group, + id: generateDatabaseId('org_group'), + })), + }, + }, + include: { + groups: true, + }, + }); + + const adminGroup = createdOrg.groups.find( + (group) => group.organisationRole === OrganisationMemberRole.ADMIN, + ); + + if (adminGroup) { + await tx.organisationMember.create({ + data: { + id: generateDatabaseId('member'), + userId, + organisationId: createdOrg.id, + organisationGroupMembers: { + create: { + id: generateDatabaseId('group_member'), + groupId: adminGroup.id, + }, + }, + }, + }); + } + + return createdOrg; + }); + + // Create default team for the newly provisioned organization + await createTeam({ + userId, + teamName: 'General', + teamUrl: prefixedId('team'), + organisationId: newOrg.id, + inheritMembers: true, + }).catch((err) => { + console.error(`[DOS ID] Failed to create default team for org ${newOrg.id}:`, err); + }); + + return newOrg; +}; + +/** + * Main JIT sync function triggered on OIDC callback. + */ +export const syncDosProfileAndOrgs = async ({ + userId, + email, + name, + avatarUrl, + organizations, +}: SyncDosUserOptions) => { + // 1. Sync Name + if (name) { + await prisma.user + .update({ + where: { id: userId }, + data: { name }, + }) + .catch((err) => { + console.error(`[DOS ID] Failed to update user name for user ${userId}:`, err); + }); + } + + // 2. Sync Avatar if URL is provided + if (avatarUrl) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { avatarImageId: true }, + }); + + if (!user?.avatarImageId) { + await syncUserAvatarFromUrl(userId, avatarUrl); + } + } + + // 3. JIT Provision Organizations from claims + if (Array.isArray(organizations) && organizations.length > 0) { + for (const org of organizations) { + if (org && typeof org === 'object') { + await syncOrganisationForUser({ userId, org }).catch((err) => { + console.error(`[DOS ID] Failed to JIT provision org for user ${userId}:`, err); + }); + } + } + } +}; diff --git a/packages/lib/server-only/dos-id/verify-dos-signature.ts b/packages/lib/server-only/dos-id/verify-dos-signature.ts new file mode 100644 index 0000000000..1a3bf9e18d --- /dev/null +++ b/packages/lib/server-only/dos-id/verify-dos-signature.ts @@ -0,0 +1,39 @@ +import crypto from 'node:crypto'; + +/** + * Verify HMAC-SHA256 signature from DOS.Me webhook dispatcher. + * Expected Header format: `X-DOS-Signature: sha256=` or ``. + */ +export const verifyDosWebhookSignature = ({ + rawBody, + signatureHeader, + secret, +}: { + rawBody: string; + signatureHeader: string | null; + secret: string; +}): boolean => { + if (!signatureHeader || !secret) { + return false; + } + + const expectedPrefix = 'sha256='; + const providedHash = signatureHeader.startsWith(expectedPrefix) + ? signatureHeader.slice(expectedPrefix.length) + : signatureHeader; + + const computedHash = crypto + .createHmac('sha256', secret) + .update(rawBody, 'utf8') + .digest('hex'); + + if (providedHash.length !== computedHash.length) { + return false; + } + + try { + return crypto.timingSafeEqual(Buffer.from(providedHash), Buffer.from(computedHash)); + } catch { + return false; + } +}; diff --git a/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql b/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql index d001bc4ae0..55a77809fc 100644 --- a/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql +++ b/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql @@ -1,9 +1,9 @@ -- Create deleted@documenso.com DO $$ BEGIN - IF NOT EXISTS (SELECT 1 FROM "public"."User" WHERE "email" = 'deleted-account@documenso.com') THEN + IF NOT EXISTS (SELECT 1 FROM "User" WHERE "email" = 'deleted-account@documenso.com') THEN INSERT INTO - "public"."User" ( + "User" ( "email", "emailVerified", "password", @@ -22,8 +22,8 @@ BEGIN NOW(), NOW(), NOW(), - ARRAY['USER'::TEXT]::"public"."Role" [], - CAST('GOOGLE'::TEXT AS "public"."IdentityProvider"), + ARRAY['USER'::TEXT]::"Role" [], + CAST('GOOGLE'::TEXT AS "IdentityProvider"), FALSE ); END IF; diff --git a/packages/prisma/migrations/20250522054049_add_id_generator/migration.sql b/packages/prisma/migrations/20250522054049_add_id_generator/migration.sql index 02a953ff02..6ddac687b0 100644 --- a/packages/prisma/migrations/20250522054049_add_id_generator/migration.sql +++ b/packages/prisma/migrations/20250522054049_add_id_generator/migration.sql @@ -36,6 +36,7 @@ CREATE OR REPLACE FUNCTION nanoid( LANGUAGE plpgsql VOLATILE PARALLEL SAFE + SET search_path = sign, extensions, public -- Uncomment the following line if you have superuser privileges -- LEAKPROOF AS @@ -85,6 +86,7 @@ CREATE OR REPLACE FUNCTION nanoid_optimized( LANGUAGE plpgsql VOLATILE PARALLEL SAFE + SET search_path = sign, extensions, public -- Uncomment the following line if you have superuser privileges -- LEAKPROOF AS @@ -101,7 +103,7 @@ BEGIN alphabetLength := array_length(alphabetArray, 1); LOOP - bytes := gen_random_bytes(step); + bytes := extensions.gen_random_bytes(step); FOR counter IN 0..step - 1 LOOP alphabetIndex := (get_byte(bytes, counter) & mask) + 1; @@ -122,7 +124,7 @@ RETURNS TEXT AS $$ BEGIN RETURN prefix || '_' || nanoid(16, 'abcdefhiklmnorstuvwxyz'); END; -$$ LANGUAGE plpgsql VOLATILE; +$$ LANGUAGE plpgsql VOLATILE SET search_path = sign, extensions, public; -- CUSTOM FUNCTION FOR GENERIC IDS CREATE OR REPLACE FUNCTION generate_id() @@ -130,4 +132,4 @@ RETURNS TEXT AS $$ BEGIN RETURN nanoid(16, 'abcdefhiklmnorstuvwxyz'); END; -$$ LANGUAGE plpgsql VOLATILE; +$$ LANGUAGE plpgsql VOLATILE SET search_path = sign, extensions, public;