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..e0d597d324 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, @@ -90,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, }, }); @@ -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..99fc7bf556 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,14 @@ 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. 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) { + void syncOrganisationAvatarFromUrl(existingOrg.id, org.avatar_url); + } + return existingOrg; } @@ -274,6 +346,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) { + void syncOrganisationAvatarFromUrl(newOrg.id, org.avatar_url); + } + return newOrg; };