Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import { normalizeExternalAvatarUrl } from '../utils'

describe('normalizeExternalAvatarUrl', () => {
it('accepts absolute http(s) URLs', () => {
expect(normalizeExternalAvatarUrl('https://idp.example.com/avatar.png')).toBe('https://idp.example.com/avatar.png')
expect(normalizeExternalAvatarUrl(' http://idp.example.com/a?s=96 ')).toBe('http://idp.example.com/a?s=96')
})

it('rejects non-string and empty values', () => {
expect(normalizeExternalAvatarUrl(undefined)).toBeUndefined()
expect(normalizeExternalAvatarUrl(null)).toBeUndefined()
expect(normalizeExternalAvatarUrl(42)).toBeUndefined()
expect(normalizeExternalAvatarUrl({ url: 'https://idp.example.com/a.png' })).toBeUndefined()
expect(normalizeExternalAvatarUrl(' ')).toBeUndefined()
})

it('rejects relative URLs and other protocols', () => {
expect(normalizeExternalAvatarUrl('/avatar.png')).toBeUndefined()
expect(normalizeExternalAvatarUrl('javascript:alert(1)')).toBeUndefined()
expect(normalizeExternalAvatarUrl('data:image/png;base64,AAAA')).toBeUndefined()
expect(normalizeExternalAvatarUrl('file:///etc/passwd')).toBeUndefined()
expect(normalizeExternalAvatarUrl('ftp://idp.example.com/a.png')).toBeUndefined()
})

it('rejects URLs with credentials or excessive length', () => {
expect(normalizeExternalAvatarUrl('https://user:pass@idp.example.com/a.png')).toBeUndefined()
expect(normalizeExternalAvatarUrl('https://idp.example.com/' + 'a'.repeat(3000))).toBeUndefined()
})
})
2 changes: 2 additions & 0 deletions foundations/core/packages/core/src/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,8 @@ export interface Person {
uuid: PersonUuid
firstName: string
lastName: string
/** External avatar URL provided by an identity provider (OIDC/OAuth), if any. */
avatarUrl?: string
}

