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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 16:08:57 +02:00
1 parent c88754a0b5
commit c366d4946c
8 files changed
+100 -20

No files matched your search

+5 -2
View File
@@ -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);
+5 -2
View File
@@ -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);
@@ -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/);
});
});
+4 -2
View File
@@ -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`
);
}
+5 -4
View File
@@ -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`
);
}
+5 -4
View File
@@ -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`
);
}
+5 -4
View File
@@ -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`
);
}
+5 -2
View File
@@ -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`
);
}