From 05d3736d75c54b5fbdbdd87a75126b2b3b569464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 1 Sep 2026 21:00:12 +0200 Subject: [PATCH] Resolve OAuth callback URLs on the server for integration cards The v1.38.0 notice that warns when the configured OAuth callback origin differs from the origin the browser is on never rendered in the published image, and the copyable callback address in the redirect guide showed a bare path. `integrationCallbackUrl()` read `process.env.NEXT_PUBLIC_APP_URL` inside a "use client" module. Next.js inlines NEXT_PUBLIC_* into client bundles at build time, and the GHCR image is built without that variable (only the version, build-sha and built-at args reach the build), so the base compiled to an empty string, `new URL("/api//callback")` threw, and the notice returned null on every card for every self-hoster. The value now comes from the server at request time. A new map (`src/lib/integrations/callback-urls.ts`) calls each provider's own redirect-URI function (the four private `getRedirectUri()` helpers are exported under provider-specific names; Polar, Oura and Strava already exported theirs), so the page shows exactly what the handshake registers, explicit `*_REDIRECT_URI` override included. The settings route resolves the map and passes it as a prop through `IntegrationsSection` and `ConnectionsPanel` to every card. Fitbit and Google Health validate their URI and throw on a bad configuration; that surfaces as `null`, the notice skips the comparison and the guide falls back to the fixed path. Tests: a map test proving the explicit override wins and the derived form is used otherwise, a component test rendering the notice from a passed value (mismatch renders, match does not), and a structural guard that fails when any "use client" file under src/components/settings/integrations reads NEXT_PUBLIC_APP_URL. --- .../integration-callback-url-guard.test.ts | 56 +++++++++ src/app/settings/[section]/page.tsx | 14 ++- .../__tests__/integrations-section.test.tsx | 12 +- .../settings/__tests__/sections.test.tsx | 15 ++- .../settings/integrations-section.tsx | 10 +- .../callback-mismatch-notice.test.tsx | 75 ++++++++++++ .../connections-panel-coverage.test.tsx | 12 +- .../__tests__/oauth-provider-card.test.tsx | 2 +- .../__tests__/whoop-card.test.tsx | 14 ++- .../integrations/connections-panel.tsx | 45 +++++-- .../settings/integrations/fitbit-card.tsx | 8 +- .../integrations/google-health-card.tsx | 7 +- .../integrations/oauth-provider-card.tsx | 8 +- .../settings/integrations/oura-card.tsx | 10 +- .../settings/integrations/polar-card.tsx | 10 +- .../integrations/setup-guide-link.tsx | 34 +++--- .../settings/integrations/strava-card.tsx | 10 +- .../settings/integrations/whoop-card.tsx | 8 +- .../settings/integrations/withings-card.tsx | 8 +- src/lib/fitbit/client.ts | 6 +- src/lib/google-health/client.ts | 6 +- .../__tests__/callback-urls.test.ts | 113 ++++++++++++++++++ src/lib/integrations/callback-urls.ts | 78 ++++++++++++ src/lib/whoop/client.ts | 6 +- src/lib/withings/client.ts | 6 +- 25 files changed, 516 insertions(+), 57 deletions(-) create mode 100644 src/__tests__/integration-callback-url-guard.test.ts create mode 100644 src/components/settings/integrations/__tests__/callback-mismatch-notice.test.tsx create mode 100644 src/lib/integrations/__tests__/callback-urls.test.ts create mode 100644 src/lib/integrations/callback-urls.ts diff --git a/src/__tests__/integration-callback-url-guard.test.ts b/src/__tests__/integration-callback-url-guard.test.ts new file mode 100644 index 000000000..4ab934aa1 --- /dev/null +++ b/src/__tests__/integration-callback-url-guard.test.ts @@ -0,0 +1,56 @@ +/** + * Structural guard: no client module on the Settings → Integrations surface + * may read `process.env.NEXT_PUBLIC_APP_URL`. + * + * Why this is a tripwire and not a behavioural test. Next.js inlines every + * `NEXT_PUBLIC_*` reference in a `"use client"` module at BUILD time. The + * published image is built without `NEXT_PUBLIC_APP_URL` (the Dockerfile + * passes only the version / build-sha / built-at args, and `.env` files are + * not in the build context), so any such read compiles to `undefined` for + * every self-hoster, no matter what their runtime env says. A unit test with + * the variable set in `process.env` cannot see that: under Vitest the read is + * live, so the code passes while the shipped bundle is inert. v1.38.0's + * callback-origin notice shipped exactly that way. + * + * The callback URL therefore comes from the server (`getIntegrationCallbackUrls` + * in `src/lib/integrations/callback-urls.ts`) and travels down as a prop. This + * guard fails the moment a client file on the surface reaches for the env var + * again. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SURFACE = join(process.cwd(), "src/components/settings/integrations"); + +const CLIENT_DIRECTIVE = /^\s*["']use client["']\s*;?/m; +const APP_URL_READ = /process\.env\.NEXT_PUBLIC_APP_URL/; + +function surfaceFiles(): string[] { + return walkSourceFiles(SURFACE, { floor: 15 }) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); +} + +describe("Settings → Integrations client modules never read NEXT_PUBLIC_APP_URL", () => { + it('every "use client" file on the surface is free of the env read', () => { + const files = surfaceFiles(); + const clientFiles = files.filter((rel) => + CLIENT_DIRECTIVE.test(readFileSync(join(SURFACE, rel), "utf8")), + ); + // The walk must have found the surface, and the surface must still be + // client-rendered — an empty set would pass on a tree the guard never read. + expect(files.length).toBeGreaterThanOrEqual(15); + expect(clientFiles.length).toBeGreaterThanOrEqual(10); + + const offenders = clientFiles.filter((rel) => + APP_URL_READ.test(readFileSync(join(SURFACE, rel), "utf8")), + ); + expect( + offenders, + `NEXT_PUBLIC_APP_URL is inlined at build time in client bundles and is empty in the published image; pass the server-derived callback URL as a prop instead: ${offenders.join(", ")}`, + ).toEqual([]); + }); +}); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 572123d39..f5c4a559a 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -29,6 +29,7 @@ import { } from "@/components/settings/section-slugs"; import { SettingsShell } from "@/components/settings/settings-shell"; import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate"; +import { getIntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; /** * Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS` @@ -46,6 +47,17 @@ export function generateStaticParams() { return SETTINGS_SECTION_SLUGS.map((section) => ({ section })); } +/** + * Integrations is the one section that needs a server-resolved value: the + * OAuth callback URL each provider registers. It is read from the runtime env + * here, per request, and handed to the client section as a prop, because a + * `NEXT_PUBLIC_*` read inside a client module is inlined at build time and the + * published image is built without it. + */ +function IntegrationsSectionWithCallbackUrls() { + return ; +} + const SECTION_COMPONENTS: Record< SettingsSectionSlug, () => JSX.Element | null @@ -57,7 +69,7 @@ const SECTION_COMPONENTS: Record< about: AboutSection, ai: AiSection, coach: CoachSection, - integrations: IntegrationsSection, + integrations: IntegrationsSectionWithCallbackUrls, sources: SourcesSection, notifications: NotificationsSection, layout: LayoutSection, diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index 929dfb29e..cdf1792b0 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -75,10 +75,20 @@ vi.mock("@tanstack/react-query", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { IntegrationsSection } from "../integrations-section"; +const CALLBACK_URLS = { + withings: "https://app.example/api/withings/callback", + whoop: "https://app.example/api/whoop/callback", + fitbit: "https://app.example/api/fitbit/callback", + "google-health": "https://app.example/api/google-health/callback", + polar: "https://app.example/api/polar/callback", + oura: "https://app.example/api/oura/callback", + strava: "https://app.example/api/strava/callback", +}; + function render() { return renderToStaticMarkup( - + , ); } diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 782bfe7ed..0abf949a0 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -104,6 +104,16 @@ import { NotificationsSection } from "../notifications-section"; // slug like every other settings section. import { ThresholdsSection } from "../thresholds-section"; +const CALLBACK_URLS = { + withings: "https://app.example/api/withings/callback", + whoop: "https://app.example/api/whoop/callback", + fitbit: "https://app.example/api/fitbit/callback", + "google-health": "https://app.example/api/google-health/callback", + polar: "https://app.example/api/polar/callback", + oura: "https://app.example/api/oura/callback", + strava: "https://app.example/api/strava/callback", +}; + function render(node: React.ReactElement, locale: "en" | "de" = "en") { return renderToStaticMarkup( {node}, @@ -184,7 +194,10 @@ describe("settings sections — SSR smoke", () => { }); it(" renders Withings card + the delivery-channels group", () => { - const html = renderFramed("integrations", ); + const html = renderFramed( + "integrations", + , + ); expect(html).toContain("Integrations"); expect(html).toContain("Withings"); // v1.25.7 — delivery channels moved here from Notifications: the diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 1408d4698..8f1247cb3 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -32,6 +32,7 @@ import Link from "next/link"; import { Link2, Send } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel"; import { SectionHeading } from "@/components/ui/section-heading"; import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel"; @@ -43,7 +44,12 @@ export { oauthReasonKey, } from "@/components/settings/integrations/connections-panel"; -export function IntegrationsSection() { +export function IntegrationsSection({ + callbackUrls, +}: { + /** Server-resolved per-provider OAuth callback URLs; see `ConnectionsPanel`. */ + callbackUrls: IntegrationCallbackUrls; +}) { const { t } = useTranslations(); // v1.18.6 (W9) — the visible page heading + subtitle come from the shared @@ -59,7 +65,7 @@ export function IntegrationsSection() { icon={Link2} title={t("settings.sections.integrations.connectionsHeading")} /> - + {/* 2026-07-17 UX/IA audit M7 — the opt-in nutrients sync inventory (moved from Settings → Sources, where "source priority" only diff --git a/src/components/settings/integrations/__tests__/callback-mismatch-notice.test.tsx b/src/components/settings/integrations/__tests__/callback-mismatch-notice.test.tsx new file mode 100644 index 000000000..38f9d2271 --- /dev/null +++ b/src/components/settings/integrations/__tests__/callback-mismatch-notice.test.tsx @@ -0,0 +1,75 @@ +/** + * `` renders from the callback URL it is HANDED, not + * from anything it reads itself. The origin the browser is on comes through + * `useSyncExternalStore`, whose server snapshot is deliberately `null` (the + * server cannot know the browser origin, and a notice that flips at hydration + * would flash). A static render therefore never shows it; to exercise the + * client path here the hook is replaced with its client snapshot and a bare + * `window.location.origin` is provided. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSyncExternalStore: ( + _subscribe: (onChange: () => void) => () => void, + getSnapshot: () => T, + ): T => getSnapshot(), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { CallbackMismatchNotice } from "../setup-guide-link"; + +const BROWSER_ORIGIN = "https://health.example"; + +function render(callbackUrl: string | null) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + const priorWindow = (globalThis as { window?: unknown }).window; + + beforeEach(() => { + (globalThis as { window?: unknown }).window = { + location: { origin: BROWSER_ORIGIN }, + }; + }); + + afterEach(() => { + if (priorWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = priorWindow; + } + }); + + it("renders the warning when the configured callback origin differs from the browser origin", () => { + const html = render("https://edge.example/api/whoop/callback"); + expect(html).toContain("WHOOP"); + expect(html).toContain("https://edge.example"); + expect(html).toContain(BROWSER_ORIGIN); + expect(html).toContain("differs from this app address"); + }); + + it("renders nothing when the callback origin matches the browser origin", () => { + expect(render(`${BROWSER_ORIGIN}/api/whoop/callback`)).toBe(""); + }); + + it("renders nothing when the server could not produce a callback URL", () => { + expect(render(null)).toBe(""); + }); + + it("renders nothing for a callback URL that is not absolute", () => { + // The pre-fix client bundle produced exactly this shape and the notice + // stayed silent; it still must, rather than throwing out of the card. + expect(render("/api/whoop/callback")).toBe(""); + }); +}); diff --git a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx index a8cdb2e1f..c06fa4d22 100644 --- a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx +++ b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx @@ -84,10 +84,20 @@ function fullEnvelope() { }; } +const CALLBACK_URLS = { + withings: "https://app.example/api/withings/callback", + whoop: "https://app.example/api/whoop/callback", + fitbit: "https://app.example/api/fitbit/callback", + "google-health": "https://app.example/api/google-health/callback", + polar: "https://app.example/api/polar/callback", + oura: "https://app.example/api/oura/callback", + strava: "https://app.example/api/strava/callback", +}; + function render() { return renderToStaticMarkup( - + , ); } diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index b4fc2d3ef..b47765b63 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -41,6 +41,7 @@ function render({ dataHref="/insights/sleep" credentials={credentials} viewModel={viewModel} + callbackUrl="https://app.example/api/polar/callback" /> , ); @@ -162,7 +163,6 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => { it("shows the callback URL guide before the user has BYO credentials", () => { - process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; const html = render({ credentials: true, viewModel: { diff --git a/src/components/settings/integrations/__tests__/whoop-card.test.tsx b/src/components/settings/integrations/__tests__/whoop-card.test.tsx index 3faae333a..6d5bc5388 100644 --- a/src/components/settings/integrations/__tests__/whoop-card.test.tsx +++ b/src/components/settings/integrations/__tests__/whoop-card.test.tsx @@ -15,11 +15,15 @@ import { I18nProvider } from "@/lib/i18n/context"; import { WhoopCard } from "../whoop-card"; import type { IntegrationStatusViewModel } from "../shared"; -function render(viewModel?: Partial) { +function render( + viewModel?: Partial, + callbackUrl: string | null = "https://app.example/api/whoop/callback", +) { return renderToStaticMarkup( , ); @@ -27,13 +31,19 @@ function render(viewModel?: Partial) { describe(" redirect-URI mini-guide", () => { it("shows the callback URL guide before credentials are configured", () => { - process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; const html = render({ connected: false, configured: false }); expect(html).toContain('data-testid="whoop-redirect-guide"'); expect(html).toContain('data-testid="whoop-redirect-uri"'); expect(html).toContain("https://app.example/api/whoop/callback"); }); + it("falls back to the fixed path when the server has no redirect URI", () => { + const html = render({ connected: false, configured: false }, null); + expect(html).toContain('data-testid="whoop-redirect-uri"'); + expect(html).toContain("/api/whoop/callback"); + expect(html).not.toContain("undefined/api/whoop/callback"); + }); + it("hides the guide once credentials are configured", () => { const html = render({ connected: false, configured: true }); expect(html).not.toContain('data-testid="whoop-redirect-guide"'); diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index c2e5c01c1..89dbfeb89 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -35,6 +35,7 @@ import { import { WhoopCard } from "@/components/settings/integrations/whoop-card"; import { WithingsCard } from "@/components/settings/integrations/withings-card"; import { useAuth } from "@/hooks/use-auth"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -209,7 +210,17 @@ const CARD_BACKED_INTEGRATIONS: ReadonlySet = new Set([ "nightscout", ]); -export function ConnectionsPanel() { +export function ConnectionsPanel({ + callbackUrls, +}: { + /** + * The per-provider OAuth callback URLs, resolved on the server per request + * (`getIntegrationCallbackUrls`) and threaded down from the route. A client + * module cannot derive them: `NEXT_PUBLIC_APP_URL` is inlined at build + * time and absent from the published image. + */ + callbackUrls: IntegrationCallbackUrls; +}) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const router = useRouter(); @@ -328,28 +339,46 @@ export function ConnectionsPanel() { {/* Per-provider anchors so deep-links (onboarding "more sources" chips, `/settings/integrations#oura`) scroll straight to the right card. */}
- +
- +
- +
- +
- +
- +
- +
diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx index 98e7c4056..2d16a8f7a 100644 --- a/src/components/settings/integrations/fitbit-card.tsx +++ b/src/components/settings/integrations/fitbit-card.tsx @@ -68,13 +68,16 @@ import { CallbackMismatchNotice, IntegrationCardDescription, IntegrationRedirectGuide, - integrationCallbackUrl, } from "./setup-guide-link"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; export function FitbitCard({ viewModel, + callbackUrl, }: { viewModel: IntegrationStatusViewModel | undefined; + /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ + callbackUrl: IntegrationCallbackUrls["fitbit"]; }) { const { t } = useTranslations(); const describeSyncOutcome = useSyncOutcomeMessage(); @@ -314,6 +317,7 @@ export function FitbitCard({ )}
@@ -525,7 +529,7 @@ export function FitbitCard({
); diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx index 99b127409..86ea06dc3 100644 --- a/src/components/settings/integrations/google-health-card.tsx +++ b/src/components/settings/integrations/google-health-card.tsx @@ -83,13 +83,16 @@ import { import { CallbackMismatchNotice, IntegrationCardDescription, - integrationCallbackUrl, } from "./setup-guide-link"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; export function GoogleHealthCard({ viewModel, + callbackUrl, }: { viewModel: IntegrationStatusViewModel | undefined; + /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ + callbackUrl: IntegrationCallbackUrls["google-health"]; }) { const { t } = useTranslations(); const describeSyncOutcome = useSyncOutcomeMessage(); @@ -764,7 +767,7 @@ export function GoogleHealthCard({ ); diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index 147fcc79a..4b8d2e5b7 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -69,8 +69,8 @@ import { IntegrationCardDescription, IntegrationRedirectGuide, type IntegrationDocsProvider, - integrationCallbackUrl, } from "./setup-guide-link"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; export interface OAuthProviderStatus { connected: boolean; @@ -123,6 +123,8 @@ export interface OAuthProviderCardProps { * exist. */ viewModel?: OAuthProviderStatus; + /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ + callbackUrl: IntegrationCallbackUrls[OAuthProviderCardProps["provider"]]; } export function OAuthProviderCard({ @@ -133,6 +135,7 @@ export function OAuthProviderCard({ dataHref, credentials = false, viewModel, + callbackUrl, }: OAuthProviderCardProps) { const { t } = useTranslations(); const describeSyncOutcome = useSyncOutcomeMessage(); @@ -341,6 +344,7 @@ export function OAuthProviderCard({ )} @@ -526,7 +530,7 @@ export function OAuthProviderCard({ ); diff --git a/src/components/settings/integrations/oura-card.tsx b/src/components/settings/integrations/oura-card.tsx index 068726982..cb669d917 100644 --- a/src/components/settings/integrations/oura-card.tsx +++ b/src/components/settings/integrations/oura-card.tsx @@ -10,9 +10,16 @@ import { OAuthProviderCard, type OAuthProviderStatus, } from "@/components/settings/integrations/oauth-provider-card"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { queryKeys } from "@/lib/query-keys"; -export function OuraCard({ viewModel }: { viewModel?: OAuthProviderStatus }) { +export function OuraCard({ + viewModel, + callbackUrl, +}: { + viewModel?: OAuthProviderStatus; + callbackUrl: IntegrationCallbackUrls["oura"]; +}) { return ( ); } diff --git a/src/components/settings/integrations/polar-card.tsx b/src/components/settings/integrations/polar-card.tsx index 592e04836..4900e449e 100644 --- a/src/components/settings/integrations/polar-card.tsx +++ b/src/components/settings/integrations/polar-card.tsx @@ -10,9 +10,16 @@ import { OAuthProviderCard, type OAuthProviderStatus, } from "@/components/settings/integrations/oauth-provider-card"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { queryKeys } from "@/lib/query-keys"; -export function PolarCard({ viewModel }: { viewModel?: OAuthProviderStatus }) { +export function PolarCard({ + viewModel, + callbackUrl, +}: { + viewModel?: OAuthProviderStatus; + callbackUrl: IntegrationCallbackUrls["polar"]; +}) { return ( ); } diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx index 23889a30a..dab6e8cbb 100644 --- a/src/components/settings/integrations/setup-guide-link.tsx +++ b/src/components/settings/integrations/setup-guide-link.tsx @@ -40,25 +40,21 @@ export function integrationDocsHref(provider: IntegrationDocsProvider): string { } /** - * The OAuth callback path every BYO-key provider registers against. Every - * provider's `getRedirectUri()` (`src/lib/{provider}/client.ts`) derives the - * same shape from `NEXT_PUBLIC_APP_URL` unless an operator overrides it with - * an explicit `*_REDIRECT_URI` env var — the common case this card targets. + * Shows a compact warning when the callback origin differs from the app. + * + * `callbackUrl` is the value the server registers as `redirect_uri`, resolved + * per request by `getIntegrationCallbackUrls()` and passed down as a prop. It + * must not be derived here: a `NEXT_PUBLIC_*` read in a client module is + * inlined at build time, and the published image is built without it. + * `null` means the provider cannot build a redirect URI from the current env, + * so there is nothing to compare. */ -export function integrationCallbackUrl( - provider: IntegrationDocsProvider, -): string { - const base = process.env.NEXT_PUBLIC_APP_URL ?? ""; - return `${base}/api/${provider}/callback`; -} - -/** Shows a compact warning when the callback origin differs from the app. */ export function CallbackMismatchNotice({ provider, callbackUrl, }: { provider: string; - callbackUrl: string; + callbackUrl: string | null; }) { // Get the current origin of the app const { t } = useTranslations(); @@ -69,6 +65,7 @@ export function CallbackMismatchNotice({ ); // Get the origin of the callback URL const callbackOrigin = (() => { + if (!callbackUrl) return null; try { return new URL(callbackUrl).origin; } catch { @@ -103,12 +100,19 @@ export function CallbackMismatchNotice({ export function IntegrationRedirectGuide({ provider, providerLabel, + callbackUrl, }: { provider: IntegrationDocsProvider; providerLabel: string; + /** + * The server-resolved callback URL (see `CallbackMismatchNotice`). When the + * provider cannot build one from the current env, show the fixed path so + * the step still names what the vendor form expects. + */ + callbackUrl: string | null; }) { const { t } = useTranslations(); - const callbackUrl = integrationCallbackUrl(provider); + const shownCallbackUrl = callbackUrl ?? `/api/${provider}/callback`; return (
- {callbackUrl} + {shownCallbackUrl}
  • {t("settings.integrationRedirectGuide.step3")}
  • diff --git a/src/components/settings/integrations/strava-card.tsx b/src/components/settings/integrations/strava-card.tsx index c2a501f50..534558e02 100644 --- a/src/components/settings/integrations/strava-card.tsx +++ b/src/components/settings/integrations/strava-card.tsx @@ -11,9 +11,16 @@ import { OAuthProviderCard, type OAuthProviderStatus, } from "@/components/settings/integrations/oauth-provider-card"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { queryKeys } from "@/lib/query-keys"; -export function StravaCard({ viewModel }: { viewModel?: OAuthProviderStatus }) { +export function StravaCard({ + viewModel, + callbackUrl, +}: { + viewModel?: OAuthProviderStatus; + callbackUrl: IntegrationCallbackUrls["strava"]; +}) { return ( ); } diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx index db02bd914..2b4fad121 100644 --- a/src/components/settings/integrations/whoop-card.tsx +++ b/src/components/settings/integrations/whoop-card.tsx @@ -59,13 +59,16 @@ import { CallbackMismatchNotice, IntegrationCardDescription, IntegrationRedirectGuide, - integrationCallbackUrl, } from "./setup-guide-link"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; export function WhoopCard({ viewModel, + callbackUrl, }: { viewModel: IntegrationStatusViewModel | undefined; + /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ + callbackUrl: IntegrationCallbackUrls["whoop"]; }) { const { t } = useTranslations(); const describeSyncOutcome = useSyncOutcomeMessage(); @@ -296,6 +299,7 @@ export function WhoopCard({ )} @@ -507,7 +511,7 @@ export function WhoopCard({
    ); diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx index b85c8b03b..ec8d61ce5 100644 --- a/src/components/settings/integrations/withings-card.tsx +++ b/src/components/settings/integrations/withings-card.tsx @@ -58,13 +58,16 @@ import { CallbackMismatchNotice, IntegrationCardDescription, IntegrationRedirectGuide, - integrationCallbackUrl, } from "./setup-guide-link"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; export function WithingsCard({ viewModel, + callbackUrl, }: { viewModel: IntegrationStatusViewModel | undefined; + /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ + callbackUrl: IntegrationCallbackUrls["withings"]; }) { const { t } = useTranslations(); const describeSyncOutcome = useSyncOutcomeMessage(); @@ -335,6 +338,7 @@ export function WithingsCard({ )} @@ -556,7 +560,7 @@ export function WithingsCard({ ); diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 29704735f..1015e15fa 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -75,7 +75,7 @@ export interface FitbitCredentials { * - must land on the fixed `/api/fitbit/callback` path, * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. */ -function getRedirectUri(): string { +export function getFitbitRedirectUri(): string { const explicit = process.env.FITBIT_REDIRECT_URI; const appUrl = process.env.NEXT_PUBLIC_APP_URL; const raw = @@ -188,7 +188,7 @@ export function getAuthorizationUrl( const params = new URLSearchParams({ response_type: "code", client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getFitbitRedirectUri(), scope: FITBIT_OAUTH_SCOPE, code_challenge: codeChallenge, code_challenge_method: "S256", @@ -287,7 +287,7 @@ export async function exchangeCode( grant_type: "authorization_code", code, client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getFitbitRedirectUri(), code_verifier: codeVerifier, }), creds, diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 659ccc75a..cb5a33b94 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -146,7 +146,7 @@ export interface GoogleHealthCredentials { * - must land on the fixed `/api/google-health/callback` path, * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. */ -function getRedirectUri(): string { +export function getGoogleHealthRedirectUri(): string { const explicit = process.env.GOOGLE_HEALTH_REDIRECT_URI; const appUrl = process.env.NEXT_PUBLIC_APP_URL; const raw = @@ -276,7 +276,7 @@ export function getAuthorizationUrl( const params = new URLSearchParams({ response_type: "code", client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getGoogleHealthRedirectUri(), scope: getGoogleHealthScopeString(), access_type: "offline", prompt: "consent", @@ -371,7 +371,7 @@ export async function exchangeCode( grant_type: "authorization_code", code, client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getGoogleHealthRedirectUri(), code_verifier: codeVerifier, }), creds, diff --git a/src/lib/integrations/__tests__/callback-urls.test.ts b/src/lib/integrations/__tests__/callback-urls.test.ts new file mode 100644 index 000000000..27d25c2e3 --- /dev/null +++ b/src/lib/integrations/__tests__/callback-urls.test.ts @@ -0,0 +1,113 @@ +/** + * `getIntegrationCallbackUrls()` must hand the Settings → Integrations cards + * the exact `redirect_uri` each provider's client registers: the explicit + * `_REDIRECT_URI` when the operator set one, the + * `${NEXT_PUBLIC_APP_URL}/api//callback` form otherwise. The map + * goes through the provider functions themselves rather than restating the + * rule, so this test pins that the wiring reaches every provider and that a + * provider whose validation throws surfaces as `null` instead of taking the + * page down. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + INTEGRATION_CALLBACK_PROVIDERS, + getIntegrationCallbackUrls, +} from "../callback-urls"; + +const OVERRIDE_VARS: Record< + (typeof INTEGRATION_CALLBACK_PROVIDERS)[number], + string +> = { + withings: "WITHINGS_REDIRECT_URI", + whoop: "WHOOP_REDIRECT_URI", + fitbit: "FITBIT_REDIRECT_URI", + "google-health": "GOOGLE_HEALTH_REDIRECT_URI", + polar: "POLAR_REDIRECT_URI", + oura: "OURA_REDIRECT_URI", + strava: "STRAVA_REDIRECT_URI", +}; + +const TOUCHED = ["NEXT_PUBLIC_APP_URL", ...Object.values(OVERRIDE_VARS)]; +let saved: Record; + +beforeEach(() => { + saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]])); + for (const k of TOUCHED) delete process.env[k]; +}); + +afterEach(() => { + for (const k of TOUCHED) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe("getIntegrationCallbackUrls", () => { + it("derives ${NEXT_PUBLIC_APP_URL}/api//callback when nothing overrides it", () => { + process.env.NEXT_PUBLIC_APP_URL = "https://health.example"; + const urls = getIntegrationCallbackUrls(); + for (const provider of INTEGRATION_CALLBACK_PROVIDERS) { + expect(urls[provider], provider).toBe( + `https://health.example/api/${provider}/callback`, + ); + } + }); + + it("lets the explicit _REDIRECT_URI win over the derived form", () => { + // Withings / WHOOP / Polar / Oura / Strava accept any explicit value. + process.env.NEXT_PUBLIC_APP_URL = "https://health.example"; + for (const provider of [ + "withings", + "whoop", + "polar", + "oura", + "strava", + ] as const) { + process.env[OVERRIDE_VARS[provider]] = + `https://edge.example/api/${provider}/callback`; + } + const urls = getIntegrationCallbackUrls(); + for (const provider of [ + "withings", + "whoop", + "polar", + "oura", + "strava", + ] as const) { + expect(urls[provider], provider).toBe( + `https://edge.example/api/${provider}/callback`, + ); + } + // No override for these two, so they still derive. + expect(urls.fitbit).toBe("https://health.example/api/fitbit/callback"); + expect(urls["google-health"]).toBe( + "https://health.example/api/google-health/callback", + ); + }); + + it("uses the explicit Fitbit / Google Health override when the app URL is unset", () => { + // Those two pin an explicit override to the app origin when both are set; + // with only the override present it is taken verbatim. + process.env.FITBIT_REDIRECT_URI = + "https://edge.example/api/fitbit/callback"; + process.env.GOOGLE_HEALTH_REDIRECT_URI = + "https://edge.example/api/google-health/callback"; + const urls = getIntegrationCallbackUrls(); + expect(urls.fitbit).toBe("https://edge.example/api/fitbit/callback"); + expect(urls["google-health"]).toBe( + "https://edge.example/api/google-health/callback", + ); + }); + + it("reports null for a provider whose redirect URI fails validation, leaving the rest intact", () => { + // Fitbit refuses a plain-http, non-loopback origin; the others do not + // validate and keep deriving. + process.env.NEXT_PUBLIC_APP_URL = "http://health.example"; + const urls = getIntegrationCallbackUrls(); + expect(urls.fitbit).toBeNull(); + expect(urls["google-health"]).toBeNull(); + expect(urls.withings).toBe("http://health.example/api/withings/callback"); + expect(urls.whoop).toBe("http://health.example/api/whoop/callback"); + }); +}); diff --git a/src/lib/integrations/callback-urls.ts b/src/lib/integrations/callback-urls.ts new file mode 100644 index 000000000..d9e7036bf --- /dev/null +++ b/src/lib/integrations/callback-urls.ts @@ -0,0 +1,78 @@ +/** + * The OAuth callback URL each BYO-key provider registers, resolved on the + * server at request time. + * + * Settings → Integrations shows two things built from this value: the + * copyable callback address in the redirect guide, and the notice that warns + * when the configured callback origin differs from the origin the browser is + * on. Both used to read `process.env.NEXT_PUBLIC_APP_URL` inside a + * `"use client"` module. Next.js inlines `NEXT_PUBLIC_*` into client bundles + * at build time, and the published image is built without that variable, so + * every self-hoster's bundle carried an empty base: the guide showed a bare + * path and the notice never rendered. Resolving here, in server code, reads + * the runtime env the operator actually set. + * + * Each entry is the exact value the provider's client sends as `redirect_uri` + * on the wire, through the same function, so what the page shows and what the + * handshake registers cannot drift apart. Fitbit and Google Health validate + * their URI and throw on a malformed configuration; here that surfaces as + * `null` (nothing would be registered), and the cards fall back to the fixed + * path for the guide and skip the origin comparison. + */ + +import { getFitbitRedirectUri } from "@/lib/fitbit/client"; +import { getGoogleHealthRedirectUri } from "@/lib/google-health/client"; +import { getOuraRedirectUri } from "@/lib/oura/client"; +import { getPolarRedirectUri } from "@/lib/polar/client"; +import { getStravaRedirectUri } from "@/lib/strava/client"; +import { getWhoopRedirectUri } from "@/lib/whoop/client"; +import { getWithingsRedirectUri } from "@/lib/withings/client"; + +export const INTEGRATION_CALLBACK_PROVIDERS = [ + "withings", + "whoop", + "fitbit", + "google-health", + "polar", + "oura", + "strava", +] as const; + +export type IntegrationCallbackProvider = + (typeof INTEGRATION_CALLBACK_PROVIDERS)[number]; + +/** `null` when the provider cannot produce a redirect URI from the current env. */ +export type IntegrationCallbackUrls = Record< + IntegrationCallbackProvider, + string | null +>; + +const RESOLVERS: Record string> = { + withings: getWithingsRedirectUri, + whoop: getWhoopRedirectUri, + fitbit: getFitbitRedirectUri, + "google-health": getGoogleHealthRedirectUri, + polar: getPolarRedirectUri, + oura: getOuraRedirectUri, + strava: getStravaRedirectUri, +}; + +function resolve(provider: IntegrationCallbackProvider): string | null { + try { + return RESOLVERS[provider](); + } catch { + return null; + } +} + +export function getIntegrationCallbackUrls(): IntegrationCallbackUrls { + return { + withings: resolve("withings"), + whoop: resolve("whoop"), + fitbit: resolve("fitbit"), + "google-health": resolve("google-health"), + polar: resolve("polar"), + oura: resolve("oura"), + strava: resolve("strava"), + }; +} diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index e3d7704b8..6419e0170 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -32,7 +32,7 @@ export interface WhoopCredentials { clientSecret: string; } -function getRedirectUri(): string { +export function getWhoopRedirectUri(): string { // `||`, not `??`: the compose whitelist materialises the var as an empty // string when unset, which must still fall through to the derived URI. return ( @@ -61,7 +61,7 @@ export function getAuthorizationUrl( const params = new URLSearchParams({ response_type: "code", client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getWhoopRedirectUri(), scope: WHOOP_OAUTH_SCOPE, state, }); @@ -119,7 +119,7 @@ export async function exchangeCode( code, client_id: creds.clientId, client_secret: creds.clientSecret, - redirect_uri: getRedirectUri(), + redirect_uri: getWhoopRedirectUri(), }), "exchangeCode", ); diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index c36b86d94..db6a41775 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -20,7 +20,7 @@ export interface WithingsCredentials { clientSecret: string; } -function getRedirectUri(): string { +export function getWithingsRedirectUri(): string { return ( process.env.WITHINGS_REDIRECT_URI ?? `${process.env.NEXT_PUBLIC_APP_URL}/api/withings/callback` @@ -79,7 +79,7 @@ export function getAuthorizationUrl( const params = new URLSearchParams({ response_type: "code", client_id: creds.clientId, - redirect_uri: getRedirectUri(), + redirect_uri: getWithingsRedirectUri(), scope: WITHINGS_OAUTH_SCOPE, state, }); @@ -105,7 +105,7 @@ export async function exchangeCode( grant_type: "authorization_code", client_id: creds.clientId, client_secret: creds.clientSecret, - redirect_uri: getRedirectUri(), + redirect_uri: getWithingsRedirectUri(), code, });