From 75cb0f62fe793316535b93afc8f41c4a91042b87 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:49:53 +0100 Subject: [PATCH] fix: measure and trace the sign-in check's calls to Stalwart --- lib/auth/verify-jmap-auth.ts | 23 ++++++++++++++++------- lib/tracing/__tests__/tracing.test.ts | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts index de6edaf88..6c455cdf4 100644 --- a/lib/auth/verify-jmap-auth.ts +++ b/lib/auth/verify-jmap-auth.ts @@ -5,6 +5,7 @@ import { type PublicFetchResponse, } from '@/lib/security/url-guard'; import { forwardedClientHeaders } from '@/lib/auth/client-address'; +import { observeUpstream } from '@/lib/metrics/upstream'; const VERIFY_TIMEOUT_MS = 10000; const MAX_REDIRECTS = 3; @@ -150,7 +151,7 @@ export function assertBasicAuthMatchesUsername(authHeader: string, claimedUserna export interface VerifiedJmapSession { apiUrl: string; - /** Canonical login name the server reports for the credential (RFC 8620 §2). */ + /** Canonical login name the server reports for the credential (RFC 8620 §2). */ username?: string; primaryAccounts?: Record; accounts: Record; @@ -361,9 +362,13 @@ async function fetchIdentityEmails( }; // No redirect handling here: a redirect on the POST would downgrade it to // a GET and can only come from a misconfigured proxy - treat as failure. - const response = options.trusted - ? await fetch(apiUrl, { ...requestInit, redirect: 'manual' }) - : await fetchPublicUrl(apiUrl, requestInit); + // Measured and traced like every call to Stalwart (lib/metrics/upstream.ts). + const response = await observeUpstream('stalwart', async (trace) => { + const init = { ...requestInit, headers: { ...requestInit.headers, ...trace } }; + return options.trusted + ? await fetch(apiUrl, { ...init, redirect: 'manual' }) + : await fetchPublicUrl(apiUrl, init); + }); if (!response.ok) { throw new JmapAuthVerificationError( response.status === 401 || response.status === 403 @@ -435,9 +440,13 @@ async function fetchVerifiedSession( // Untrusted (user-supplied) endpoints connect through the rebinding-safe // fetch so the address validated above is the one the socket uses. // Admin-configured servers may legitimately live on private addresses. - response = options.trusted - ? await fetch(currentUrl, { ...requestInit, redirect: 'manual' }) - : await fetchPublicUrl(currentUrl, requestInit); + const target = currentUrl; + response = await observeUpstream('stalwart', async (trace) => { + const init = { ...requestInit, headers: { ...requestInit.headers, ...trace } }; + return options.trusted + ? await fetch(target, { ...init, redirect: 'manual' }) + : await fetchPublicUrl(target, init); + }); if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location'); diff --git a/lib/tracing/__tests__/tracing.test.ts b/lib/tracing/__tests__/tracing.test.ts index 5134404b9..dd9b5c73c 100644 --- a/lib/tracing/__tests__/tracing.test.ts +++ b/lib/tracing/__tests__/tracing.test.ts @@ -13,6 +13,7 @@ import { markTracingActive } from '../config'; import { scrubAttributes, scrubSpanName } from '../scrub'; import { observeUpstream } from '@/lib/metrics/upstream'; import { fetchJmapServer } from '@/lib/stalwart/server-fetch'; +import { verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; const exporter = new InMemorySpanExporter(); const provider = createProvider(exporter, false); @@ -56,6 +57,20 @@ describe('spans for upstream calls', () => { } }); + it('reach Stalwart from the sign-in check too', async () => { + const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({ apiUrl: 'https://mail.example.org/jmap/', username: 'u@example.org' }), { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + try { + await verifyJmapAuth('https://mail.example.org', 'Basic dTpw', { trusted: true }).catch(() => undefined); + const headers = new Headers((fetchSpy.mock.calls[0][1] as RequestInit).headers); + expect(String(fetchSpy.mock.calls[0][0])).toBe('https://mail.example.org/.well-known/jmap'); + expect(headers.get('traceparent')).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/); + expect(headers.get('authorization')).toBe('Basic dTpw'); + } finally { + vi.unstubAllGlobals(); + } + }); + it('mark 5xx and thrown errors as errors, without the error message', async () => { await expect(observeUpstream('translate', async () => { throw new Error(`refused for ${ADDRESS}`); })).rejects.toThrow(); const [span] = exporter.getFinishedSpans();