From cf4164d5e43e4a1395bdae4eb2e619f65e03a56d Mon Sep 17 00:00:00 2001 From: ChrisAceda Date: Tue, 21 Jul 2026 14:30:30 +0000 Subject: [PATCH 1/2] fix: handle Trello fragment OAuth callback --- .../lib/server/integrations/oauth-handlers.ts | 37 +++++++++++-- .../trello/__tests__/fragment-bridge.test.ts | 26 ++++++++++ .../integrations/trello/fragment-bridge.ts | 52 +++++++++++++++++++ .../src/routes/oauth/$integration/callback.ts | 4 ++ 4 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/server/integrations/trello/__tests__/fragment-bridge.test.ts create mode 100644 apps/web/src/lib/server/integrations/trello/fragment-bridge.ts diff --git a/apps/web/src/lib/server/integrations/oauth-handlers.ts b/apps/web/src/lib/server/integrations/oauth-handlers.ts index 65115550a..902b60224 100644 --- a/apps/web/src/lib/server/integrations/oauth-handlers.ts +++ b/apps/web/src/lib/server/integrations/oauth-handlers.ts @@ -23,6 +23,7 @@ import { isValidTenantDomain, } from './oauth' import { logger } from '@/lib/server/logger' +import { trelloFragmentBridgeResponse } from './trello/fragment-bridge' const log = logger.child({ component: 'oauth' }) @@ -109,7 +110,7 @@ export async function handleOAuthConnect( } /** - * Handle the OAuth callback (GET /oauth/:integration/callback) + * Handle the OAuth callback (GET/POST /oauth/:integration/callback) */ export async function handleOAuthCallback( request: Request, @@ -121,15 +122,22 @@ export async function handleOAuthCallback( return Response.json({ error: 'Unknown integration' }, { status: 404 }) } + if (request.method === 'POST' && integrationType !== 'trello') { + return Response.json( + { error: 'Method not allowed' }, + { status: 405, headers: { Allow: 'GET' } } + ) + } + const settingsPath = definition.catalog.settingsPath const errorUrl = (base: string, reason: string) => buildSettingsUrl(base, settingsPath, integrationType, 'error', reason) const url = new URL(request.url) - const code = url.searchParams.get('code') + let code = url.searchParams.get('code') const state = url.searchParams.get('state') const errorParam = definition.oauth.errorParam ?? 'error' - const providerError = url.searchParams.get(errorParam) + let providerError = url.searchParams.get(errorParam) const stateData = verifyOAuthState(state || '') if (!stateData || stateData.type !== definition.oauth.stateType) { @@ -147,6 +155,29 @@ export async function handleOAuthCallback( return redirectResponse(errorUrl(FALLBACK_URL, 'invalid_tenant')) } + // Trello's fragment callback keeps the long-lived access token out of the + // HTTP request. The bridge page reads it in the browser and POSTs it back so + // it never appears in the callback query string or reverse-proxy access log. + if (integrationType === 'trello' && request.method === 'GET' && !code && !providerError) { + return trelloFragmentBridgeResponse() + } + + if (request.method === 'POST') { + try { + const contentType = request.headers.get('content-type') || '' + if (!contentType.startsWith('application/x-www-form-urlencoded')) { + return redirectResponse(errorUrl(tenantUrl, 'invalid_request')) + } + const formData = await request.formData() + const postedCode = formData.get('code') + const postedError = formData.get(errorParam) + code = typeof postedCode === 'string' ? postedCode : code + providerError = typeof postedError === 'string' ? postedError : providerError + } catch { + return redirectResponse(errorUrl(tenantUrl, 'invalid_request')) + } + } + if (providerError) { return redirectResponse(errorUrl(tenantUrl, `${integrationType}_denied`)) } diff --git a/apps/web/src/lib/server/integrations/trello/__tests__/fragment-bridge.test.ts b/apps/web/src/lib/server/integrations/trello/__tests__/fragment-bridge.test.ts new file mode 100644 index 000000000..9109de840 --- /dev/null +++ b/apps/web/src/lib/server/integrations/trello/__tests__/fragment-bridge.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { trelloFragmentBridgeResponse } from '../fragment-bridge' + +describe('trelloFragmentBridgeResponse', () => { + it('POSTs the fragment token without copying it into the query string', async () => { + const response = trelloFragmentBridgeResponse() + const html = await response.text() + + expect(response.status).toBe(200) + expect(html).toContain("fragment.get('token')") + expect(html).toContain("form.method = 'post'") + expect(html).toContain("field.name = token ? 'code' : 'error'") + expect(html).toContain('window.location.pathname + window.location.search') + expect(html).not.toContain("searchParams.set('code'") + }) + + it('prevents caching, referrer leakage, and framing', () => { + const response = trelloFragmentBridgeResponse() + + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + expect(response.headers.get('content-security-policy')).toContain("form-action 'self'") + expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'") + }) +}) diff --git a/apps/web/src/lib/server/integrations/trello/fragment-bridge.ts b/apps/web/src/lib/server/integrations/trello/fragment-bridge.ts new file mode 100644 index 000000000..c41bdcdfb --- /dev/null +++ b/apps/web/src/lib/server/integrations/trello/fragment-bridge.ts @@ -0,0 +1,52 @@ +/** + * Return a one-use browser bridge for Trello's fragment-based token response. + * + * URL fragments are not sent to the server. The bridge reads the token in the + * browser and POSTs it to the same callback URL, keeping the long-lived token + * out of query strings, browser history, referrers, and reverse-proxy logs. + */ +export function trelloFragmentBridgeResponse(): Response { + const html = ` + + + + + Completing Trello connection + + +

Completing Trello connection…

+ + +` + + return new Response(html, { + status: 200, + headers: { + 'Cache-Control': 'no-store', + 'Content-Security-Policy': + "default-src 'none'; script-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + 'Content-Type': 'text/html; charset=utf-8', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + }, + }) +} diff --git a/apps/web/src/routes/oauth/$integration/callback.ts b/apps/web/src/routes/oauth/$integration/callback.ts index 75116f39c..4e0f0e6a3 100644 --- a/apps/web/src/routes/oauth/$integration/callback.ts +++ b/apps/web/src/routes/oauth/$integration/callback.ts @@ -7,6 +7,10 @@ export const Route = createFileRoute('/oauth/$integration/callback')({ const { handleOAuthCallback } = await import('@/lib/server/integrations/oauth-handlers') return handleOAuthCallback(request, params.integration) }, + POST: async ({ request, params }) => { + const { handleOAuthCallback } = await import('@/lib/server/integrations/oauth-handlers') + return handleOAuthCallback(request, params.integration) + }, }, }, }) From 78c92034869c5959c508db6eaa054b8b912b3813 Mon Sep 17 00:00:00 2001 From: ChrisAceda Date: Wed, 22 Jul 2026 12:55:27 +0000 Subject: [PATCH 2/2] Fix Trello API key passed to event hook --- apps/web/src/lib/server/events/targets.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/events/targets.ts b/apps/web/src/lib/server/events/targets.ts index fd0f371d2..682beaebb 100644 --- a/apps/web/src/lib/server/events/targets.ts +++ b/apps/web/src/lib/server/events/targets.ts @@ -325,7 +325,13 @@ async function getIntegrationTargets( targets.push({ type: m.integrationType, target: { channelId }, - config: { accessToken, rootUrl: context.portalBaseUrl }, + config: { + accessToken, + rootUrl: context.portalBaseUrl, + ...(m.integrationType === 'trello' + ? { apiKey: process.env.TRELLO_API_KEY } + : {}), + }, }) }