Skip to content
126 changes: 126 additions & 0 deletions src/api/gmail-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,129 @@ describe('handleGmailConnectCallback', () => {
expect(html).not.toContain('<script>evil()</script>')
})
})

describe('handleGmailConnectCallback with uiBaseUrl configured (HT-123)', () => {
function callbackRequest(query: string): Request {
return new Request(`${CALLBACK_URL}${query}`)
}

const UI_BASE_URL = 'https://desk.example.test'

it('302-redirects to /manage/mailboxes?connected=<address> on success', async () => {
const service = fakeService({
completeConnect: async () => ({ mailboxId: 'mb-1', address: 'connected@example.test' }),
})
const res = await handleGmailConnectCallback(callbackRequest('?code=abc&state=xyz'), {
service,
uiBaseUrl: UI_BASE_URL,
})

expect(res.status).toBe(302)
expect(res.headers.get('Location')).toBe(
`${UI_BASE_URL}/manage/mailboxes?connected=connected%40example.test`,
)
expect(res.headers.get('Cache-Control')).toBe('no-store')
})

it('302-redirects to /manage/mailboxes?connect_error=missing_params when code/state are missing, without calling the service', async () => {
const completeConnect = vi.fn()
const service = fakeService({ completeConnect: completeConnect as never })

const res = await handleGmailConnectCallback(callbackRequest(''), {
service,
uiBaseUrl: UI_BASE_URL,
})

expect(res.status).toBe(302)
expect(res.headers.get('Location')).toBe(
`${UI_BASE_URL}/manage/mailboxes?connect_error=missing_params`,
)
expect(completeConnect).not.toHaveBeenCalled()
})

it.each([
['invalid_state' as const],
['exchange_failed' as const],
['no_refresh_token' as const],
['watch_failed' as const],
])(
'302-redirects to /manage/mailboxes?connect_error=%s for a caught GmailConnectError, never including its message',
async (code) => {
const service = fakeService({
completeConnect: async () => {
throw new GmailConnectError(
code,
`safe message for ${code} β€” must never appear in the URL`,
)
},
})

const res = await handleGmailConnectCallback(callbackRequest('?code=abc&state=xyz'), {
service,
uiBaseUrl: UI_BASE_URL,
})

expect(res.status).toBe(302)
const location = res.headers.get('Location') ?? ''
expect(location).toBe(`${UI_BASE_URL}/manage/mailboxes?connect_error=${code}`)
expect(location).not.toContain('safe message')
},
)

it('302-redirects to /manage/mailboxes?connect_error=server_error for an unexpected throw, without leaking the thrown message', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const service = fakeService({
completeConnect: async () => {
throw new Error('db connection refused β€” must never reach the browser or the URL')
},
})

const res = await handleGmailConnectCallback(callbackRequest('?code=abc&state=xyz'), {
service,
uiBaseUrl: UI_BASE_URL,
})

expect(res.status).toBe(302)
expect(res.headers.get('Location')).toBe(
`${UI_BASE_URL}/manage/mailboxes?connect_error=server_error`,
)
errorSpy.mockRestore()
})

it('never carries the code, state, or a GmailConnectError message anywhere in the redirect Location', async () => {
const service = fakeService({
completeConnect: async () => {
throw new GmailConnectError('invalid_state', 'This connect link is invalid or has expired.')
},
})

const res = await handleGmailConnectCallback(
callbackRequest('?code=super-secret-auth-code&state=super-secret-state-value'),
{ service, uiBaseUrl: UI_BASE_URL },
)

const location = res.headers.get('Location') ?? ''
expect(location).not.toContain('super-secret-auth-code')
expect(location).not.toContain('super-secret-state-value')
// The thrown message itself must not leak either β€” the test's own title
// promises this, and asserting only the code/state left it unchecked.
expect(location).not.toContain('invalid or has expired')
// Pin the WHOLE redirect: anything beyond the fixed error code would have
// to show up here, so this closes the gap by construction rather than by
// enumerating individual secrets.
expect(location).toBe(`${UI_BASE_URL}/manage/mailboxes?connect_error=invalid_state`)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('falls back to the plain HTML page when uiBaseUrl is not configured (regression guard)', async () => {
const service = fakeService({
completeConnect: async () => ({ mailboxId: 'mb-1', address: 'connected@example.test' }),
})
const res = await handleGmailConnectCallback(callbackRequest('?code=abc&state=xyz'), {
service,
})

expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
expect(res.headers.get('Location')).toBeNull()
})
})
82 changes: 79 additions & 3 deletions src/api/gmail-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,25 @@
* success) the resolved address.
*/

