import { randomBytes } from 'node:crypto' import type { RequiredEntityData } from '@mikro-orm/core' import { EntityManager } from '@mikro-orm/postgresql' import { hash, compare } from 'bcryptjs' import { ScimToken, SsoConfig } from '../data/entities' import type { SsoAdminScope } from './ssoConfigService' const BCRYPT_COST = 10 const TOKEN_PREFIX = 'omscim_' export interface ScimTokenPublic { id: string ssoConfigId: string name: string tokenPrefix: string isActive: boolean createdBy: string | null createdAt: Date } export interface ScimTokenCreateResult { id: string token: string prefix: string name: string } export class ScimTokenService { constructor(private em: EntityManager) {} async generateToken( ssoConfigId: string, name: string, scope: SsoAdminScope, ): Promise { const where: Record = { id: ssoConfigId, deletedAt: null } if (!scope.isSuperAdmin) { if (!scope.organizationId) throw new ScimTokenError('Organization context is required', 403) where.organizationId = scope.organizationId } const config = await this.em.findOne(SsoConfig, where) if (!config) throw new ScimTokenError('SSO configuration not found', 404) if (config.jitEnabled) { throw new ScimTokenError('Cannot create SCIM tokens while JIT provisioning is enabled. Disable JIT first.', 409) } const raw = TOKEN_PREFIX + randomBytes(32).toString('hex') const tokenHash = await hash(raw, BCRYPT_COST) const tokenPrefix = raw.slice(0, 12) const token = this.em.create(ScimToken, { ssoConfigId, name, tokenHash, tokenPrefix, isActive: true, createdBy: null, tenantId: config.tenantId ?? null, organizationId: config.organizationId, } as RequiredEntityData) await this.em.persist(token).flush() return { id: token.id, token: raw, prefix: tokenPrefix, name } } async verifyToken(rawToken: string): Promise<{ ssoConfigId: string organizationId: string tenantId: string | null } | null> { const prefix = rawToken.slice(0, 12) const candidates = await this.em.find(ScimToken, { tokenPrefix: prefix, isActive: true, }) if (candidates.length === 0) { await hash(rawToken, BCRYPT_COST) return null } for (const candidate of candidates) { const isValid = await compare(rawToken, candidate.tokenHash) if (isValid) { return { ssoConfigId: candidate.ssoConfigId, organizationId: candidate.organizationId, tenantId: candidate.tenantId ?? null, } } } return null } async revokeToken(tokenId: string, scope: SsoAdminScope): Promise { const where: Record = { id: tokenId } if (!scope.isSuperAdmin) where.organizationId = scope.organizationId const token = await this.em.findOne(ScimToken, where) if (!token) throw new ScimTokenError('SCIM token not found', 404) token.isActive = false await this.em.flush() } async listTokens(ssoConfigId: string, scope: SsoAdminScope): Promise { const where: Record = { ssoConfigId } if (!scope.isSuperAdmin) where.organizationId = scope.organizationId const tokens = await this.em.find(ScimToken, where, { orderBy: { createdAt: 'desc' }, }) return tokens.map((t) => ({ id: t.id, ssoConfigId: t.ssoConfigId, name: t.name, tokenPrefix: t.tokenPrefix, isActive: t.isActive, createdBy: t.createdBy ?? null, createdAt: t.createdAt, })) } } export class ScimTokenError extends Error { constructor( message: string, public readonly statusCode: number, ) { super(message) this.name = 'ScimTokenError' } }