mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Google turns the legacy Fitbit Web API off on 30 October 2026 (support ended on 30 September). From that day the hourly poll, the backfill, the sleep repair and the token refresh make no call, the connect, sync and test routes refuse with `provider_retired`, and the connection reads as not connected to the health read and the watchdog, so the shutdown pages nobody and asks nobody to reconnect. The connection row and everything it imported stay. The status envelope publishes `retirement` on the Fitbit entry (the day, whether it has come, the successor), and the Fitbit card shows when the connection stops, how to move to Google Health (same devices, one more consent, tokens cannot be carried over) and that the data stays; sync and connect grey out after the day. Both integrations can run side by side meanwhile: every type Fitbit writes is laddered with Fitbit just above Google Health, so a day both deliver counts once. The test setups pin the turndown off, so the Fitbit suites do not turn red on the day; the turndown's own tests flip it.
110 lines
3.9 KiB
TypeScript
110 lines
3.9 KiB
TypeScript
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import { prisma } from "@/lib/db";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client";
|
|
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
|
|
import {
|
|
FITBIT_OAUTH_STATE_COOKIE,
|
|
FITBIT_OAUTH_STATE_TTL_MS,
|
|
mintFitbitOAuthStateNonce,
|
|
} from "@/lib/fitbit/oauth-state";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { relativeRedirect } from "@/lib/http/relative-redirect";
|
|
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
|
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
|
|
|
/**
|
|
* Redirect the user to the Fitbit OAuth consent page (classic
|
|
* `www.fitbit.com/oauth2/authorize`, v1.20.0).
|
|
*
|
|
* Mirrors the WHOOP connect route: a fully-random base64url state nonce backed
|
|
* by a 10-minute `FitbitOAuthState` ledger row carries the `(nonce → userId)`
|
|
* mapping, so the user id never travels in the OAuth `state` param (which can
|
|
* land in request logs / network captures). The httpOnly + Secure cookie
|
|
* carries JUST the nonce; the callback resolves the user via the row's `userId`.
|
|
*
|
|
* v1.20.0 — the classic Fitbit Web API uses PKCE (S256). The route mints a
|
|
* `code_verifier`/`code_challenge` pair, stashes the verifier on the ledger row
|
|
* (server-side, never exposed to the browser), and sends the challenge to the
|
|
* authorize endpoint. The callback reads the verifier back to present on the
|
|
* token exchange.
|
|
*
|
|
* Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam
|
|
* ledger rows for the full 10-min TTL window. Both the rate-limit and
|
|
* create-failure paths redirect (not JSON) because the entry point is a browser
|
|
* navigation — a 429 envelope would surface as a blank page. The
|
|
* no-credentials branch redirects too (v1.29.x), matching Withings/WHOOP.
|
|
*/
|
|
const CONNECT_RATE_LIMIT = 10;
|
|
const CONNECT_WINDOW_MS = 60_000;
|
|
|
|
export const GET = apiHandler(async (req: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "fitbit.connect" } });
|
|
|
|
const rl = await checkRateLimit(
|
|
`fitbit:connect:${user.id}`,
|
|
CONNECT_RATE_LIMIT,
|
|
CONNECT_WINDOW_MS,
|
|
);
|
|
if (!rl.allowed) {
|
|
annotate({ action: { name: "fitbit.connect.rate_limited" } });
|
|
return NextResponse.redirect(
|
|
new URL(
|
|
"/settings/integrations?fitbit=error&reason=rate_limited",
|
|
req.url,
|
|
),
|
|
);
|
|
}
|
|
|
|
// A grant issued now would never sync anything: Google has turned the Web
|
|
// API off. The card explains the move to Google Health.
|
|
if (isFitbitRetired()) {
|
|
annotate({ action: { name: "fitbit.connect.retired" } });
|
|
return relativeRedirect(
|
|
"/settings/integrations?fitbit=error&reason=retired",
|
|
);
|
|
}
|
|
|
|
const creds = await getUserFitbitCredentials(user.id);
|
|
if (!creds) {
|
|
annotate({ action: { name: "fitbit.connect.no_credentials" } });
|
|
return relativeRedirect(
|
|
"/settings/integrations?fitbit=error&reason=nocreds",
|
|
);
|
|
}
|
|
|
|
const nonce = mintFitbitOAuthStateNonce();
|
|
const pkce = generatePkcePair();
|
|
try {
|
|
await prisma.fitbitOAuthState.create({
|
|
data: {
|
|
nonce,
|
|
userId: user.id,
|
|
codeVerifier: pkce.verifier,
|
|
expiresAt: new Date(Date.now() + FITBIT_OAUTH_STATE_TTL_MS),
|
|
},
|
|
});
|
|
} catch (err) {
|
|
getEvent()?.setError(err);
|
|
annotate({ action: { name: "fitbit.connect.create_failed" } });
|
|
return relativeRedirect(
|
|
"/settings/integrations?fitbit=error&reason=connect",
|
|
);
|
|
}
|
|
|
|
const url = getAuthorizationUrl(nonce, creds, pkce.challenge);
|
|
|
|
const response = NextResponse.redirect(url);
|
|
response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, {
|
|
httpOnly: true,
|
|
secure: shouldEmitSecureCookie(),
|
|
sameSite: "lax",
|
|
maxAge: Math.floor(FITBIT_OAUTH_STATE_TTL_MS / 1000),
|
|
path: "/",
|
|
});
|
|
|
|
return response;
|
|
});
|