diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1c023e0cec..22012b479f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -63,6 +63,7 @@ "highlight.js": "^11.7.0", "intl-messageformat": "^10.7.7", "iso-3166-2": "1.0.0", + "jose": "^6.2.2", "js-yaml": "^4.1.0", "jszip": "^3.10.1", "lru-cache": "^11.2.4", diff --git a/apps/frontend/src/pages/hosting/manage/[id].vue b/apps/frontend/src/pages/hosting/manage/[id].vue index 7c02b51bd8..6ea25b17c4 100644 --- a/apps/frontend/src/pages/hosting/manage/[id].vue +++ b/apps/frontend/src/pages/hosting/manage/[id].vue @@ -1335,18 +1335,11 @@ async function initializeIntercom() { if (!auth.value?.user) return try { - const intercomData = await $fetch<{ - token: string - user: AuthUser - }>('/api/intercom/messenger-jwt') + const intercomData = await $fetch<{ token: string }>('/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) { diff --git a/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts b/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts new file mode 100644 index 0000000000..047f85d865 --- /dev/null +++ b/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts @@ -0,0 +1,99 @@ +import { type Labrinth, ModrinthApiError } from '@modrinth/api-client' +import { SignJWT } from 'jose' + +import { useServerModrinthClient } from '~/server/utils/api-client' + +type IntercomTokenResponse = { + token: string +} + +async function signIntercomUserJwt( + user: { id: string; username: string; email?: string; created: string }, + secret: string, +): Promise { + const createdAt = Math.floor(new Date(user.created).getTime() / 1000) + + const payload: Record = { + user_id: user.id, + name: user.username, + } + + if (user.email) { + payload.email = user.email + } + + if (Number.isFinite(createdAt)) { + payload.created_at = createdAt + } + + return await new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(new TextEncoder().encode(secret)) +} + +export default defineEventHandler(async (event): Promise => { + if (event.method !== 'GET') { + throw createError({ + statusCode: 405, + message: 'Method not allowed', + }) + } + + const authToken = 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: { id: string; username: string; email?: string; created: string } + 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 token = await signIntercomUserJwt(user, config.intercomIdentitySecret) + + return { + token, + } +}) diff --git a/apps/frontend/src/server/routes/api/intercom/messenger-jwt.ts b/apps/frontend/src/server/routes/api/intercom/messenger-jwt.ts deleted file mode 100644 index dd1ebaf8f8..0000000000 --- a/apps/frontend/src/server/routes/api/intercom/messenger-jwt.ts +++ /dev/null @@ -1,119 +0,0 @@ -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/pnpm-lock.yaml b/pnpm-lock.yaml index acb12e2cc8..d81867dc9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: iso-3166-2: specifier: 1.0.0 version: 1.0.0 + jose: + specifier: ^6.2.2 + version: 6.2.2 js-yaml: specifier: ^4.1.0 version: 4.1.1 @@ -6727,6 +6730,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -16355,6 +16361,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-beautify@1.15.4: dependencies: config-chain: 1.1.13