Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9c2344d
feat: API token management in workspace settings
dnplkndll Mar 17, 2026
97be826
feat: enforce API token revocation at transactor level
dnplkndll Mar 17, 2026
92fb72b
feat: implement Phase 1 API token scopes (read/write/delete)
dnplkndll Mar 17, 2026
ca69693
test: add unit tests for API token scope enforcement
dnplkndll Mar 17, 2026
78c74ee
fix: address review feedback — role restriction, locale parity, forma…
dnplkndll Mar 19, 2026
bd680ca
fix: address aonnikov architectural review feedback
dnplkndll Apr 18, 2026
97ac92a
fix: formatting in ApiTokenPopup, apiTokenScopes test, and operations
dnplkndll Apr 18, 2026
8e6aea7
fix: apply rushx fmt to pass CI formatting check
dnplkndll Apr 23, 2026
dfa360b
fix: restore (s as any) cast in server_http.ts removed by ESLint autofix
dnplkndll Apr 23, 2026
d1264a6
Merge develop into feat/api-token-management
dnplkndll May 30, 2026
c7eea62
refactor(token): centralize API token revocation/expiry in verifyToken
dnplkndll May 30, 2026
401fd98
fix(setting): derive REST API base from account-provided transactor e…
dnplkndll May 30, 2026
4ea0ae4
i18n(setting): translate API token strings across all locales
dnplkndll May 30, 2026
ee2c962
style: wrap long lines to satisfy prettier (account utils import, Api…
dnplkndll May 30, 2026
77d8517
Merge develop into feat/api-token-management
dnplkndll Jul 26, 2026
bbf6797
fix(api-token): drop unenforceable scopes, harden the token lifecycle
dnplkndll Jul 26, 2026
ab782e6
fix(setting): correct the API token settings page
dnplkndll Jul 26, 2026
74a0089
chore: drop docs/openapi.yaml from the API token PR
dnplkndll Jul 26, 2026
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
32 changes: 32 additions & 0 deletions foundations/core/packages/account-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import type {
AccountAggregatedInfo,
ApiTokenInfo,
ApiTokenResult,
Integration,
IntegrationKey,
IntegrationSecret,
Expand Down Expand Up @@ -260,6 +262,9 @@ export interface AccountClient {
getWorkspaceUsersWithPermission: (params: { permission: string }) => Promise<AccountUuid[]>

verify2fa: (code: string) => Promise<LoginInfo>
createApiToken: (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number) => Promise<ApiTokenResult>
listApiTokens: () => Promise<ApiTokenInfo[]>
revokeApiToken: (tokenId: string) => Promise<void>

setCookie: () => Promise<void>
deleteCookie: () => Promise<void>
Expand Down Expand Up @@ -1233,6 +1238,33 @@ class AccountClientImpl implements AccountClient {
await this.rpc(request)
}

async createApiToken (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number): Promise<ApiTokenResult> {
const request = {
method: 'createApiToken' as const,
params: { name, workspaceUuid, expiryDays }
}

return await this.rpc(request)
}

async listApiTokens (): Promise<ApiTokenInfo[]> {
const request = {
method: 'listApiTokens' as const,
params: {}
}

return await this.rpc(request)
}

async revokeApiToken (tokenId: string): Promise<void> {
const request = {
method: 'revokeApiToken' as const,
params: { tokenId }
}

await this.rpc(request)
}

async setCookie (): Promise<void> {
const url = concatLink(this.url, '/cookie')
const response = await fetch(url, { ...this.request, method: 'PUT' })
Expand Down
16 changes: 16 additions & 0 deletions foundations/core/packages/account-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ export interface MailboxInfo {
appPasswords: string[]
}

export interface ApiTokenInfo {
id: string
name: string
workspaceUuid: WorkspaceUuid
workspaceName: string
createdOn: number
expiresOn: number
revoked: boolean
}

export interface ApiTokenResult {
id: string
token: string
expiresOn: number
}

export interface MailboxSecret {
mailbox: string
app?: string
Expand Down
97 changes: 95 additions & 2 deletions foundations/core/packages/token/src/__tests__/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
//

import { setMetadata } from '@hcengineering/platform'
import type { PersonUuid, WorkspaceUuid } from '@hcengineering/core'
import { decodeToken, generateToken } from '../token'
import type { AccountUuid, PersonUuid, WorkspaceUuid } from '@hcengineering/core'
import { decodeToken, generateToken, isTokenExpired, setApiTokenRevocationChecker, verifyToken } from '../token'
import plugin from '../plugin'

export function decodeTokenPayload (token: string): any {
Expand Down Expand Up @@ -114,3 +114,96 @@ describe('generateToken', () => {
})
})
})

const ACCOUNT = '123e4567-e89b-12d3-a456-426614174000' as AccountUuid
const WORKSPACE = '123e4567-e89b-12d3-a456-426614174001' as WorkspaceUuid

describe('isTokenExpired', () => {
it('is false when exp is absent', () => {
expect(isTokenExpired({ account: ACCOUNT, workspace: WORKSPACE })).toBe(false)
})

it('is false when exp is in the future', () => {
const exp = Math.floor(Date.now() / 1000) + 3600
expect(isTokenExpired({ account: ACCOUNT, workspace: WORKSPACE, exp })).toBe(false)
})

it('is true when exp is in the past', () => {
const exp = Math.floor(Date.now() / 1000) - 1
expect(isTokenExpired({ account: ACCOUNT, workspace: WORKSPACE, exp })).toBe(true)
})
})

describe('verifyToken', () => {
beforeEach(() => {
setMetadata(plugin.metadata.Secret, undefined)
setMetadata(plugin.metadata.Service, undefined)
setApiTokenRevocationChecker(undefined)
})

afterAll(() => {
setApiTokenRevocationChecker(undefined)
})

it('returns the decoded token for a valid, non-expiring token', async () => {
const token = generateToken(ACCOUNT, WORKSPACE, undefined, 'secret')
const decoded = await verifyToken(token, 'secret')
expect(decoded.account).toBe(ACCOUNT)
expect(decoded.workspace).toBe(WORKSPACE)
})

it('throws for an expired token', async () => {
const exp = Math.floor(Date.now() / 1000) - 1
const token = generateToken(ACCOUNT, WORKSPACE, undefined, 'secret', { exp })
await expect(verifyToken(token, 'secret')).rejects.toThrow('Token expired')
})

it('skips revocation when no checker is registered', async () => {
const token = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-1' }, 'secret')
const decoded = await verifyToken(token, 'secret')
expect(decoded.extra?.apiTokenId).toBe('tok-1')
})

it('throws when the registered checker reports the API token revoked', async () => {
setApiTokenRevocationChecker(async () => true)
const token = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-revoked' }, 'secret')
await expect(verifyToken(token, 'secret')).rejects.toThrow('Token revoked')
})

it('refuses the token when revocation cannot be verified', async () => {
// The account is the only authority on revocation. Failing open here would let
// a revoked token survive for as long as an attacker can keep the account busy.
setApiTokenRevocationChecker(async () => {
throw new Error('account unreachable')
})
const token = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-unreachable' }, 'secret')
await expect(verifyToken(token, 'secret')).rejects.toThrow('Token revocation could not be verified')
})

it('does not re-ask while a verdict is still fresh', async () => {
let calls = 0
setApiTokenRevocationChecker(async () => {
calls++
return false
})
const token = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-cached' }, 'secret')
await verifyToken(token, 'secret')
await verifyToken(token, 'secret')
expect(calls).toBe(1)
})

it('only invokes the checker for revokable (API) tokens', async () => {
let calls = 0
setApiTokenRevocationChecker(async () => {
calls++
return false
})
const plain = generateToken(ACCOUNT, WORKSPACE, undefined, 'secret')
await verifyToken(plain, 'secret')
expect(calls).toBe(0)

const api = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-2' }, 'secret')
await verifyToken(api, 'secret')
expect(calls).toBe(1)
})
})
91 changes: 91 additions & 0 deletions foundations/core/packages/token/src/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,94 @@ export function decodeTokenVerbose (ctx: MeasureContext, token: string): Token {
throw new TokenError(err.message)
}
}

/**
* Checks whether a token has passed its `exp` (seconds since epoch) deadline.
* `decodeToken` only verifies the signature — expiry must be checked separately.
* @public
*/
export function isTokenExpired (token: Token, now: number = Date.now()): boolean {
return token.exp !== undefined && token.exp * 1000 <= now
}

/**
* Resolves whether a revokable API token (identified by `extra.apiTokenId`)
* has been revoked. Registered by services that can reach the account
* (see {@link setApiTokenRevocationChecker}); other services skip the check.
* @public
*/
export type ApiTokenRevocationChecker = (apiTokenId: string, token: Token, raw: string) => Promise<boolean>

let apiTokenRevocationChecker: ApiTokenRevocationChecker | undefined

const REVOCATION_CACHE_TTL_MS = 60_000
const REVOCATION_CACHE_LIMIT = 4096
const revocationCache = new Map<string, { revoked: boolean, checkedAt: number }>()

function cacheRevocation (apiTokenId: string, revoked: boolean, now: number): void {
// Bounded so a stream of distinct tokens cannot grow this without limit.
if (revocationCache.size >= REVOCATION_CACHE_LIMIT && !revocationCache.has(apiTokenId)) {
for (const [key, value] of revocationCache) {
if (now - value.checkedAt > REVOCATION_CACHE_TTL_MS) {
revocationCache.delete(key)
}
}
if (revocationCache.size >= REVOCATION_CACHE_LIMIT) {
revocationCache.delete(revocationCache.keys().next().value as string)
}
}
revocationCache.set(apiTokenId, { revoked, checkedAt: now })
}

/**
* Registers the revocation resolver used by {@link verifyToken}. Services with
* an account client install this once at startup; this is the "method to verify"
* metadata the token plugin needs to enforce revocation without depending on the
* account client directly.
* @public
*/
export function setApiTokenRevocationChecker (checker: ApiTokenRevocationChecker | undefined): void {
apiTokenRevocationChecker = checker
revocationCache.clear()
}

async function isApiTokenRevoked (apiTokenId: string, token: Token, raw: string, now: number): Promise<boolean> {
const cached = revocationCache.get(apiTokenId)
if (cached !== undefined && now - cached.checkedAt <= REVOCATION_CACHE_TTL_MS) {
return cached.revoked
}

try {
const revoked = await (apiTokenRevocationChecker as ApiTokenRevocationChecker)(apiTokenId, token, raw)
cacheRevocation(apiTokenId, revoked, now)
return revoked
} catch {
// The account is the only authority on revocation. If it cannot be reached we
// do not know whether this token still stands, so refuse it rather than let a
// revoked token survive by making the account unreachable. A verdict from
// within the TTL is still trusted, which keeps brief outages from cutting off
// healthy tokens mid-flight.
throw new TokenError('Token revocation could not be verified')
}
}

/**
* Decodes and fully validates a token: signature (via {@link decodeToken}),
* expiry, and — for revokable API tokens — revocation. Reuse this instead of
* `decodeToken` anywhere expired or revoked tokens must be rejected (transactor
* REST API, blob access, etc.) so the policy lives in one place.
* @public
*/
export async function verifyToken (token: string, secret?: string): Promise<Token> {
const decoded = decodeToken(token, true, secret)
if (isTokenExpired(decoded)) {
throw new TokenError('Token expired')
}
const apiTokenId = decoded.extra?.apiTokenId
if (apiTokenId !== undefined && apiTokenRevocationChecker !== undefined) {
if (await isApiTokenRevoked(apiTokenId, decoded, token, Date.now())) {
throw new TokenError('Token revoked')
}
}
return decoded
}
17 changes: 17 additions & 0 deletions models/setting/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,23 @@ export function createModel (builder: Builder): void {
setting.ids.OfficeSettings
)

// Tokens belong to the account, not to a workspace: they are listed across every
// workspace the user is in, and creating one only needs the User role the account
// service checks. So this sits with the other per-account settings.
builder.createDoc(
setting.class.SettingsCategory,
core.space.Model,
{
name: 'apiTokens',
label: setting.string.ApiTokens,
icon: setting.icon.ApiToken,
component: setting.component.ApiTokens,
group: 'settings-account',
order: 1500,
role: AccountRole.User
},
setting.ids.ApiTokens
)
// Currently remove Support item from settings
// builder.createDoc(
// setting.class.SettingsCategory,
Expand Down
3 changes: 3 additions & 0 deletions plugins/setting-assets/assets/icons.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 38 additions & 4 deletions plugins/setting-assets/lang/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,6 @@
"IntegerOnly": "Pouze celá čísla",
"AccessControl": "Řízení přístupu",
"DangerZone": "Nebezpečná zóna",
"ApiAccess": "Přístup k API",
"ApiToken": "API token",
"GenerateApiToken": "Vygenerovat API token",
"IdentifierExists": "Identifikátor již existuje",
"PasswordAgingRule": "Pravidlo stárnutí hesla",
"PasswordAgingRuleDescription": "Počet dní, po kterých budou uživatelé muset změnit své heslo.",
Expand Down Expand Up @@ -247,6 +244,43 @@
"ShowQRCode": "Zobrazit QR kód",
"EnterVerificationCode": "Zadejte ověřovací kód",
"OverrideAttribute": "Přepsat atribut",
"Required": "Požadované"
"Required": "Požadované",
"ApiBaseUrl": "Základní URL",
"ApiEndpointAccount": "Získat informace o účtu",
"ApiEndpointFindAll": "Dotaz na dokumenty podle třídy",
"ApiEndpointFindAllPost": "Dotaz s filtry (tělo JSON)",
"ApiEndpointLoadModel": "Načíst datový model",
"ApiEndpointPing": "Kontrola stavu",
"ApiEndpointTx": "Vytvoření nebo aktualizace dokumentů",
"ApiTokenCopyWarning": "Zkopírujte si tento token nyní. Později jej už neuvidíte.",
"ApiTokenCreated": "Token vytvořen",
"ApiTokenExpiry": "Platnost",
"ApiTokenName": "Název tokenu",
"ApiTokenNoTokens": "Zatím žádné API tokeny",
"ApiTokenRevoke": "Odvolat token",
"ApiTokenRevokeConfirm": "Opravdu chcete tento token odvolat? Už jej nebude možné použít pro přístup k API.",
"ApiTokenWorkspace": "Pracovní prostor",
"ApiTokens": "API tokeny",
"ApiUsageDescription": "Použijte svůj API token s vestavěným REST API k dotazování a úpravě dat pracovního prostoru. Token předejte jako Bearer token v hlavičce Authorization.",
"ApiUsageTitle": "Použití REST API",
"ApiWorkspaceId": "ID vašeho pracovního prostoru (UUID) je součástí tokenu. Předejte jej jako :workspaceId v URL.",
"CreateApiToken": "Vytvořit token",
"Created": "Vytvořeno",
"Expires": "Vyprší",
"Login": "Login",
"Primary": "Primary",
"TokenStatus": "Stav",
"ApiTokenStatusActive": "Aktivní",
"ApiTokenStatusExpiring": "Vyprší",
"ApiTokenStatusRevoked": "Odvolán",
"ApiTokenStatusExpired": "Vypršel",
"ApiTokenExpiry7Days": "7 dní",
"ApiTokenExpiry30Days": "30 dní",
"ApiTokenExpiry90Days": "90 dní",
"ApiTokenExpiry180Days": "180 dní",
"ApiTokenExpiry365Days": "365 dní",
"ApiTokenLoadError": "Nepodařilo se načíst API tokeny",
"ApiTokenCreateError": "Nepodařilo se vytvořit token. Zkuste to prosím znovu.",
"ApiTokenRevokeError": "Odvolání tokenu se nezdařilo. Zkuste to prosím znovu."
}
}
Loading
Loading