import { GmailConnectError, type GmailConnectService } from '../mail/gmail-connect.js'
import {
GmailConnectError,
type GmailConnectErrorCode,
type GmailConnectService,
} from '../mail/gmail-connect.js'
import { apiError, json } from './responses.js'

/** Dependencies both handlers need. ABSENT BY DEFAULT on `InboxApiDeps` (`src/api/index.ts`) β€” a deployment that hasn't provisioned Gmail OAuth yet (HT-43) simply never configures this. */
export interface GmailConnectDeps {
service: GmailConnectService
/**
* The operator UI's bare origin (`AppConfig.uiBaseUrl`, same field
* `src/composition/app.ts` uses for the bare-root redirect). OPTIONAL β€”
* when absent, the callback falls back to the plain HTML page below
* exactly as before (no UI to send the operator back to). When present,
* the callback redirects there instead so the operator lands back in the
* app rather than stranded on a bare page.
*/
uiBaseUrl?: string
}

/** Minimal HTML escaping for the handful of dynamic values ever spliced into a callback page β€” see the module doc. */
Expand Down Expand Up @@ -87,6 +100,35 @@ function htmlResponse(status: number, title: string, bodyHtml: string): Response
})
}

/** The mailbox list screen the callback redirects back to when `uiBaseUrl` is configured. */
const MAILBOXES_PATH = '/manage/mailboxes'

/** Short, secret-free codes the redirect's `connect_error` query param can carry β€” never a `GmailConnectError` message, `code`, `state`, or token. */
type ConnectRedirectErrorCode = GmailConnectErrorCode | 'missing_params' | 'server_error'

/**
* Build the `302` back to `${uiBaseUrl}${MAILBOXES_PATH}`, carrying ONLY the
* connected address (on success) or a short error code (on failure) β€”
* never a token, `code`, `state`, or secret (module doc). `URL`'s own
* encoding handles the query-string escaping, so no HTML-escaping is
* needed here (this is a redirect header, not rendered markup).
*/
function redirectToMailboxes(
uiBaseUrl: string,
query: { connected: string } | { connect_error: ConnectRedirectErrorCode },
): Response {
const url = new URL(MAILBOXES_PATH, uiBaseUrl)
if ('connected' in query) {
url.searchParams.set('connected', query.connected)
} else {
url.searchParams.set('connect_error', query.connect_error)
}
return new Response(null, {
status: 302,
headers: { Location: url.toString(), 'Cache-Control': 'no-store' },
})
}

