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, });