From 8803572ddb1f0c41b606969c71b6dca4532c59d2 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:56:09 +0700 Subject: [PATCH 1/2] feat(dos-id): sync organisation avatars from DOS ID syncOrganisationForUser received the org avatar_url claim but dropped it on the floor, so organisations with an upstream avatar (e.g. the JOY org on prod) rendered as initials in Sign. Add syncOrganisationAvatarFromUrl (mirrors the user variant: SSRF-guarded fetch, create-swap-delete, fails soft) and wire it into the JIT org sync (existing and newly provisioned orgs) plus the org.updated webhook; org.created already routes through syncOrganisationForUser. Same source-of-truth policy as the user avatar: when DOS ID provides a URL it replaces the stored avatar on every sync. --- .../server-only/dos-id/handle-dos-webhook.ts | 6 ++ .../server-only/dos-id/sync-dos-profile.ts | 102 +++++++++++++++--- 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/packages/lib/server-only/dos-id/handle-dos-webhook.ts b/packages/lib/server-only/dos-id/handle-dos-webhook.ts index ac202114bc..310dd093ed 100644 --- a/packages/lib/server-only/dos-id/handle-dos-webhook.ts +++ b/packages/lib/server-only/dos-id/handle-dos-webhook.ts @@ -6,6 +6,7 @@ import { deleteOrganisation } from '../organisation/delete-organisation'; import { createTeam } from '../team/create-team'; import { mapDosRoleToOrgRole, + syncOrganisationAvatarFromUrl, syncOrganisationForUser, syncTeamForUser, syncUserAvatarFromUrl, @@ -102,6 +103,7 @@ export const handleDosWebhookEvent = async ( 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 avatarUrl = (data.avatar_url || data.picture) as string | undefined; // An empty where clause must never reach Prisma: a missing id AND slug // is a malformed payload, not an entity to resolve. @@ -127,6 +129,10 @@ export const handleDosWebhookEvent = async ( }, }); + if (avatarUrl) { + await syncOrganisationAvatarFromUrl(org.id, avatarUrl); + } + return { success: true, message: 'Organization updated successfully' }; } diff --git a/packages/lib/server-only/dos-id/sync-dos-profile.ts b/packages/lib/server-only/dos-id/sync-dos-profile.ts index c51c5acf80..bdb0875e59 100644 --- a/packages/lib/server-only/dos-id/sync-dos-profile.ts +++ b/packages/lib/server-only/dos-id/sync-dos-profile.ts @@ -48,31 +48,44 @@ export type SyncDosUserOptions = { activeOrgId?: string; }; +/** + * Downloads an avatar from an external URL and returns the optimised bytes + * as base64, or null when the fetch or optimisation fails. The URL comes + * from OIDC claims and webhook payloads, so it is attacker-influenceable: + * guard it with the same SSRF checks as webhooks before the server fetches. + */ +const fetchOptimisedAvatarBase64 = async (avatarUrl: string): Promise => { + await assertNotPrivateUrl(avatarUrl); + + 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); + + return optimisedBuffer.toString('base64'); +}; + /** * Downloads and sets a user avatar from an external URL if provided. */ export const syncUserAvatarFromUrl = async (userId: number, avatarUrl: string): Promise => { try { - // The URL comes from OIDC claims and webhook payloads, so it is - // attacker-influenceable: guard it with the same SSRF checks as webhooks - // before the server fetches it. - await assertNotPrivateUrl(avatarUrl); + const bytes = await fetchOptimisedAvatarBase64(avatarUrl); - const response = await fetch(avatarUrl, { - signal: AbortSignal.timeout(5000), - }); - - if (!response.ok) { + if (!bytes) { 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'), + bytes, }, }); @@ -105,6 +118,57 @@ export const syncUserAvatarFromUrl = async (userId: number, avatarUrl: string): } }; +/** + * Downloads and sets an organisation avatar from an external URL. Mirrors the + * user variant: the IdP URL is the source of truth, refreshed on every sync, + * and any error fails soft so logins never break on avatar errors. + */ +export const syncOrganisationAvatarFromUrl = async ( + organisationId: string, + avatarUrl: string, +): Promise => { + try { + const bytes = await fetchOptimisedAvatarBase64(avatarUrl); + + if (!bytes) { + return null; + } + + const avatarImage = await prisma.avatarImage.create({ + data: { + bytes, + }, + }); + + const organisation = await prisma.organisation.findUnique({ + where: { id: organisationId }, + select: { avatarImageId: true }, + }); + + const oldAvatarId = organisation?.avatarImageId; + + await prisma.organisation.update({ + where: { id: organisationId }, + 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 organisation ${organisationId}:`, error); + return null; + } +}; + /** * Maps DOS ID role string to Documenso OrganisationMemberRole enum. */ @@ -187,6 +251,12 @@ export const syncOrganisationForUser = async ({ userId, org }: { userId: number; } } + // The IdP org avatar is the source of truth (same policy as the user + // avatar): refresh whenever DOS ID provides a URL. + if (org.avatar_url) { + await syncOrganisationAvatarFromUrl(existingOrg.id, org.avatar_url); + } + return existingOrg; } @@ -274,6 +344,10 @@ export const syncOrganisationForUser = async ({ userId, org }: { userId: number; console.error(`[DOS ID] Failed to create default team for org ${newOrg.id}:`, err); }); + if (org.avatar_url) { + await syncOrganisationAvatarFromUrl(newOrg.id, org.avatar_url); + } + return newOrg; }; From 5bfff2ad7d3d2c79555e6483611a7cc0ffdca298 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:52:15 +0700 Subject: [PATCH 2/2] fix(dos-id): make org avatar sync fire-and-forget and accept the picture alias Address review findings: the JIT org avatar sync is now fire-and-forget so the OAuth redirect latency stays independent of the org count (the sync logs its own failures), and the org.created webhook accepts the picture alias alongside avatar_url like the org.updated and user paths. --- packages/lib/server-only/dos-id/handle-dos-webhook.ts | 2 +- packages/lib/server-only/dos-id/sync-dos-profile.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/lib/server-only/dos-id/handle-dos-webhook.ts b/packages/lib/server-only/dos-id/handle-dos-webhook.ts index 310dd093ed..e0d597d324 100644 --- a/packages/lib/server-only/dos-id/handle-dos-webhook.ts +++ b/packages/lib/server-only/dos-id/handle-dos-webhook.ts @@ -91,7 +91,7 @@ export const handleDosWebhookEvent = async ( name, slug, role: 'ADMIN', - avatar_url: data.avatar_url as string | undefined, + avatar_url: (data.avatar_url || data.picture) as string | undefined, }, }); diff --git a/packages/lib/server-only/dos-id/sync-dos-profile.ts b/packages/lib/server-only/dos-id/sync-dos-profile.ts index bdb0875e59..99fc7bf556 100644 --- a/packages/lib/server-only/dos-id/sync-dos-profile.ts +++ b/packages/lib/server-only/dos-id/sync-dos-profile.ts @@ -252,9 +252,11 @@ export const syncOrganisationForUser = async ({ userId, org }: { userId: number; } // The IdP org avatar is the source of truth (same policy as the user - // avatar): refresh whenever DOS ID provides a URL. + // avatar): refresh whenever DOS ID provides a URL. Fire-and-forget so + // the OAuth redirect latency stays independent of the org count - the + // avatar lands a moment after login and failures are logged inside. if (org.avatar_url) { - await syncOrganisationAvatarFromUrl(existingOrg.id, org.avatar_url); + void syncOrganisationAvatarFromUrl(existingOrg.id, org.avatar_url); } return existingOrg; @@ -345,7 +347,7 @@ export const syncOrganisationForUser = async ({ userId, org }: { userId: number; }); if (org.avatar_url) { - await syncOrganisationAvatarFromUrl(newOrg.id, org.avatar_url); + void syncOrganisationAvatarFromUrl(newOrg.id, org.avatar_url); } return newOrg;