Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/web/src/lib/server/events/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
: {}),
},
})
}

Expand Down
37 changes: 34 additions & 3 deletions apps/web/src/lib/server/integrations/oauth-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })

Expand Down Expand Up @@ -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,
Expand All @@ -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<OAuthState>(state || '')
if (!stateData || stateData.type !== definition.oauth.stateType) {
Expand All @@ -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`))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'")
})
})
52 changes: 52 additions & 0 deletions apps/web/src/lib/server/integrations/trello/fragment-bridge.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Completing Trello connection</title>
</head>
<body>
<p>Completing Trello connection…</p>
<script>
(() => {
const fragment = new URLSearchParams(window.location.hash.slice(1));
const token = fragment.get('token');
const error = fragment.get('error');
const form = document.createElement('form');
form.method = 'post';
form.action = window.location.pathname + window.location.search;

const field = document.createElement('input');
field.type = 'hidden';
field.name = token ? 'code' : 'error';
field.value = token || error || 'missing_token';
form.appendChild(field);

window.history.replaceState(null, '', form.action);
document.body.appendChild(form);
form.submit();
})();
</script>
</body>
</html>`

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',
},
})
}
4 changes: 4 additions & 0 deletions apps/web/src/routes/oauth/$integration/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
},
},
})