From c366d4946c24f3956d333ad3116c95cbfa4ceaed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 16:08:57 +0200 Subject: [PATCH] fix(integrations): treat an empty redirect URI variable as unset The compose file passes every whitelisted variable through, so an unset redirect URI reaches the process as an empty string. Withings then sent redirect_uri= to the provider, and Fitbit and Google Health refused to start the connection as not configured although NEXT_PUBLIC_APP_URL was set. Every integration now treats an empty or blank value as unset and derives the callback from the app URL. --- src/lib/fitbit/client.ts | 7 +- src/lib/google-health/client.ts | 7 +- .../__tests__/redirect-uri-empty-env.test.ts | 66 +++++++++++++++++++ src/lib/oura/client.ts | 6 +- src/lib/polar/client.ts | 9 +-- src/lib/strava/client.ts | 9 +-- src/lib/whoop/client.ts | 9 +-- src/lib/withings/client.ts | 7 +- 8 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 6521cd8f0..6a01c454f 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -77,8 +77,11 @@ export interface FitbitCredentials { * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. */ export function getFitbitRedirectUri(): string { - const explicit = process.env.FITBIT_REDIRECT_URI; - const appUrl = process.env.NEXT_PUBLIC_APP_URL; + // An empty or blank value counts as unset: the compose whitelist + // materialises an unset var as an empty string, which must fall through to + // the derived URI rather than read as "not configured". + const explicit = process.env.FITBIT_REDIRECT_URI?.trim() || undefined; + const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim() || undefined; const raw = explicit ?? (appUrl ? `${appUrl}/api/fitbit/callback` : undefined); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index cb5a33b94..6a675631b 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -147,8 +147,11 @@ export interface GoogleHealthCredentials { * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. */ export function getGoogleHealthRedirectUri(): string { - const explicit = process.env.GOOGLE_HEALTH_REDIRECT_URI; - const appUrl = process.env.NEXT_PUBLIC_APP_URL; + // An empty or blank value counts as unset: the compose whitelist + // materialises an unset var as an empty string, which must fall through to + // the derived URI rather than read as "not configured". + const explicit = process.env.GOOGLE_HEALTH_REDIRECT_URI?.trim() || undefined; + const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim() || undefined; const raw = explicit ?? (appUrl ? `${appUrl}/api/google-health/callback` : undefined); diff --git a/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts b/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts new file mode 100644 index 000000000..25924210c --- /dev/null +++ b/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts @@ -0,0 +1,66 @@ +/** + * The compose file passes every whitelisted variable through, so an unset + * redirect URI reaches the process as an empty string. Every integration + * treats an empty or blank value as unset and derives the callback from + * `NEXT_PUBLIC_APP_URL`: Withings used to send `redirect_uri=` and Fitbit and + * Google Health refused to start the handshake as "not configured". + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getWithingsRedirectUri } from "@/lib/withings/client"; +import { getFitbitRedirectUri } from "@/lib/fitbit/client"; +import { getGoogleHealthRedirectUri } from "@/lib/google-health/client"; +import { getPolarRedirectUri } from "@/lib/polar/client"; +import { getWhoopRedirectUri } from "@/lib/whoop/client"; +import { getStravaRedirectUri } from "@/lib/strava/client"; +import { getOuraRedirectUri } from "@/lib/oura/client"; + +const APP = "https://health.example.test"; + +const CASES: Array<[string, string, () => string, string]> = [ + ["Withings", "WITHINGS_REDIRECT_URI", getWithingsRedirectUri, "withings"], + ["Fitbit", "FITBIT_REDIRECT_URI", getFitbitRedirectUri, "fitbit"], + [ + "Google Health", + "GOOGLE_HEALTH_REDIRECT_URI", + getGoogleHealthRedirectUri, + "google-health", + ], + ["Polar", "POLAR_REDIRECT_URI", getPolarRedirectUri, "polar"], + ["WHOOP", "WHOOP_REDIRECT_URI", getWhoopRedirectUri, "whoop"], + ["Strava", "STRAVA_REDIRECT_URI", getStravaRedirectUri, "strava"], + ["Oura", "OURA_REDIRECT_URI", getOuraRedirectUri, "oura"], +]; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe.each(CASES)("%s redirect URI", (_name, variable, resolve, path) => { + it.each(["", " "])( + "derives the callback when the variable is %j", + (value) => { + vi.stubEnv(variable, value); + vi.stubEnv("NEXT_PUBLIC_APP_URL", APP); + expect(resolve()).toBe(`${APP}/api/${path}/callback`); + }, + ); + + it("uses an explicit value, trimmed", () => { + const explicit = `${APP}/api/${path}/callback`; + vi.stubEnv(variable, ` ${explicit} `); + vi.stubEnv("NEXT_PUBLIC_APP_URL", APP); + expect(resolve()).toBe(explicit); + }); +}); + +describe("with neither variable set", () => { + it.each([ + ["Fitbit", "FITBIT_REDIRECT_URI", getFitbitRedirectUri], + ["Google Health", "GOOGLE_HEALTH_REDIRECT_URI", getGoogleHealthRedirectUri], + ] as const)("%s still says it is not configured", (_n, variable, resolve) => { + vi.stubEnv(variable, ""); + vi.stubEnv("NEXT_PUBLIC_APP_URL", " "); + expect(() => resolve()).toThrow(/not configured/); + }); +}); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index ede36d3e6..577087167 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -42,9 +42,11 @@ export function getOuraCredentials(): OuraCredentials | null { } export function getOuraRedirectUri(): string { + // An empty or blank value counts as unset (the compose whitelist + // materialises an unset var as an empty string). return ( - process.env.OURA_REDIRECT_URI || - `${process.env.NEXT_PUBLIC_APP_URL}/api/oura/callback` + process.env.OURA_REDIRECT_URI?.trim() || + `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/oura/callback` ); } diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 9739329ce..b3560d73f 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -54,11 +54,12 @@ export function getPolarCredentials(): PolarCredentials | null { } export function getPolarRedirectUri(): string { - // `||`, not `??`: the compose whitelist materialises an unset var as an empty - // string, which must still fall through to the derived URI. + // An empty or blank value counts as unset: the compose whitelist + // materialises an unset var as an empty string, which must still fall + // through to the derived URI. return ( - process.env.POLAR_REDIRECT_URI || - `${process.env.NEXT_PUBLIC_APP_URL}/api/polar/callback` + process.env.POLAR_REDIRECT_URI?.trim() || + `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/polar/callback` ); } diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts index 09b95fc66..b3e68db25 100644 --- a/src/lib/strava/client.ts +++ b/src/lib/strava/client.ts @@ -52,11 +52,12 @@ export function getStravaCredentials(): StravaCredentials | null { } export function getStravaRedirectUri(): string { - // `||`, not `??`: the compose whitelist materialises an unset var as an empty - // string, which must still fall through to the derived URI. + // An empty or blank value counts as unset: the compose whitelist + // materialises an unset var as an empty string, which must still fall + // through to the derived URI. return ( - process.env.STRAVA_REDIRECT_URI || - `${process.env.NEXT_PUBLIC_APP_URL}/api/strava/callback` + process.env.STRAVA_REDIRECT_URI?.trim() || + `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/strava/callback` ); } diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 6419e0170..bc91beb31 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -33,11 +33,12 @@ export interface WhoopCredentials { } 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. + // An empty or blank value counts as unset: the compose whitelist + // materialises the var as an empty string when unset, which must still fall + // through to the derived URI. return ( - process.env.WHOOP_REDIRECT_URI || - `${process.env.NEXT_PUBLIC_APP_URL}/api/whoop/callback` + process.env.WHOOP_REDIRECT_URI?.trim() || + `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/whoop/callback` ); } diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index db6a41775..6bfe27f4e 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -21,9 +21,12 @@ export interface WithingsCredentials { } export function getWithingsRedirectUri(): string { + // An empty or blank value counts as unset: the compose whitelist + // materialises an unset var as an empty string, which must fall through to + // the derived URI rather than go out as `redirect_uri=`. return ( - process.env.WITHINGS_REDIRECT_URI ?? - `${process.env.NEXT_PUBLIC_APP_URL}/api/withings/callback` + process.env.WITHINGS_REDIRECT_URI?.trim() || + `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/withings/callback` ); }