/**
* Handle `POST /api/v1/inbound/gmail/connect` (gmail-connect.md Β§2a). The
* router (`src/api/router.ts`) guarantees the method is `POST` and the
Expand All @@ -106,7 +148,13 @@ export async function handleGmailConnect(
const { consentUrl } = deps.service.beginConnect()
return json(200, { consentUrl })
} catch (err) {
console.error('[gmail-connect] unhandled error beginning connect', err)
// Log the error's CLASS only, never the caught object or its message: an
// unexpected failure on the OAuth path can originate upstream (a token
// exchange, a provider HTTP error) and carry a token or `code` in its
// text. The "never log a secret" guarantee outranks log fidelity here.
console.error('[gmail-connect] unhandled error beginning connect', {
error: err instanceof Error ? err.name : typeof err,
})
return apiError(500, 'server_error', 'Internal server error.')
}
}
Expand All @@ -127,6 +175,16 @@ export async function handleGmailConnect(
* unexpected) β†’ `500`, generic message.
* - Success β†’ `200` confirming the connected address.
*
* **When `deps.uiBaseUrl` is configured**, every one of those outcomes
* becomes a `302` to `${uiBaseUrl}/manage/mailboxes` instead, so the
* operator lands back in the app rather than on a bare page: `?connected=
* <address>` on success, `?connect_error=<short code>` on every failure
* branch (HT-123). The redirect never carries a token, `code`, `state`, or
* any `GmailConnectError` message β€” only the resolved address or one of
* {@link ConnectRedirectErrorCode}'s fixed codes ({@link
* redirectToMailboxes}). **When `uiBaseUrl` is absent, this is unchanged**:
* the plain HTML pages below, exactly as before.
*
* Wrapped in its own try/catch: like `handleGmailPushWebhook`
* (`src/api/gmail-webhook.ts`), this handler runs in a PRE-AUTH branch of
* `createInboxApi` (`src/api/index.ts`), before the outer try/catch that
Expand All @@ -137,12 +195,16 @@ export async function handleGmailConnectCallback(
request: Request,
deps: GmailConnectDeps,
): Promise<Response> {
const { uiBaseUrl } = deps
try {
const params = new URL(request.url).searchParams
const code = params.get('code')
const state = params.get('state')

if (code === null || code.length === 0 || state === null || state.length === 0) {
if (uiBaseUrl !== undefined) {
return redirectToMailboxes(uiBaseUrl, { connect_error: 'missing_params' })
}
return htmlResponse(
400,
'Connection failed',
Expand All @@ -152,19 +214,33 @@ export async function handleGmailConnectCallback(

try {
const { address } = await deps.service.completeConnect({ code, state })
if (uiBaseUrl !== undefined) {
return redirectToMailboxes(uiBaseUrl, { connected: address })
}
return htmlResponse(
200,
'Mailbox connected',
`<p>Successfully connected <strong>${escapeHtml(address)}</strong>. You can close this window.</p>`,
)
} catch (err) {
if (err instanceof GmailConnectError) {
if (uiBaseUrl !== undefined) {
return redirectToMailboxes(uiBaseUrl, { connect_error: err.code })
}
return htmlResponse(400, 'Connection failed', `<p>${escapeHtml(err.message)}</p>`)
}
throw err
}
} catch (err) {
console.error('[gmail-connect] unhandled error completing connect', err)
// Class only, never the caught object/message β€” see handleGmailConnect's
// identical guard. This path is reached AFTER a token exchange, so an
// upstream error's text is exactly where a leaked token would surface.
console.error('[gmail-connect] unhandled error completing connect', {
error: err instanceof Error ? err.name : typeof err,
})
if (uiBaseUrl !== undefined) {
return redirectToMailboxes(uiBaseUrl, { connect_error: 'server_error' })
}
return htmlResponse(
500,
'Connection failed',
Expand Down
8 changes: 7 additions & 1 deletion src/composition/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,13 @@ export async function buildApp(
watchStateStore,
createWatchClient: (getAccessToken) => createGmailWatchClient({ getAccessToken }),
})
const gmailConnect: GmailConnectDeps = { service: connectService }
const gmailConnect: GmailConnectDeps = {
service: connectService,
// Optional (HT-123): when configured, the callback redirects the
// operator back into the app instead of rendering a bare HTML page β€”
// same field `app.ts` uses for the bare-root redirect.
...(config.uiBaseUrl !== undefined ? { uiBaseUrl: config.uiBaseUrl } : {}),
}

// --- Gmail disconnect admin action (HT-47) β€” the inverse of connect. ---
const disconnectService = createGmailDisconnectService({
Expand Down
Loading