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