From 26b2febcc8a0c00b39cc5e22de29c4c17e691f36 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 3 Apr 2026 16:53:57 +0100 Subject: [PATCH] wip: icom jwts --- apps/frontend/nuxt.config.ts | 9 ++ .../src/pages/hosting/manage/[id].vue | 61 ++++----- .../routes/api/intercom/messenger-jwt.ts | 119 ++++++++++++++++++ turbo.jsonc | 2 + 4 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 apps/frontend/src/server/routes/api/intercom/messenger-jwt.ts diff --git a/apps/frontend/nuxt.config.ts b/apps/frontend/nuxt.config.ts index 693693853c..3fbc024304 100644 --- a/apps/frontend/nuxt.config.ts +++ b/apps/frontend/nuxt.config.ts @@ -207,10 +207,19 @@ export default defineNuxtConfig({ // @ts-ignore rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY ?? globalThis.RATE_LIMIT_IGNORE_KEY, pyroBaseUrl: process.env.PYRO_BASE_URL, + intercomIdentitySecret: + process.env.INTERCOM_IDENTITY_SECRET || + // @ts-ignore + globalThis.INTERCOM_IDENTITY_SECRET, public: { apiBaseUrl: getApiUrl(), pyroBaseUrl: process.env.PYRO_BASE_URL, siteUrl: getDomain(), + intercomAppId: + process.env.INTERCOM_APP_ID || + // @ts-ignore + globalThis.INTERCOM_APP_ID || + 'ykeritl9', production: isProduction(), buildEnv: process.env.BUILD_ENV, preview: process.env.PREVIEW === 'true', diff --git a/apps/frontend/src/pages/hosting/manage/[id].vue b/apps/frontend/src/pages/hosting/manage/[id].vue index be76662c72..7c02b51bd8 100644 --- a/apps/frontend/src/pages/hosting/manage/[id].vue +++ b/apps/frontend/src/pages/hosting/manage/[id].vue @@ -414,17 +414,16 @@ const isLoading = ref(true) const isMounted = ref(true) const unsubscribers = ref<(() => void)[]>([]) const flags = useFeatureFlags() +const config = useRuntimeConfig() -const INTERCOM_APP_ID = ref('ykeritl9') -const auth = (await useAuth()) as unknown as { - value: { user: { id: string; username: string; email: string; created: string } } +type AuthUser = { + id: string + username: string + email?: string + created: string } -const userId = ref(auth.value?.user?.id ?? null) -const username = ref(auth.value?.user?.username ?? null) -const email = ref(auth.value?.user?.email ?? null) -const createdAt = ref( - auth.value?.user?.created ? Math.floor(new Date(auth.value.user.created).getTime() / 1000) : null, -) + +const auth = (await useAuth()) as unknown as { value: { user: AuthUser | null } } const debug = useDebugLogger('ServerManage') const route = useNativeRoute() @@ -1332,6 +1331,29 @@ const openInstallLog = () => { }) } +async function initializeIntercom() { + if (!auth.value?.user) return + + try { + const intercomData = await $fetch<{ + token: string + user: AuthUser + }>('/api/intercom/messenger-jwt') + + Intercom({ + app_id: config.public.intercomAppId, + intercom_user_jwt: intercomData.token, + user_id: intercomData.user.id, + name: intercomData.user.username, + email: intercomData.user.email, + created_at: Math.floor(new Date(intercomData.user.created).getTime() / 1000), + session_duration: 1000 * 60 * 60 * 24, + }) + } catch (error) { + console.warn('[PYROSERVERS][INTERCOM] failed to initialize secure support chat', error) + } +} + const cleanup = () => { isMounted.value = false @@ -1490,26 +1512,7 @@ onMounted(() => { }) } - if (username.value && email.value && userId.value && createdAt.value) { - const currentUser = auth.value?.user as any - const matches = - username.value === currentUser?.username && - email.value === currentUser?.email && - userId.value === currentUser?.id && - createdAt.value === Math.floor(new Date(currentUser?.created).getTime() / 1000) - - if (matches) { - Intercom({ - app_id: INTERCOM_APP_ID.value, - userId: userId.value, - name: username.value, - email: email.value, - created_at: createdAt.value, - }) - } else { - console.warn('[PYROSERVERS][INTERCOM] mismatch') - } - } + void initializeIntercom() DOMPurify.addHook( 'afterSanitizeAttributes', diff --git a/apps/frontend/src/server/routes/api/intercom/messenger-jwt.ts b/apps/frontend/src/server/routes/api/intercom/messenger-jwt.ts new file mode 100644 index 0000000000..dd1ebaf8f8 --- /dev/null +++ b/apps/frontend/src/server/routes/api/intercom/messenger-jwt.ts @@ -0,0 +1,119 @@ +import { createHmac } from 'node:crypto' + +import { ModrinthApiError } from '@modrinth/api-client' + +import { useServerModrinthClient } from '~/server/utils/api-client' + +type AuthenticatedUser = { + id: string + username: string + email?: string + created: string +} + +type IntercomTokenResponse = { + token: string + user: AuthenticatedUser +} + +function base64UrlEncode(input: string | Buffer): string { + return Buffer.from(input) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') +} + +function signIntercomUserJwt(payload: Record, secret: string): string { + const header = { + alg: 'HS256', + typ: 'JWT', + } + + const encodedHeader = base64UrlEncode(JSON.stringify(header)) + const encodedPayload = base64UrlEncode(JSON.stringify(payload)) + const unsignedToken = `${encodedHeader}.${encodedPayload}` + + const signature = createHmac('sha256', secret).update(unsignedToken).digest() + + return `${unsignedToken}.${base64UrlEncode(signature)}` +} + +export default defineEventHandler(async (event): Promise => { + if (getMethod(event) !== 'GET') { + throw createError({ + statusCode: 405, + message: 'Method not allowed', + }) + } + + const headerToken = getHeader(event, 'authorization') + const parsedHeaderToken = headerToken?.replace(/^Bearer\s+/i, '').trim() + const authToken = parsedHeaderToken || getCookie(event, 'auth-token') + if (!authToken) { + throw createError({ + statusCode: 401, + message: 'Authentication required', + }) + } + + setHeader(event, 'cache-control', 'private, no-store, max-age=0') + + const config = useRuntimeConfig(event) + if (!config.intercomIdentitySecret) { + throw createError({ + statusCode: 500, + message: 'Intercom identity secret is not configured', + }) + } + + const client = useServerModrinthClient({ + event, + authToken, + }) + + let user: AuthenticatedUser + try { + const currentUser = await client.request('/user', { + api: 'labrinth', + version: 2, + method: 'GET', + }) + user = { + id: currentUser.id, + username: currentUser.username, + email: currentUser.email, + created: currentUser.created, + } + } catch (error) { + if (error instanceof ModrinthApiError && error.statusCode === 401) { + throw createError({ + statusCode: 401, + message: 'Authentication required', + }) + } + + throw createError({ + statusCode: 502, + message: 'Failed to resolve current user', + }) + } + + const now = Math.floor(Date.now() / 1000) + const token = signIntercomUserJwt( + { + user_id: user.id, + email: user.email, + name: user.username, + created_at: Math.floor(new Date(user.created).getTime() / 1000), + iat: now, + exp: now + 60 * 60, + }, + config.intercomIdentitySecret, + ) + + return { + token, + user, + } +}) diff --git a/turbo.jsonc b/turbo.jsonc index 542fa76bec..ec7db4e25b 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -24,6 +24,8 @@ "CF_PAGES_*", "HEROKU_APP_NAME", "STRIPE_PUBLISHABLE_KEY", + "INTERCOM_APP_ID", + "INTERCOM_IDENTITY_SECRET", "PYRO_BASE_URL", "PROD_OVERRIDE", "PYRO_MASTER_KEY",