Files
speckle-server/packages/server/modules/core/services/tokens.ts
T
Kristaps Fabians Geikins bde148f286 chore(server): migrating fully to ESM (#5042)
* wip

* some extra fixes

* stuff kinda works?

* need to figure out mocks

* need to figure out mocks

* fix db listener

* gqlgen fix

* minor gqlgen watch adjustment

* lint fixes

* delete old codegen file

* converting migrations to ESM

* getModuleDIrectory

* vitest sort of works

* added back ts-vitest

* resolve gql double load

* fixing test timeout configs

* TSC lint fix

* fix automate tests

* moar debugging

* debugging

* more debugging

* codegen update

* server works

* yargs migrated

* chore(server): getting rid of global mocks for Server ESM (#5046)

* got rid of email mock

* got rid of comment mocks

* got rid of multi region mocks

* got rid of stripe mock

* admin override mock updated

* removed final mock

* fixing import.meta.resolve calls

* another import.meta.resolve fix

* added requested test

* nyc ESM fix

* removed unneeded deps + linting

* yarn lock forgot to commit

* tryna fix flakyness

* email capture util fix

* sendEmail fix

* fix TSX check

* sender transporter fix + CR comments

* merge main fix

* test fixx

* circleci fix

* gqlgen bigint fix

* error formatter fix

* more error formatting improvements

* esmloader added to Dockerfile

* more dockerfile fixes

* bg jobs fix
2025-07-14 10:26:19 +03:00

271 lines
7.5 KiB
TypeScript

import bcrypt from 'bcrypt'
import crs from 'crypto-random-string'
import {
TokenResourceAccessRecord,
TokenValidationResult
} from '@/modules/core/helpers/types'
import { Optional, Scopes, ServerScope } from '@speckle/shared'
import {
CountProjectEmbedTokens,
CreateAndStoreAppToken,
CreateAndStoreEmbedToken,
CreateAndStorePersonalAccessToken,
CreateAndStoreUserToken,
GetApiTokenById,
GetPaginatedProjectEmbedTokens,
GetTokenResourceAccessDefinitionsById,
GetTokenScopesById,
ListProjectEmbedTokens,
RevokeUserTokenById,
StoreApiToken,
StoreEmbedApiToken,
StorePersonalApiToken,
StoreTokenResourceAccessDefinitions,
StoreTokenScopes,
StoreUserServerAppToken,
UpdateApiToken,
ValidateToken
} from '@/modules/core/domain/tokens/operations'
import { GetTokenAppInfo } from '@/modules/auth/domain/operations'
import { GetUserRole } from '@/modules/core/domain/users/operations'
import { TokenCreateError } from '@/modules/core/errors/user'
import cryptoRandomString from 'crypto-random-string'
import {
EmbedApiToken,
TokenResourceIdentifierType
} from '@/modules/core/domain/tokens/types'
import {
createGetParamFromResources,
parseUrlParameters
} from '@speckle/shared/viewer/route'
import {
decodeIsoDateCursor,
encodeIsoDateCursor
} from '@/modules/shared/helpers/dbHelper'
import { pick } from 'lodash-es'
import { LogicError } from '@/modules/shared/errors'
/*
Tokens
Note: tokens are composed of a 10 char token id and a 32 char token string.
The token string is smoked, salted and hashed and stored in the database.
*/
export async function createBareToken() {
const tokenId = crs({ length: 10 })
const tokenString = crs({ length: 32 })
const tokenHash = await bcrypt.hash(tokenString, 10)
const lastChars = tokenString.slice(tokenString.length - 6, tokenString.length)
return { tokenId, tokenString, tokenHash, lastChars }
}
type CreateTokenDeps = {
storeApiToken: StoreApiToken
storeTokenScopes: StoreTokenScopes
storeTokenResourceAccessDefinitions: StoreTokenResourceAccessDefinitions
}
export const createTokenFactory =
(deps: CreateTokenDeps): CreateAndStoreUserToken =>
async ({ userId, name, scopes, lifespan, limitResources }) => {
const { tokenId, tokenString, tokenHash, lastChars } = await createBareToken()
if (scopes.length === 0) throw new TokenCreateError('No scopes provided')
const token = {
id: tokenId,
tokenDigest: tokenHash,
lastChars,
owner: userId,
name,
lifespan
}
const tokenScopes = scopes.map((scope) => ({ tokenId, scopeName: scope }))
const resourceAccessEntries: Optional<TokenResourceAccessRecord[]> =
limitResources?.map((resource) => ({
tokenId,
resourceId: resource.id,
resourceType: resource.type
}))
await deps.storeApiToken(token)
await Promise.all([
deps.storeTokenScopes(tokenScopes),
...(resourceAccessEntries?.length
? [deps.storeTokenResourceAccessDefinitions(resourceAccessEntries)]
: [])
])
return { id: tokenId, token: tokenId + tokenString }
}
export const createAppTokenFactory =
(
deps: CreateTokenDeps & {
storeUserServerAppToken: StoreUserServerAppToken
}
): CreateAndStoreAppToken =>
async (params) => {
const token = await createTokenFactory(deps)(params)
await deps.storeUserServerAppToken({
tokenId: token.token.slice(0, 10),
userId: params.userId,
appId: params.appId
})
return token.token
}
/**
* Creates a personal access token for a user with a set of given scopes.
*/
export const createPersonalAccessTokenFactory =
(
deps: CreateTokenDeps & {
storePersonalApiToken: StorePersonalApiToken
}
): CreateAndStorePersonalAccessToken =>
async (
userId: string,
name: string,
scopes: ServerScope[],
lifespan?: number | bigint
) => {
const { id, token } = await createTokenFactory(deps)({
userId,
name,
scopes,
lifespan
})
// Store the relationship
await deps.storePersonalApiToken({ userId, tokenId: id })
return token
}
export const createEmbedTokenFactory =
(deps: {
createToken: CreateAndStoreUserToken
getToken: GetApiTokenById
storeEmbedToken: StoreEmbedApiToken
}): CreateAndStoreEmbedToken =>
async ({ projectId, userId, resourceIdString, lifespan }) => {
const validatedResourceIdString = createGetParamFromResources(
parseUrlParameters(resourceIdString)
)
const { id, token } = await deps.createToken({
userId,
name: cryptoRandomString({ length: 10 }),
scopes: [Scopes.Streams.Read],
limitResources: [
{
id: projectId,
type: TokenResourceIdentifierType.Project
}
],
lifespan
})
const tokenMetadata: EmbedApiToken = {
projectId,
tokenId: id,
userId,
resourceIdString: validatedResourceIdString
}
await deps.storeEmbedToken(tokenMetadata)
const apiToken = await deps.getToken(id)
if (!apiToken) {
throw new LogicError('Failed to create api token for embed')
}
return {
token,
tokenMetadata: {
...tokenMetadata,
...pick(apiToken, 'createdAt', 'lastUsed', 'lifespan')
}
}
}
export const getPaginatedProjectEmbedTokensFactory =
(deps: {
listEmbedTokens: ListProjectEmbedTokens
countEmbedTokens: CountProjectEmbedTokens
}): GetPaginatedProjectEmbedTokens =>
async ({ projectId, filter = {} }) => {
const cursor = filter.cursor ? decodeIsoDateCursor(filter.cursor) : null
const [items, totalCount] = await Promise.all([
deps.listEmbedTokens({
projectId,
filter: {
createdBefore: cursor,
limit: filter.limit
}
}),
deps.countEmbedTokens({ projectId })
])
const lastItem = items.at(-1)
return {
items,
totalCount,
cursor: lastItem ? encodeIsoDateCursor(lastItem.createdAt) : null
}
}
export const validateTokenFactory =
(deps: {
revokeUserTokenById: RevokeUserTokenById
getApiTokenById: GetApiTokenById
getTokenAppInfo: GetTokenAppInfo
getTokenScopesById: GetTokenScopesById
getUserRole: GetUserRole
getTokenResourceAccessDefinitionsById: GetTokenResourceAccessDefinitionsById
updateApiToken: UpdateApiToken
}): ValidateToken =>
async (tokenString: string): Promise<TokenValidationResult> => {
const tokenId = tokenString.slice(0, 10)
const tokenContent = tokenString.slice(10, 42)
const token = await deps.getApiTokenById(tokenId)
if (!token) {
return { valid: false, tokenId }
}
const timeDiff = Math.abs(Date.now() - new Date(token.createdAt).getTime())
if (timeDiff > token.lifespan) {
await deps.revokeUserTokenById(tokenId, token.owner)
return { valid: false, tokenId }
}
const valid = await bcrypt.compare(tokenContent, token.tokenDigest)
if (valid) {
const [scopes, role, app, resourceAccessRules] = await Promise.all([
deps.getTokenScopesById(tokenId),
deps.getUserRole(token.owner),
deps.getTokenAppInfo({ token: tokenString }),
deps.getTokenResourceAccessDefinitionsById(tokenId),
deps.updateApiToken(tokenId, { lastUsed: new Date() })
])
return {
valid: true,
userId: token.owner,
role: role!,
scopes: scopes.map((s) => s.scopeName),
appId: app?.id || null,
resourceAccessRules: resourceAccessRules.length ? resourceAccessRules : null,
tokenId
}
} else return { valid: false, tokenId }
}