export interface PersonInfo extends BasePerson {
Expand Down
25 changes: 25 additions & 0 deletions foundations/core/packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1018,3 +1018,28 @@ export function toRank (str: string | undefined): Rank | undefined {
export function escapeRegExp (value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

const MAX_EXTERNAL_AVATAR_URL_LENGTH = 2048

/**
* Returns a normalized http(s) URL suitable for an external avatar, or undefined
* when the value is not a string, not an absolute http(s) URL, carries credentials
* or is unreasonably long. The URL is never fetched here.
*/
export function normalizeExternalAvatarUrl (value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (trimmed === '' || trimmed.length > MAX_EXTERNAL_AVATAR_URL_LENGTH) return undefined

let url: URL
try {
url = new URL(trimmed)
} catch {
return undefined
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined
if (url.username !== '' || url.password !== '') return undefined

const normalized = url.toString()
return normalized.length > MAX_EXTERNAL_AVATAR_URL_LENGTH ? undefined : normalized
}
2 changes: 1 addition & 1 deletion models/contact/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,7 @@ export function createModel (builder: Builder): void {
type: AvatarType.EXTERNAL,
getUrl: contact.function.GetExternalUrl
},
contact.avatarProvider.Color
contact.avatarProvider.External
)

builder.createDoc(
Expand Down
3 changes: 2 additions & 1 deletion plugins/contact-resources/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
//
/* eslint-disable @typescript-eslint/no-unused-vars */
import {
getExternalAvatarUrl,
getGravatarUrl,
getName,
type AvatarInfo,
Expand Down Expand Up @@ -458,7 +459,7 @@ export default async (): Promise<Resources> => ({
}),
GetExternalUrl: async (person: Data<WithLookup<AvatarInfo>>, name: string) => ({
color: getPersonColor(person, name),
url: person.avatarProps?.url
url: getExternalAvatarUrl(person)
}),
EmployeeSort: employeeSort,
FilterChannelInResult: filterChannelInResult,
Expand Down
90 changes: 90 additions & 0 deletions plugins/contact/src/__tests__/providerAvatar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import contact, { AvatarType } from '..'
import { getAvatarProviderId, getExternalAvatarUrl, getProviderAvatarUpdate } from '../utils'

const url = 'https://idp.example.com/avatar.png'
const external = { avatarType: AvatarType.EXTERNAL, avatarProps: { url } }

describe('getProviderAvatarUpdate', () => {
it('uses the provider avatar for a new person', () => {
expect(getProviderAvatarUpdate(undefined, url)).toEqual(external)
})

it('ignores missing or non-http avatar urls', () => {
expect(getProviderAvatarUpdate(undefined, undefined)).toBeUndefined()
expect(getProviderAvatarUpdate(undefined, 'javascript:alert(1)')).toBeUndefined()
expect(getProviderAvatarUpdate({ avatarType: AvatarType.COLOR }, 'ftp://idp.example.com/a.png')).toBeUndefined()
})

it('replaces the default color avatar', () => {
expect(getProviderAvatarUpdate({ avatarType: AvatarType.COLOR }, url)).toEqual(external)
expect(getProviderAvatarUpdate({ avatarType: AvatarType.COLOR, avatar: null, avatarProps: {} }, url)).toEqual(
external
)
})

it('refreshes a previously synced external avatar', () => {
expect(
getProviderAvatarUpdate(
{ avatarType: AvatarType.EXTERNAL, avatarProps: { url: 'https://old.example.com/a' } },
url
)
).toEqual(external)
expect(getProviderAvatarUpdate(external, url)).toBeUndefined()
})

it('keeps an avatar chosen by the user', () => {
expect(
getProviderAvatarUpdate({ avatarType: AvatarType.COLOR, avatarProps: { color: 'Sky' } }, url)
).toBeUndefined()
expect(getProviderAvatarUpdate({ avatarType: AvatarType.IMAGE, avatar: 'blob' as any }, url)).toBeUndefined()
expect(
getProviderAvatarUpdate({ avatarType: AvatarType.GRAVATAR, avatarProps: { url: 'gravatar-id' } }, url)
).toBeUndefined()
})
})

describe('getAvatarProviderId', () => {
it('resolves every avatar type to its own provider', () => {
expect(getAvatarProviderId(AvatarType.COLOR)).toBe(contact.avatarProvider.Color)
expect(getAvatarProviderId(AvatarType.IMAGE)).toBe(contact.avatarProvider.Image)
expect(getAvatarProviderId(AvatarType.GRAVATAR)).toBe(contact.avatarProvider.Gravatar)
expect(getAvatarProviderId(AvatarType.EXTERNAL)).toBe(contact.avatarProvider.External)
})

it('uses distinct provider ids', () => {
const ids = Object.values(AvatarType).map((type) => getAvatarProviderId(type))
expect(new Set(ids).size).toBe(ids.length)
})
})

describe('getExternalAvatarUrl', () => {
it('returns http(s) avatar urls', () => {
expect(getExternalAvatarUrl({ avatarProps: { url } })).toBe(url)
expect(getExternalAvatarUrl({ avatarProps: { url: 'http://avatars.example.com/u/1?v=4' } })).toBe(
'http://avatars.example.com/u/1?v=4'
)
})

it('never returns unsafe or missing urls', () => {
expect(getExternalAvatarUrl({})).toBeUndefined()
expect(getExternalAvatarUrl({ avatarProps: {} })).toBeUndefined()
expect(getExternalAvatarUrl({ avatarProps: { url: 'javascript:alert(1)' } })).toBeUndefined()
expect(getExternalAvatarUrl({ avatarProps: { url: 'data:image/svg+xml;base64,AAAA' } })).toBeUndefined()
expect(getExternalAvatarUrl({ avatarProps: { url: '/files/avatar.png' } })).toBeUndefined()
})
})
3 changes: 2 additions & 1 deletion plugins/contact/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ export const contactPlugin = plugin(contactId, {
avatarProvider: {
Color: '' as Ref<AvatarProvider>,
Image: '' as Ref<AvatarProvider>,
Gravatar: '' as Ref<AvatarProvider>
Gravatar: '' as Ref<AvatarProvider>,
External: '' as Ref<AvatarProvider>
},
function: {
GetColorUrl: '' as Resource<GetAvatarUrl>,
Expand Down
49 changes: 48 additions & 1 deletion plugins/contact/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
generateId,
Hierarchy,
MeasureContext,
normalizeExternalAvatarUrl,
notEmpty,
Person as GlobalPerson,
PersonId,
Expand All @@ -39,6 +40,7 @@ import {
import platform, { getMetadata, PlatformError } from '@hcengineering/platform'
import { ColorDefinition } from '@hcengineering/ui'
import contact, {
AvatarInfo,
AvatarProvider,
AvatarType,
Channel,
Expand Down Expand Up @@ -137,6 +139,8 @@ export function getAvatarProviderId (kind: AvatarType): Ref<AvatarProvider> | un
return contact.avatarProvider.Gravatar
case AvatarType.COLOR:
return contact.avatarProvider.Color
case AvatarType.EXTERNAL:
return contact.avatarProvider.External
}
return contact.avatarProvider.Image
}
Expand Down Expand Up @@ -415,6 +419,40 @@ export async function getAllUserAccounts (client: Client): Promise<AccountUuid[]
return employees.map((it) => it.personUuid).filter(notEmpty)
}

/**
* Returns the image URL of an external avatar, or undefined when the stored value is not
* an absolute http(s) URL (so e.g. javascript: or data: values are never used as image source).
*/
export function getExternalAvatarUrl (person: Pick<AvatarInfo, 'avatarProps'>): string | undefined {
return normalizeExternalAvatarUrl(person.avatarProps?.url)
}

/**
* Returns the avatar fields to apply for an avatar URL provided by an identity provider
* (OIDC/OAuth), or undefined when nothing should change. Only the default color avatar and a
* previously synced external avatar are replaced; an uploaded image, Gravatar or an explicitly
* chosen color are kept. Pass undefined as person for a person that is about to be created.
*/
export function getProviderAvatarUpdate (
person: Pick<AvatarInfo, 'avatarType' | 'avatar' | 'avatarProps'> | undefined,
avatarUrl: string | undefined
): Pick<AvatarInfo, 'avatarType' | 'avatarProps'> | undefined {
const url = normalizeExternalAvatarUrl(avatarUrl)
if (url === undefined) return undefined
const update = { avatarType: AvatarType.EXTERNAL, avatarProps: { url } }
if (person === undefined) return update

if (person.avatarType === AvatarType.EXTERNAL) {
return person.avatarProps?.url === url ? undefined : update
}

const isDefaultColor =
(person.avatarType ?? AvatarType.COLOR) === AvatarType.COLOR &&
person.avatar == null &&
(person.avatarProps?.color ?? '') === ''
return isDefaultColor ? update : undefined
}

export async function ensureEmployee (
ctx: MeasureContext,
me: Account,
Expand Down Expand Up @@ -462,7 +500,8 @@ export async function ensureEmployeeForPerson (
personUuid: person.uuid,
name: combineName(globalPerson.firstName, globalPerson.lastName),
city: '',
avatarType: AvatarType.COLOR
avatarType: AvatarType.COLOR,
...getProviderAvatarUpdate(undefined, globalPerson.avatarUrl)
}
personRef = generateId()

Expand All @@ -480,6 +519,14 @@ export async function ensureEmployeeForPerson (
personUuid: person.uuid
})
await client.tx(updatePersonTx)
} else {
// Keep the avatar in sync with the identity provider unless the user has chosen one
const avatarUpdate = getProviderAvatarUpdate(personByUuid, globalPerson?.avatarUrl)
if (avatarUpdate !== undefined) {
await client.tx(
txFactory.createTxUpdateDoc(contact.class.Person, contact.space.Contacts, personByUuid._id, avatarUpdate)
)
}
}

const existingIdentifiers = toIdMap(
Expand Down
48 changes: 48 additions & 0 deletions pods/authProviders/src/__tests__/providerAvatar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import { getProviderAvatarUrl } from '../utils'

describe('getProviderAvatarUrl', () => {
test('reads the OIDC picture claim', () => {
expect(getProviderAvatarUrl({ sub: 'user', picture: 'https://idp.example.com/u/1.png' })).toBe(
'https://idp.example.com/u/1.png'
)
})

test('reads the first passport profile photo (Google, GitHub)', () => {
expect(
getProviderAvatarUrl({
photos: [{ value: 'https://avatars.example.com/u/1?v=4' }, { value: 'https://other.example.com/2' }]
})
).toBe('https://avatars.example.com/u/1?v=4')
})

test('falls back to photos when the picture claim is not a usable url', () => {
expect(
getProviderAvatarUrl({ picture: 'javascript:alert(1)', photos: [{ value: 'http://idp.example.com/a' }] })
).toBe('http://idp.example.com/a')
})

test('returns undefined when there is no usable avatar', () => {
expect(getProviderAvatarUrl(undefined)).toBeUndefined()
expect(getProviderAvatarUrl('user')).toBeUndefined()
expect(getProviderAvatarUrl({})).toBeUndefined()
expect(getProviderAvatarUrl({ picture: '' })).toBeUndefined()
expect(getProviderAvatarUrl({ picture: { url: 'https://idp.example.com/a' } })).toBeUndefined()
expect(getProviderAvatarUrl({ photos: 'https://idp.example.com/a' })).toBeUndefined()
expect(getProviderAvatarUrl({ photos: [{ value: 'data:image/png;base64,AAAA' }] })).toBeUndefined()
})
})
Loading
Loading