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/<provider>/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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-01 21:00:12 +02:00
1 parent 97b1603cb9
commit 05d3736d75
25 files changed
+516 -57

No files matched your search

@@ -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([]);
});
});
+13 -1
View File
@@ -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 <IntegrationsSection callbackUrls={getIntegrationCallbackUrls()} />;
}
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,
@@ -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(
<I18nProvider initialLocale="en">
<IntegrationsSection />
<IntegrationsSection callbackUrls={CALLBACK_URLS} />
</I18nProvider>,
);
}
@@ -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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
@@ -184,7 +194,10 @@ describe("settings sections — SSR smoke", () => {
});
it("<IntegrationsSection> renders Withings card + the delivery-channels group", () => {
const html = renderFramed("integrations", <IntegrationsSection />);
const html = renderFramed(
"integrations",
<IntegrationsSection callbackUrls={CALLBACK_URLS} />,
);
expect(html).toContain("Integrations");
expect(html).toContain("Withings");
// v1.25.7 — delivery channels moved here from Notifications: the
@@ -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")}
/>
<ConnectionsPanel />
<ConnectionsPanel callbackUrls={callbackUrls} />
{/* 2026-07-17 UX/IA audit M7 — the opt-in nutrients sync inventory
(moved from Settings → Sources, where "source priority" only
@@ -0,0 +1,75 @@
/**
* `<CallbackMismatchNotice>` 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<typeof import("react")>();
return {
...actual,
useSyncExternalStore: <T,>(
_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(
<I18nProvider initialLocale="en">
<CallbackMismatchNotice provider="WHOOP" callbackUrl={callbackUrl} />
</I18nProvider>,
);
}
describe("<CallbackMismatchNotice>", () => {
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("");
});
});
@@ -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(
<I18nProvider initialLocale="en">
<ConnectionsPanel />
<ConnectionsPanel callbackUrls={CALLBACK_URLS} />
</I18nProvider>,
);
}
@@ -41,6 +41,7 @@ function render({
dataHref="/insights/sleep"
credentials={credentials}
viewModel={viewModel}
callbackUrl="https://app.example/api/polar/callback"
/>
</I18nProvider>,
);
@@ -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: {
@@ -15,11 +15,15 @@ import { I18nProvider } from "@/lib/i18n/context";
import { WhoopCard } from "../whoop-card";
import type { IntegrationStatusViewModel } from "../shared";
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
function render(
viewModel?: Partial<IntegrationStatusViewModel>,
callbackUrl: string | null = "https://app.example/api/whoop/callback",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<WhoopCard
viewModel={viewModel as IntegrationStatusViewModel | undefined}
callbackUrl={callbackUrl}
/>
</I18nProvider>,
);
@@ -27,13 +31,19 @@ function render(viewModel?: Partial<IntegrationStatusViewModel>) {
describe("<WhoopCard> 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"');
@@ -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<IntegrationKey> = 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. */}
<div id="withings" className="scroll-mt-28">
<WithingsCard viewModel={withingsViewModel} />
<WithingsCard
viewModel={withingsViewModel}
callbackUrl={callbackUrls.withings}
/>
</div>
<div id="whoop" className="scroll-mt-28">
<WhoopCard viewModel={whoopViewModel} />
<WhoopCard
viewModel={whoopViewModel}
callbackUrl={callbackUrls.whoop}
/>
</div>
<div id="fitbit" className="scroll-mt-28">
<FitbitCard viewModel={fitbitViewModel} />
<FitbitCard
viewModel={fitbitViewModel}
callbackUrl={callbackUrls.fitbit}
/>
</div>
<div id="google-health" className="scroll-mt-28">
<GoogleHealthCard viewModel={googleHealthViewModel} />
<GoogleHealthCard
viewModel={googleHealthViewModel}
callbackUrl={callbackUrls["google-health"]}
/>
</div>
<div id="apple-health" className="scroll-mt-28">
<AppleHealthCard enabled={isAuthenticated} />
</div>
<div id="polar" className="scroll-mt-28">
<PolarCard viewModel={polarViewModel} />
<PolarCard
viewModel={polarViewModel}
callbackUrl={callbackUrls.polar}
/>
</div>
<div id="oura" className="scroll-mt-28">
<OuraCard viewModel={ouraViewModel} />
<OuraCard viewModel={ouraViewModel} callbackUrl={callbackUrls.oura} />
</div>
<div id="strava" className="scroll-mt-28">
<StravaCard viewModel={stravaViewModel} />
<StravaCard
viewModel={stravaViewModel}
callbackUrl={callbackUrls.strava}
/>
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard viewModel={nightscoutViewModel} />
@@ -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({
<IntegrationRedirectGuide
provider="fitbit"
providerLabel={t("settings.fitbit")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -525,7 +529,7 @@ export function FitbitCard({
</div>
<CallbackMismatchNotice
provider={t("settings.fitbit")}
callbackUrl={integrationCallbackUrl("fitbit")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -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({
</div>
<CallbackMismatchNotice
provider={t("settings.googleHealth")}
callbackUrl={integrationCallbackUrl("google-health")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -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({
<IntegrationRedirectGuide
provider={provider}
providerLabel={t(i18nPrefix)}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -526,7 +530,7 @@ export function OAuthProviderCard({
</div>
<CallbackMismatchNotice
provider={t(i18nPrefix)}
callbackUrl={integrationCallbackUrl(provider)}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -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 (
<OAuthProviderCard
provider="oura"
@@ -22,6 +29,7 @@ export function OuraCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
dataHref="/insights/sleep"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -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 (
<OAuthProviderCard
provider="polar"
@@ -22,6 +29,7 @@ export function PolarCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
dataHref="/insights/sleep"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -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 (
<div
className="bg-muted/40 border-border/60 space-y-1.5 rounded-md border p-3 text-xs"
@@ -131,7 +135,7 @@ export function IntegrationRedirectGuide({
className="bg-background text-foreground rounded px-1 py-0.5 font-mono break-all"
data-testid={`${provider}-redirect-uri`}
>
{callbackUrl}
{shownCallbackUrl}
</code>
</li>
<li>{t("settings.integrationRedirectGuide.step3")}</li>
@@ -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 (
<OAuthProviderCard
provider="strava"
@@ -23,6 +30,7 @@ export function StravaCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
dataHref="/insights/workouts"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -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({
<IntegrationRedirectGuide
provider="whoop"
providerLabel={t("settings.whoop")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -507,7 +511,7 @@ export function WhoopCard({
</div>
<CallbackMismatchNotice
provider={t("settings.whoop")}
callbackUrl={integrationCallbackUrl("whoop")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -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({
<IntegrationRedirectGuide
provider="withings"
providerLabel={t("settings.withings")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -556,7 +560,7 @@ export function WithingsCard({
</div>
<CallbackMismatchNotice
provider={t("settings.withings")}
callbackUrl={integrationCallbackUrl("withings")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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,
@@ -0,0 +1,113 @@
/**
* `getIntegrationCallbackUrls()` must hand the Settings → Integrations cards
* the exact `redirect_uri` each provider's client registers: the explicit
* `<PROVIDER>_REDIRECT_URI` when the operator set one, the
* `${NEXT_PUBLIC_APP_URL}/api/<provider>/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<string, string | undefined>;
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/<provider>/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 <PROVIDER>_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");
});
});
+78
View File
@@ -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<IntegrationCallbackProvider, () => 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"),
};
}
+3 -3
View File
@@ -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",
);
+3 -3
View File
@@ -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,
});