UBERF-13511: Support guest tokens with open account (#9834)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev
2025-09-11 22:49:33 +07:00
committed by GitHub
parent 70ca4b1420
commit c0fa4b7299
19 changed files with 510 additions and 72 deletions
+14
View File
@@ -97,6 +97,14 @@ export interface AccountClient {
) => Promise<WorkspaceLoginInfo>
join: (email: string, password: string, inviteId: string, workspaceUrl: string) => Promise<WorkspaceLoginInfo>
createInvite: (exp: number, emailMask: string, limit: number, role: AccountRole) => Promise<string>
/**
* @param options.personalized
* If true, will generate a link with a personalized token for one person access
* If false, will generate a link with an open-ended account in the token. Every token use will generate a new account.
* When false, notBefore and expiration parameters are mandatory.
* @param options.notBefore - not valid before; timestamp in seconds
* @param options.expiration - expires after; timestamp in seconds
*/
createAccessLink: (
role: AccountRole,
options?: {
@@ -105,6 +113,9 @@ export interface AccountClient {
navigateUrl?: string
extra?: Record<string, any>
spaces?: string[]
notBefore?: number
expiration?: number
personalized?: boolean
}
) => Promise<string>
checkJoin: (inviteId: string) => Promise<WorkspaceLoginInfo>
@@ -435,6 +446,9 @@ class AccountClientImpl implements AccountClient {
navigateUrl?: string
extra?: Record<string, any>
spaces?: string[]
notBefore?: number
expiration?: number
personalized?: boolean
}
): Promise<string> {
const params: any = { ...(options ?? {}), role }
+2
View File
@@ -141,6 +141,8 @@ export default plugin(platformId, {
BadRequest: '' as StatusCode,
Forbidden: '' as StatusCode, // 403
Unauthorized: '' as StatusCode, // 401
TokenExpired: '' as StatusCode, // 401
TokenNotActive: '' as StatusCode<{ notBefore: number }>, // 401
Conflict: '' as StatusCode, // 409
ExpiredLink: '' as StatusCode,
UnknownMethod: '' as StatusCode<{ method: string }>,
+14 -1
View File
@@ -17,13 +17,18 @@
import { createEventDispatcher, onDestroy } from 'svelte'
import { Timestamp } from '@hcengineering/core'
import { Label } from '..'
import ui from '../plugin'
export let time: Timestamp
export let showHours = false
const dispatch = createEventDispatcher()
let displayTime = time
let notified = false
let intervalId: any | undefined = undefined
const dayMs = 1000 * 60 * 60 * 24
function applyTimer (time: number): void {
if (intervalId !== undefined) {
@@ -56,11 +61,19 @@
function getDisplayTime (time: number): string {
const options: Intl.DateTimeFormatOptions = { minute: 'numeric', second: 'numeric' }
if (showHours) {
options.timeZone = 'UTC'
options.hour = 'numeric'
}
return new Date(time).toLocaleString('default', options)
}
</script>
{#if displayTime > 0}
{getDisplayTime(displayTime)}
{#if displayTime < dayMs}
{getDisplayTime(displayTime)}
{:else}
<Label label={ui.string.Days} params={{ days: Math.floor(displayTime / dayMs) }} />
{/if}
{/if}
+3 -1
View File
@@ -75,6 +75,8 @@
"SetPasswordNow": "Nastavím heslo nyní",
"LoginAsGuest": "Pokračovat jako host",
"SignUpToCreateWorkspace": "Neexistují žádné pracovní prostory s přístupem pro hosty. Prosím, zaregistrujte se a vytvořte si vlastní.",
"WhatIsYourName": "Jak se jmenujete?"
"WhatIsYourName": "Jak se jmenujete?",
"AccessExpired": "Doba přístupu vypršela. Požádejte prosím o nový odkaz pro přístup.",
"AccessNotActive": "Doba přístupu ještě nezačala. Začíná za: "
}
}
+3 -1
View File
@@ -75,6 +75,8 @@
"SetPasswordNow": "Ich werde jetzt ein Passwort festlegen",
"LoginAsGuest": "Als Gast fortfahren",
"SignUpToCreateWorkspace": "Keine Arbeitsbereiche mit Gastzugang. Bitte registrieren Sie sich, um Ihren eigenen zu erstellen.",
"WhatIsYourName": "Wie ist Ihr Name?"
"WhatIsYourName": "Wie ist Ihr Name?",
"AccessExpired": "Zugriffszeit abgelaufen. Bitte fragen Sie nach einem neuen Zugangslink.",
"AccessNotActive": "Zugriffszeit hat noch nicht begonnen. Sie beginnt in: "
}
}
+3 -1
View File
@@ -74,6 +74,8 @@
"SetPasswordNow": "I'll set a password now",
"LoginAsGuest": "Continue as a guest",
"SignUpToCreateWorkspace": "There are no workspaces with guest access. Please sign up to create your own.",
"WhatIsYourName": "What is your name?"
"WhatIsYourName": "What is your name?",
"AccessExpired": "Access time expired. Please ask for a new access link.",
"AccessNotActive": "Access time hasn't started yet. It is starting in: "
}
}
+3 -1
View File
@@ -74,6 +74,8 @@
"SetPasswordNow": "Estableceré una contraseña ahora",
"LoginAsGuest": "Continuar como invitado",
"SignUpToCreateWorkspace": "No hay espacios de trabajo con acceso de invitado. Por favor, regístrese para crear el suyo.",
"WhatIsYourName": "¿Cuál es su nombre?"
"WhatIsYourName": "¿Cuál es su nombre?",
"AccessExpired": "El tiempo de acceso ha expirado. Por favor, solicite un nuevo enlace de acceso.",
"AccessNotActive": "El tiempo de acceso aún no ha comenzado. Comienza en: "
}
}
+3 -1
View File
@@ -74,6 +74,8 @@
"SetPasswordNow": "Je définirai un mot de passe maintenant",
"LoginAsGuest": "Continuer en tant qu'invité",
"SignUpToCreateWorkspace": "Aucun espace de travail avec accès invité. Veuillez vous inscrire pour créer le vôtre.",
"WhatIsYourName": "Quel est votre nom ?"
"WhatIsYourName": "Quel est votre nom ?",
"AccessExpired": "Le temps d'accès a expiré. Veuillez demander un nouveau lien d'accès.",
"AccessNotActive": "Le temps d'accès n'a pas encore commencé. Il commence dans : "
}
}
+3 -1
View File
@@ -74,6 +74,8 @@
"SetPasswordNow": "Imposterò una password ora",
"LoginAsGuest": "Continua come ospite",
"SignUpToCreateWorkspace": "Nessuna area di lavoro con accesso ospite. Per favore, registrati per crearne una tua.",
"WhatIsYourName": "Come ti chiami?"
"WhatIsYourName": "Come ti chiami?",
"AccessExpired": "Il tempo di accesso è scaduto. Per favore, richiedi un nuovo link di accesso.",
"AccessNotActive": "Il tempo di accesso non è ancora iniziato. Inizia tra: "
}
}
+3 -1
View File
@@ -74,6 +74,8 @@
"SetPasswordNow": "今すぐパスワードを設定します",
"LoginAsGuest": "ゲストとして続行",
"SignUpToCreateWorkspace": "ゲストアクセス可能なワークスペースはありません。登録して独自のワークスペースを作成してください。",
"WhatIsYourName": "お名前は何ですか?"
"WhatIsYourName": "お名前は何ですか?",
"AccessExpired": "アクセス時間が期限切れです。新しいアクセスリンクをリクエストしてください。",
"AccessNotActive": "アクセス時間はまだ開始されていません。開始まで: "
}
}
+3 -1
View File
@@ -74,6 +74,8 @@
"SetPasswordNow": "Vou definir uma senha agora",
"LoginAsGuest": "Continuar como convidado",
"SignUpToCreateWorkspace": "Não há espaços de trabalho com acesso de convidado. Por favor, cadastre-se para criar o seu.",
"WhatIsYourName": "Qual é o seu nome?"
"WhatIsYourName": "Qual é o seu nome?",
"AccessExpired": "O tempo de acesso expirou. Por favor, solicite um novo link de acesso.",
"AccessNotActive": "O tempo de acesso ainda não começou. Começa em: "
}
}
+3 -1
View File
@@ -74,6 +74,8 @@
"SetPasswordNow": "Я установлю пароль сейчас",
"LoginAsGuest": "Продолжить как гость",
"SignUpToCreateWorkspace": "Нет рабочих пространств с гостевым доступом. Пожалуйста, зарегистрируйтесь, чтобы создать своё.",
"WhatIsYourName": "Как вас зовут?"
"WhatIsYourName": "Как вас зовут?",
"AccessExpired": "Время доступа истекло. Пожалуйста, запросите новую ссылку доступа.",
"AccessNotActive": "Время доступа еще не началось. Начинается через: "
}
}
+3 -1
View File
@@ -74,6 +74,8 @@
"SetPasswordNow": "现在设置密码",
"LoginAsGuest": "以访客身份继续",
"SignUpToCreateWorkspace": "没有支持访客访问的工作区。请注册以创建您自己的工作区。",
"WhatIsYourName": "您的姓名是什么?"
"WhatIsYourName": "您的姓名是什么?",
"AccessExpired": "访问时间已过期。请申请新的访问链接。",
"AccessNotActive": "访问时间尚未开始。开始时间:"
}
}
@@ -1,10 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte'
import { getCurrentLocation, Loading } from '@hcengineering/ui'
import { getCurrentLocation, Label, Loading, TimeLeft } from '@hcengineering/ui'
import { logIn } from '@hcengineering/workbench'
import { trackOAuthCompletion } from '@hcengineering/analytics-providers'
import { type LoginInfoRequest, type LoginInfoByToken } from '@hcengineering/account-client'
import { OK, PlatformError, unknownError } from '@hcengineering/platform'
import platform, { OK, PlatformError, Status, unknownError } from '@hcengineering/platform'
import type { Field } from '../types'
import {
@@ -21,6 +21,7 @@
let request: LoginInfoRequest | undefined
let fields: Field[]
let status: Status = OK
$: fields =
request != null
? [
@@ -40,7 +41,19 @@
return
}
const result = await getLoginInfoFromQuery()
let result: LoginInfoByToken | null = null
try {
result = await getLoginInfoFromQuery()
} catch (err: any) {
if (
err instanceof PlatformError &&
[platform.status.TokenExpired, platform.status.TokenNotActive].includes(err.status.code)
) {
status = err.status
return
}
}
trackOAuthCompletion(result)
await handleLoginInfo(result)
@@ -63,8 +76,6 @@
}
}
let status = OK
const action = {
i18n: login.string.Proceed,
func: async () => {
@@ -80,10 +91,34 @@
}
}
}
let timer: TimeLeft | undefined
</script>
{#if request != null}
{#if status?.code === platform.status.TokenExpired}
<span class="text ml-8">
<Label label={login.string.AccessExpired} />
</span>
{:else if status?.code === platform.status.TokenNotActive}
<span class="text ml-8">
<Label label={login.string.AccessNotActive} />
<TimeLeft
bind:this={timer}
time={status.params.notBefore * 1000}
showHours={true}
on:timeout={() => {
window.location.reload()
}}
/>
</span>
{:else if request != null}
<Form caption={login.string.WhatIsYourName} {status} {fields} object={formData} {action} ignoreInitialValidation />
{:else}
<Loading />
{/if}
<style>
.text {
color: var(--theme-caption-color);
}
</style>
+3 -1
View File
@@ -77,6 +77,8 @@ export default mergeIds(loginId, login, {
SetPasswordLater: '' as IntlString,
SetPasswordNow: '' as IntlString,
SignUpToCreateWorkspace: '' as IntlString,
WhatIsYourName: '' as IntlString
WhatIsYourName: '' as IntlString,
AccessExpired: '' as IntlString,
AccessNotActive: '' as IntlString
}
})
+294 -31
View File
@@ -25,7 +25,7 @@ import {
systemAccountUuid
} from '@hcengineering/core'
import platform, { PlatformError, Status, Severity, getMetadata } from '@hcengineering/platform'
import { decodeTokenVerbose } from '@hcengineering/server-token'
import { decodeToken, decodeTokenVerbose } from '@hcengineering/server-token'
import * as utils from '../utils'
import { type AccountDB, type SocialId } from '../types'
@@ -66,13 +66,14 @@ jest.mock('@hcengineering/platform', () => {
// Mock server-token
jest.mock('@hcengineering/server-token', () => ({
decodeTokenVerbose: jest.fn(),
generateToken: jest.fn().mockImplementation((account, workspace, extra, _, grant) => {
decodeToken: jest.fn(),
generateToken: jest.fn().mockImplementation((account, workspace, extra, _, options) => {
let token = `mocked-token-${account}`
if (workspace != null) {
token += `-${workspace}`
}
if (grant != null) {
token += `-${JSON.stringify(grant)}`
if (options != null) {
token += `-${JSON.stringify(options)}`
}
if (extra != null) {
token += `-${JSON.stringify(extra)}`
@@ -113,7 +114,8 @@ describe('account operations', () => {
const mockWorkspace = {
uuid: 'workspace-uuid' as WorkspaceUuid,
name: 'Test Workspace',
url: 'test-workspace'
url: 'test-workspace',
region: 'us'
}
beforeEach(() => {
@@ -529,7 +531,8 @@ describe('account operations', () => {
},
getWorkspaceRole: jest.fn(),
assignWorkspace: jest.fn(),
updateWorkspaceRole: jest.fn()
updateWorkspaceRole: jest.fn(),
generatePersonUuid: jest.fn()
} as unknown as AccountDB
beforeEach(() => {
@@ -586,7 +589,7 @@ describe('account operations', () => {
account: mockPersonId,
name: 'John Doe',
socialId: 'social-id-1',
token: 'mocked-token-test-person-id-workspace-uuid-{}',
token: 'mocked-token-test-person-id-workspace-uuid-{}-{}',
workspace: mockWorkspaceEu.uuid,
workspaceUrl: 'test-workspace',
endpoint: 'ws://external:3000',
@@ -616,7 +619,7 @@ describe('account operations', () => {
account: mockPersonId,
name: 'John Doe',
socialId: 'social-id-1',
token: 'mocked-token-test-person-id-workspace-uuid-{}',
token: 'mocked-token-test-person-id-workspace-uuid-{}-{}',
workspace: mockWorkspaceUs.uuid,
workspaceUrl: 'test-workspace',
endpoint: 'ws://internal:3000',
@@ -633,7 +636,7 @@ describe('account operations', () => {
account: mockPersonId,
name: 'John Doe',
socialId: 'social-id-1',
token: 'mocked-token-test-person-id-workspace-uuid-{}',
token: 'mocked-token-test-person-id-workspace-uuid-{}-{}',
workspace: mockWorkspaceEu.uuid,
workspaceUrl: 'test-workspace',
endpoint: 'ws://external:3000',
@@ -663,7 +666,7 @@ describe('account operations', () => {
account: mockPersonId,
name: 'John Doe',
socialId: 'social-id-1',
token: 'mocked-token-test-person-id-{}'
token: 'mocked-token-test-person-id-{}-{}'
})
})
@@ -692,6 +695,37 @@ describe('account operations', () => {
)
})
test('should use sub claim as account when provided', async () => {
const subAccount = 'sub-account-uuid' as AccountUuid
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: {},
sub: subAccount
})
;(mockDb.person.findOne as jest.Mock).mockResolvedValue({
uuid: subAccount,
firstName: 'Sub',
lastName: 'User'
})
;(mockDb.socialId.find as jest.Mock).mockResolvedValue([{ _id: 'social-id-1' }])
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.User)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
const result = await getLoginInfoByToken(mockCtx, mockDb, mockBranding, mockToken)
expect(result).toEqual({
account: subAccount, // Should use sub instead of account
name: 'Sub User',
socialId: 'social-id-1',
token: expect.stringContaining(`"sub":"${subAccount}"`),
workspace: mockWorkspace.uuid,
workspaceUrl: mockWorkspace.url,
endpoint: expect.any(String),
role: AccountRole.User
})
})
describe('with grants', () => {
const grantWorkspace = {
uuid: 'grant-workspace-uuid' as WorkspaceUuid,
@@ -800,12 +834,92 @@ describe('account operations', () => {
expect(mockDb.assignWorkspace).not.toHaveBeenCalled()
})
test('should generate UUID and use as sub when grant present without sub', async () => {
const generatedUuid = 'generated-uuid' as AccountUuid
const grant = {
workspace: mockWorkspace.uuid,
role: AccountRole.User,
firstName: 'John',
lastName: 'Doe'
}
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: utils.GUEST_ACCOUNT,
extra: {},
grant
})
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null)
;(mockDb.generatePersonUuid as jest.Mock).mockResolvedValue(generatedUuid)
;(mockDb.person.findOne as jest.Mock).mockResolvedValue({
uuid: generatedUuid,
firstName: 'John',
lastName: 'Doe'
})
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.User)
const result = await getLoginInfoByToken(mockCtx, mockDb, mockBranding, mockToken)
expect(mockDb.generatePersonUuid).toHaveBeenCalled()
expect(result).toEqual({
account: generatedUuid,
name: 'John Doe',
socialId: 'social-id-1',
token: expect.stringContaining(`"sub":"${generatedUuid}"`),
workspace: mockWorkspace.uuid,
workspaceUrl: mockWorkspace.url,
endpoint: expect.any(String),
role: AccountRole.User
})
})
test('should use existing sub', async () => {
const existingUuid = 'existing-uuid' as AccountUuid
const grant = {
workspace: mockWorkspace.uuid,
role: AccountRole.User,
firstName: 'John',
lastName: 'Doe'
}
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: utils.GUEST_ACCOUNT,
extra: {},
grant,
sub: existingUuid
})
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null)
;(mockDb.person.findOne as jest.Mock).mockResolvedValue({
uuid: existingUuid,
firstName: 'John',
lastName: 'Doe'
})
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.User)
const result = await getLoginInfoByToken(mockCtx, mockDb, mockBranding, mockToken)
expect(mockDb.generatePersonUuid).not.toHaveBeenCalled()
expect(result).toEqual({
account: existingUuid,
name: 'John Doe',
socialId: 'social-id-1',
token: expect.stringContaining(`"sub":"${existingUuid}"`),
workspace: mockWorkspace.uuid,
workspaceUrl: mockWorkspace.url,
endpoint: expect.any(String),
role: AccountRole.User
})
})
test('should throw error for grant with system account', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: systemAccountUuid,
account: utils.GUEST_ACCOUNT,
extra: {},
grant: { workspace: grantWorkspace.uuid, role: grantRole }
grant: { workspace: grantWorkspace.uuid, role: grantRole },
sub: systemAccountUuid
})
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null)
await expect(getLoginInfoByToken(mockCtx, mockDb, mockBranding, mockToken)).rejects.toThrow(
new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
@@ -836,6 +950,63 @@ describe('account operations', () => {
)
})
})
test('should handle token with nbf and exp claims', async () => {
const notBefore = Math.floor(Date.now() / 1000) - 3600 // 1 hour ago
const expiration = Math.floor(Date.now() / 1000) + 3600 // 1 hour from now
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: {},
nbf: notBefore,
exp: expiration
})
;(mockDb.person.findOne as jest.Mock).mockResolvedValue({
uuid: mockAccount.uuid,
firstName: 'John',
lastName: 'Doe'
})
;(mockDb.socialId.find as jest.Mock).mockResolvedValue([{ _id: 'social-id-1' }])
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.User)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
const result = await getLoginInfoByToken(mockCtx, mockDb, mockBranding, mockToken)
expect(result).toEqual({
account: mockAccount.uuid,
name: 'John Doe',
socialId: 'social-id-1',
token: expect.stringContaining(`"nbf":${notBefore},"exp":${expiration}`),
workspace: mockWorkspace.uuid,
workspaceUrl: mockWorkspace.url,
endpoint: expect.any(String),
role: AccountRole.User
})
})
test('should throw error when token is not yet active', async () => {
const futureTime = Math.floor(Date.now() / 1000) + 3600 // 1 hour in the future
;(decodeToken as jest.Mock).mockReturnValue({ nbf: futureTime })
;(decodeTokenVerbose as jest.Mock).mockImplementation(() => {
throw new Error('Token not yet active')
})
await expect(getLoginInfoByToken(mockCtx, mockDb, mockBranding, mockToken)).rejects.toThrow(
new PlatformError(new Status(Severity.ERROR, platform.status.TokenNotActive, { notBefore: futureTime }))
)
})
test('should throw error when token has expired', async () => {
;(decodeTokenVerbose as jest.Mock).mockImplementation(() => {
throw new Error('Token expired')
})
await expect(getLoginInfoByToken(mockCtx, mockDb, mockBranding, mockToken)).rejects.toThrow(
new PlatformError(new Status(Severity.ERROR, platform.status.TokenExpired, {}))
)
})
})
describe('releaseSocialId', () => {
@@ -956,10 +1127,13 @@ describe('account operations', () => {
expect(mockDb.generatePersonUuid).toHaveBeenCalled()
expect(result).toBe(
`${frontUrl}/login/auth?token=mocked-token-generated-person-uuid-${JSON.stringify({
workspace: 'workspace-uuid',
role: 'USER',
grantedBy: 'account-uuid'
`${frontUrl}/login/auth?token=mocked-token-b6996120-416f-49cd-841e-e4a5d2e49c9b-${JSON.stringify({
grant: {
workspace: 'workspace-uuid',
role: 'USER',
grantedBy: 'account-uuid'
},
sub: 'generated-person-uuid'
})}`
)
})
@@ -977,12 +1151,15 @@ describe('account operations', () => {
expect(mockDb.generatePersonUuid).toHaveBeenCalled()
expect(result).toBe(
`${frontUrl}/login/auth?token=mocked-token-generated-person-uuid-${JSON.stringify({
workspace: mockWorkspace.uuid,
role: AccountRole.User,
grantedBy: 'account-uuid',
firstName: 'John',
lastName: 'Doe'
`${frontUrl}/login/auth?token=mocked-token-b6996120-416f-49cd-841e-e4a5d2e49c9b-${JSON.stringify({
grant: {
workspace: mockWorkspace.uuid,
role: AccountRole.User,
grantedBy: 'account-uuid',
firstName: 'John',
lastName: 'Doe'
},
sub: 'generated-person-uuid'
})}`
)
})
@@ -1000,11 +1177,14 @@ describe('account operations', () => {
expect(mockDb.generatePersonUuid).toHaveBeenCalled()
expect(result).toBe(
`${frontUrl}/login/auth?token=mocked-token-generated-person-uuid-${JSON.stringify({
workspace: mockWorkspace.uuid,
role: AccountRole.User,
grantedBy: 'account-uuid',
extra: { param1: 'value1', param2: 'value2' }
`${frontUrl}/login/auth?token=mocked-token-b6996120-416f-49cd-841e-e4a5d2e49c9b-${JSON.stringify({
grant: {
workspace: mockWorkspace.uuid,
role: AccountRole.User,
grantedBy: 'account-uuid',
extra: { param1: 'value1', param2: 'value2' }
},
sub: 'generated-person-uuid'
})}`
)
})
@@ -1022,10 +1202,13 @@ describe('account operations', () => {
expect(mockDb.generatePersonUuid).toHaveBeenCalled()
expect(result).toBe(
`${frontUrl}/login/auth?token=mocked-token-generated-person-uuid-${JSON.stringify({
workspace: 'workspace-uuid',
role: 'USER',
grantedBy: 'account-uuid'
`${frontUrl}/login/auth?token=mocked-token-b6996120-416f-49cd-841e-e4a5d2e49c9b-${JSON.stringify({
grant: {
workspace: 'workspace-uuid',
role: 'USER',
grantedBy: 'account-uuid'
},
sub: 'generated-person-uuid'
})}&navigateUrl=${encodeURIComponent(navigateUrl)}`
)
})
@@ -1058,6 +1241,86 @@ describe('account operations', () => {
expect(mockDb.generatePersonUuid).not.toHaveBeenCalled()
})
test('should create non-personalized access link without UUID with notBefore and expiration', async () => {
const notBefore = 1703894400 // December 30, 2023 00:00:00 UTC in seconds
const expiration = 1704067200 // January 1, 2024 00:00:00 UTC in seconds
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
const result = await createAccessLink(mockCtx, mockDb, mockBranding, mockToken, {
role: AccountRole.User,
personalized: false,
notBefore,
expiration
})
expect(result).toBe(
`${frontUrl}/login/auth?token=mocked-token-b6996120-416f-49cd-841e-e4a5d2e49c9b-${JSON.stringify({
grant: {
workspace: mockWorkspace.uuid,
role: AccountRole.User,
grantedBy: 'account-uuid'
},
exp: expiration,
nbf: notBefore
})}`
)
expect(mockDb.generatePersonUuid).not.toHaveBeenCalled()
})
test('should throw error when notBefore is in milliseconds', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
await expect(
createAccessLink(mockCtx, mockDb, mockBranding, mockToken, {
role: AccountRole.User,
notBefore: Date.now() // Milliseconds instead of seconds
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})))
expect(mockCtx.error).toHaveBeenCalledWith(
'Not before appears to be in milliseconds instead of seconds',
expect.any(Object)
)
})
test('should throw error when expiration is in milliseconds', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
await expect(
createAccessLink(mockCtx, mockDb, mockBranding, mockToken, {
role: AccountRole.User,
expiration: Date.now() // Milliseconds instead of seconds
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})))
expect(mockCtx.error).toHaveBeenCalledWith(
'Expiration appears to be in milliseconds instead of seconds',
expect.any(Object)
)
})
test('should throw error when expiration is before notBefore', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
await expect(
createAccessLink(mockCtx, mockDb, mockBranding, mockToken, {
role: AccountRole.User,
notBefore: 1000,
expiration: 900
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})))
expect(mockCtx.error).toHaveBeenCalledWith('Expiration time must be after Not Before time', expect.any(Object))
})
})
describe('authentication operations', () => {
+74 -18
View File
@@ -35,7 +35,7 @@ import {
type WorkspaceUuid
} from '@hcengineering/core'
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
import { decodeTokenVerbose, generateToken, type PermissionsGrant } from '@hcengineering/server-token'
import { decodeToken, decodeTokenVerbose, generateToken, type PermissionsGrant } from '@hcengineering/server-token'
import { isAdminEmail } from './admin'
import { accountPlugin } from './plugin'
@@ -665,9 +665,13 @@ export async function createAccessLink (
extra?: string
navigateUrl?: string
spaces?: string[]
notBefore?: number
expiration?: number
personalized?: boolean
}
): Promise<string> {
const { role, firstName, lastName, navigateUrl, spaces } = params
const { role, firstName, lastName, navigateUrl, spaces, notBefore, expiration, personalized = true } = params
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
const currentAccount = await db.account.findOne({ uuid: account })
@@ -675,6 +679,10 @@ export async function createAccessLink (
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account }))
}
if (workspaceUuid == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const workspace = await db.workspace.findOne({ uuid: workspaceUuid })
if (workspace == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
@@ -691,12 +699,30 @@ export async function createAccessLink (
}
}
const RECENT_PAST_MS = 1577836800000 // January 1, 2020 in milliseconds
if (notBefore !== undefined && notBefore > RECENT_PAST_MS) {
ctx.error('Not before appears to be in milliseconds instead of seconds', { nbf: notBefore })
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
if (expiration !== undefined && expiration > RECENT_PAST_MS) {
ctx.error('Expiration appears to be in milliseconds instead of seconds', { exp: expiration })
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
if (notBefore !== undefined && expiration !== undefined && expiration <= notBefore) {
ctx.error('Expiration time must be after Not Before time', { nbf: notBefore, exp: expiration })
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
verifyAllowedRole(callerRole, AccountRole.User, extra)
verifyAllowedRole(callerRole, role, extra)
const newUuid = await db.generatePersonUuid()
const accessToken = generateToken(newUuid, undefined, undefined, undefined, {
const newUuid = personalized ? await db.generatePersonUuid() : undefined
const grant = {
workspace: workspaceUuid,
role,
grantedBy: account,
@@ -704,16 +730,28 @@ export async function createAccessLink (
lastName,
extra: extraObj,
spaces
})
let path = `/login/auth?token=${accessToken}`
if (navigateUrl != null) {
path += `&navigateUrl=${encodeURIComponent(navigateUrl.trim())}`
}
const front = getFrontUrl(branding)
const link = concatLink(front, path)
try {
const accessToken = generateToken(GUEST_ACCOUNT, undefined, undefined, undefined, {
grant,
sub: newUuid,
exp: expiration,
nbf: notBefore
})
let path = `/login/auth?token=${accessToken}`
if (navigateUrl != null) {
path += `&navigateUrl=${encodeURIComponent(navigateUrl.trim())}`
}
return link
const front = getFrontUrl(branding)
const link = concatLink(front, path)
return link
} catch (err: any) {
ctx.error('Failed to create access link', { err })
throw err
}
}
export async function createInviteLink (
@@ -1578,12 +1616,30 @@ export async function getLoginInfoByToken (
let workspaceUuid: WorkspaceUuid
let extra: any
let grant: PermissionsGrant | undefined
let sub: AccountUuid | undefined
let account: AccountUuid | undefined
let nbf: number | undefined
let exp: number | undefined
try {
;({ account: accountUuid, workspace: workspaceUuid, extra, grant } = decodeTokenVerbose(ctx, token))
;({ account, workspace: workspaceUuid, extra, grant, nbf, exp, sub } = decodeTokenVerbose(ctx, token))
if (grant != null && sub == null) {
sub = (await db.generatePersonUuid()) as AccountUuid
}
accountUuid = sub ?? account
} catch (err: any) {
Analytics.handleError(err)
ctx.error('Invalid token', { token })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Unauthorized, {}))
ctx.error('Invalid token', { token, errMsg: err.message })
switch (err.message) {
case 'Token not yet active': {
const { nbf } = decodeToken(token, false)
throw new PlatformError(new Status(Severity.ERROR, platform.status.TokenNotActive, { notBefore: nbf }))
}
case 'Token expired':
throw new PlatformError(new Status(Severity.ERROR, platform.status.TokenExpired, {}))
default:
throw new PlatformError(new Status(Severity.ERROR, platform.status.Unauthorized, {}))
}
}
if (accountUuid == null) {
@@ -1595,7 +1651,6 @@ export async function getLoginInfoByToken (
const isAdmin = extra?.admin === 'true'
// Check if token has grants and create automatic account if needed
// NOTE: grants with open account UUID are not currently supported
if (grant != null) {
if (workspaceUuid != null) {
ctx.error('Grants are not allowed in workspace-specific tokens', { workspaceUuid, grant })
@@ -1690,7 +1745,7 @@ export async function getLoginInfoByToken (
account: accountUuid,
name: getPersonName(person),
socialId: socialId?._id,
token: generateToken(accountUuid, workspaceUuid, extra, undefined, grant)
token: generateToken(accountUuid, workspaceUuid, extra, undefined, { grant, nbf, exp, sub })
}
if (!isSystem) {
@@ -1853,7 +1908,8 @@ export async function getSocialIds (
params: { confirmed: boolean, includeDeleted: boolean }
): Promise<SocialId[]> {
const { confirmed = true, includeDeleted = false } = params
const { account } = decodeTokenVerbose(ctx, token)
const { account: accountUuid, sub } = decodeTokenVerbose(ctx, token)
const account = sub ?? accountUuid
// do not expose not-confirmed social ids for now
if (!confirmed) {
@@ -2180,7 +2236,7 @@ async function exchangeGuestToken (
)
}
return generateToken(GUEST_ACCOUNT as PersonUuid, workspace.uuid, { linkId, guest: 'true' })
return generateToken(GUEST_ACCOUNT, workspace.uuid, { linkId, guest: 'true' })
}
return token
+19 -3
View File
@@ -67,7 +67,7 @@ import {
} from './types'
import { isAdminEmail } from './admin'
export const GUEST_ACCOUNT = 'b6996120-416f-49cd-841e-e4a5d2e49c9b'
export const GUEST_ACCOUNT = 'b6996120-416f-49cd-841e-e4a5d2e49c9b' as PersonUuid
export async function getAccountDB (
uri: string,
@@ -612,6 +612,9 @@ export async function selectWorkspace (
let accountUuid: AccountUuid
let extra: Record<string, any> | undefined
let grant: PermissionsGrant | undefined
let sub: AccountUuid | undefined
let exp: number | undefined
let nbf: number | undefined
try {
const decodedToken = decodeTokenVerbose(ctx, token ?? '')
accountUuid = decodedToken.account
@@ -620,6 +623,9 @@ export async function selectWorkspace (
}
extra = decodedToken.extra
grant = decodedToken.grant
sub = decodedToken.sub
exp = decodedToken.exp
nbf = decodedToken.nbf
} catch (e) {
if (workspace?.allowReadOnlyGuest === true) {
accountUuid = readOnlyGuestAccountUuid
@@ -668,7 +674,12 @@ export async function selectWorkspace (
if (accountUuid === systemAccountUuid) {
return {
account: accountUuid,
token: generateToken(accountUuid, workspace.uuid, extra, undefined, grant),
token: generateToken(accountUuid, workspace.uuid, extra, undefined, {
grant,
sub,
exp,
nbf
}),
endpoint: getEndpoint(workspace.uuid, workspace.region, getKind(workspace.region)),
workspace: workspace.uuid,
workspaceUrl: workspace.url,
@@ -725,7 +736,12 @@ export async function selectWorkspace (
return {
account: accountUuid,
token: generateToken(accountUuid, workspace.uuid, extra, undefined, grant),
token: generateToken(accountUuid, workspace.uuid, extra, undefined, {
grant,
sub,
exp,
nbf
}),
endpoint: getEndpoint(workspace.uuid, workspace.region, getKind(workspace.region)),
workspace: workspace.uuid,
workspaceUrl: workspace.url,
+19 -2
View File
@@ -12,6 +12,10 @@ export interface Token {
workspace: WorkspaceUuid
extra?: Record<string, any>
grant?: PermissionsGrant
sub?: AccountUuid // Subject
exp?: number // Expiration, seconds since epoch
nbf?: number // Not valid before, seconds since epoch
}
// Permissions grant provides the token presenter access to a specific workspace
@@ -53,7 +57,12 @@ export function generateToken (
workspaceUuid?: WorkspaceUuid,
extra?: Record<string, string>,
secret?: string,
grant?: PermissionsGrant
options?: {
grant?: PermissionsGrant
nbf?: number
exp?: number
sub?: PersonUuid
}
): string {
if (!validate(accountUuid)) {
throw new TokenError(`Invalid account uuid: "${accountUuid}"`)
@@ -61,10 +70,15 @@ export function generateToken (
if (workspaceUuid !== undefined && !validate(workspaceUuid)) {
throw new TokenError(`Invalid workspace uuid: "${workspaceUuid}"`)
}
const { grant, nbf, exp, sub } = options ?? {}
if (grant?.workspace !== undefined && !validate(grant?.workspace)) {
throw new TokenError(`Invalid grant workspace uuid: "${grant?.workspace}"`)
}
if (grant != null && sub == null && (nbf == null || exp == null)) {
throw new TokenError('nbf and exp are required when sub is not provided')
}
const service = getMetadata(serverPlugin.metadata.Service)
if (service !== undefined) {
extra = { service, ...extra }
@@ -88,7 +102,10 @@ export function generateToken (
...(extra !== undefined ? { extra } : {}),
account: accountUuid,
workspace: workspaceUuid,
grant: sanitizedGrant
grant: sanitizedGrant,
sub,
exp,
nbf
},
secret ?? getSecret()
)