mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(fitbit): correct the setup guide to the classic Fitbit Web API
The Fitbit integration connects through the classic Fitbit Web API, so a self-hoster registers a Fitbit developer app at dev.fitbit.com — but the setup guide walked users through creating a Google Cloud OAuth client, whose client id the Fitbit sign-in rejects (unauthorized_client). Rewrite the guide for the dev.fitbit.com flow (app type, HTTPS callback, scopes, PKCE), leave the old page as a pointer, and add a test that keeps the documented scopes and endpoints in sync with the client so the guide cannot drift from the code again. Flags the September 2026 sunset of the classic Fitbit Web API.
This commit is contained in:
1 parent
3bceb61aed
commit
46db21dbeb
4 files changed
+162
-77
No files matched your search
@@ -0,0 +1,99 @@
|
||||
# Fitbit integration (Fitbit & Pixel)
|
||||
|
||||
> **Experimental.** Field mapping is still being verified against live accounts,
|
||||
> so some values may be missing or change in a future update.
|
||||
|
||||
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
|
||||
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
|
||||
at [dev.fitbit.com](https://dev.fitbit.com/apps/new)** — **not** a Google Cloud
|
||||
OAuth client. Like WHOOP, credentials are brought per user: each user (or the
|
||||
operator on their behalf) registers their own Fitbit app and pastes the client
|
||||
id/secret into Settings. The internal integration key is `fitbit`.
|
||||
|
||||
> **Common mistake:** the field wants a **Fitbit** Client ID (a short numeric
|
||||
> id from dev.fitbit.com), **not** a Google Cloud client id ending in
|
||||
> `.apps.googleusercontent.com`. A Google Cloud client fails at the Fitbit
|
||||
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
|
||||
> and Google Cloud are separate client registries.
|
||||
|
||||
> **Heads-up — September 2026 sunset.** Google is retiring the classic Fitbit
|
||||
> Web API in **September 2026** and moving to the Google Health API behind Google
|
||||
> sign-in. That replacement currently requires Google Restricted-scope brand
|
||||
> verification plus an annual third-party CASA security assessment, which does
|
||||
> not fit a self-hosted bring-your-own-credentials model, so the path beyond the
|
||||
> sunset is still being worked out. Until then the setup below is the supported
|
||||
> way to connect Fitbit.
|
||||
|
||||
## 1. Register a Fitbit developer app
|
||||
|
||||
1. Sign in at <https://dev.fitbit.com/apps/new> and register a new application.
|
||||
2. **OAuth 2.0 Application Type:**
|
||||
- **Personal** — the app only ever sees the **owner's own** Fitbit account,
|
||||
and gets intraday (per-minute) heart-rate/steps automatically. This is the
|
||||
right choice for a single-person self-host.
|
||||
- **Server** (or **Client**) — needed when the instance connects **other
|
||||
people's** accounts. Intraday/heart-rate access is then granted case by
|
||||
case via Fitbit's Intraday Data Access request form.
|
||||
3. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
|
||||
— absolute, **HTTPS**, and matching the `redirect_uri` HealthLog sends (see
|
||||
the redirect-URI note below). One entry per environment you connect from.
|
||||
4. **Scopes:** tick the ones HealthLog requests (next section).
|
||||
5. Save. Fitbit issues a **Client ID** (short, numeric) and a **Client Secret**
|
||||
— keep the secret out of any chat log.
|
||||
|
||||
## 2. Scopes
|
||||
|
||||
HealthLog requests exactly these read scopes (space-separated on the wire):
|
||||
|
||||
```
|
||||
activity cardio_fitness heartrate oxygen_saturation profile respiratory_rate sleep weight
|
||||
```
|
||||
|
||||
The `temperature` scope is intentionally omitted — the classic skin-temperature
|
||||
reading is a baseline delta rather than an absolute value, so it has no honest
|
||||
canonical slot yet. The authorization uses the Authorization Code Grant **with
|
||||
PKCE (S256)**, which Fitbit recommends; HealthLog mints the `code_verifier`
|
||||
server-side and never exposes it to the browser.
|
||||
|
||||
## 3. Wire the credentials in Settings → Integrations
|
||||
|
||||
Credentials are stored per user, not in `.env`. Sign in as the target user, open
|
||||
**Settings → Integrations → Fitbit**, and paste the Client ID and Client Secret.
|
||||
They are encrypted AES-256-GCM at rest.
|
||||
|
||||
## 4. Connect
|
||||
|
||||
Click **Connect with Fitbit**. HealthLog redirects to the Fitbit consent screen
|
||||
(`www.fitbit.com/oauth2/authorize`), exchanges the returned code for an access +
|
||||
refresh token at `api.fitbit.com/oauth2/token`, stores them encrypted, and runs
|
||||
an initial sync. There is **no webhook** — Fitbit syncs on the safety-net cron
|
||||
pull. Classic Fitbit refresh tokens rotate (one-time use); HealthLog replaces the
|
||||
stored token on every refresh.
|
||||
|
||||
## Redirect URI is validated strictly
|
||||
|
||||
The resolved `redirect_uri` is asserted at connect time to be absolute, `https`
|
||||
(or `http` on localhost), to target exactly `/api/fitbit/callback`, and — when
|
||||
`FITBIT_REDIRECT_URI` is set alongside `NEXT_PUBLIC_APP_URL` — to share its
|
||||
origin. A misconfigured value fails closed rather than redirecting elsewhere.
|
||||
|
||||
## Optional instance env var
|
||||
|
||||
```env
|
||||
# Only when the callback origin differs from NEXT_PUBLIC_APP_URL:
|
||||
FITBIT_REDIRECT_URI="https://your-instance.example.com/api/fitbit/callback"
|
||||
```
|
||||
|
||||
The Fitbit client id/secret are per-user (Settings), not env vars.
|
||||
|
||||
## Source overlap
|
||||
|
||||
If Fitbit and another source (Withings, WHOOP, Apple Health) both supply the same
|
||||
vital — resting heart rate, blood oxygen, body temperature or respiratory rate —
|
||||
you may see both values until a future update settles on a single preferred
|
||||
source.
|
||||
|
||||
## Disconnect
|
||||
|
||||
The Settings **Disconnect** button revokes the stored tokens and removes the
|
||||
connection. Previously synced measurements stay in the database.
|
||||
@@ -1,79 +1,16 @@
|
||||
# Google Health integration (Fitbit & Pixel)
|
||||
# Google Health / Fitbit integration
|
||||
|
||||
> **Beta.** Field mapping is still being verified against live Google Health
|
||||
> accounts, so some values may be missing or change in a future update.
|
||||
> **This page moved.** HealthLog connects Fitbit and Pixel Watch data through the
|
||||
> classic **Fitbit Web API** (an app registered at
|
||||
> [dev.fitbit.com](https://dev.fitbit.com/apps/new)), **not** through a Google
|
||||
> Cloud OAuth client. If you were told to create a Google Cloud client and hit
|
||||
> `unauthorized_client — Invalid client_id`, that is why.
|
||||
|
||||
HealthLog reads Fitbit and Pixel Watch data through the **Google Health API**.
|
||||
The OAuth app is therefore a **Google Cloud** OAuth client — not a Fitbit
|
||||
developer-console app. Like WHOOP, credentials are brought per user: each user
|
||||
registers their own Google Cloud OAuth client and pastes the client id/secret
|
||||
into Settings. The internal integration key is `fitbit`.
|
||||
See **[Fitbit integration (Fitbit & Pixel)](./fitbit.md)** for the correct setup.
|
||||
|
||||
## 1. Create a Google Cloud OAuth client
|
||||
|
||||
1. In <https://console.cloud.google.com> create (or pick) a project and, under
|
||||
**APIs & Services**, enable the **Google Health API**.
|
||||
2. Configure the OAuth consent screen for the project.
|
||||
3. Under **APIs & Services → Credentials**, create an **OAuth 2.0 Client ID** of
|
||||
type **Web application**.
|
||||
4. **Authorized redirect URI:**
|
||||
`https://your-instance.example.com/api/fitbit/callback`
|
||||
5. Save. Google issues a **Client ID** and **Client Secret** — keep the secret
|
||||
out of any chat log.
|
||||
|
||||
## 2. Scopes
|
||||
|
||||
HealthLog requests exactly these read-only scopes:
|
||||
|
||||
```
|
||||
https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly
|
||||
https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly
|
||||
https://www.googleapis.com/auth/googlehealth.sleep.readonly
|
||||
https://www.googleapis.com/auth/googlehealth.profile.readonly
|
||||
```
|
||||
|
||||
Location and intraday scopes are deliberately omitted to keep the
|
||||
Restricted-scope review surface minimal. The authorize request sends
|
||||
`access_type=offline` + `prompt=consent` — Google's requirement to issue a
|
||||
refresh token (the equivalent of WHOOP's `offline` scope).
|
||||
|
||||
## 3. Wire the credentials in Settings → Integrations
|
||||
|
||||
Credentials are stored per user, not in `.env`. Sign in as the target user, open
|
||||
**Settings → Integrations → Google Health**, and paste the client ID and secret.
|
||||
They are encrypted AES-256-GCM at rest.
|
||||
|
||||
## 4. Connect
|
||||
|
||||
Click **Connect with Google Health**. HealthLog redirects to Google's OAuth
|
||||
consent screen, exchanges the returned code for an access + refresh token, stores
|
||||
them encrypted, and runs an initial sync. There is **no webhook** — Google Health
|
||||
syncs on the safety-net cron pull.
|
||||
|
||||
## Redirect URI is validated strictly
|
||||
|
||||
The resolved `redirect_uri` is asserted at connect time to be absolute, `https`
|
||||
(or `http` on localhost), to target exactly `/api/fitbit/callback`, and — when
|
||||
`FITBIT_REDIRECT_URI` is set alongside `NEXT_PUBLIC_APP_URL` — to share its
|
||||
origin. A misconfigured value fails closed rather than redirecting elsewhere.
|
||||
|
||||
## Optional instance env var
|
||||
|
||||
```env
|
||||
# Only when the callback origin differs from NEXT_PUBLIC_APP_URL:
|
||||
FITBIT_REDIRECT_URI="https://your-instance.example.com/api/fitbit/callback"
|
||||
```
|
||||
|
||||
The Google Cloud client id/secret are per-user (Settings), not env vars.
|
||||
|
||||
## Source overlap
|
||||
|
||||
If Google Health and another source (Withings, WHOOP, Apple Health) both supply
|
||||
the same vital — resting heart rate, blood oxygen, body temperature or
|
||||
respiratory rate — you may see both values until a future update settles on a
|
||||
single preferred source.
|
||||
|
||||
## Disconnect
|
||||
|
||||
The Settings **Disconnect** button revokes the stored tokens and removes the
|
||||
connection. Previously synced measurements stay in the database.
|
||||
Background: Google is retiring the classic Fitbit Web API in September 2026 in
|
||||
favour of the Google Health API behind Google sign-in. That replacement requires
|
||||
Restricted-scope brand verification plus an annual CASA security assessment,
|
||||
which does not currently fit a self-hosted bring-your-own-credentials model, so
|
||||
until the sunset the Fitbit developer-app path documented above is the supported
|
||||
way to connect.
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
// v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a
|
||||
// v1.12.0 — Fitbit (Fitbit & Pixel) card. Mirrors the WHOOP card: a
|
||||
// BYO-key credentials form first, then an OAuth connect, then the
|
||||
// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status
|
||||
// reads off the consolidated /api/integrations/status envelope (the per-card
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FITBIT_OAUTH_SCOPE } from "../client";
|
||||
|
||||
/**
|
||||
* Guard against the class of bug reported in #396: the setup docs drifting away
|
||||
* from what the code actually does. The Fitbit integration uses the CLASSIC
|
||||
* Fitbit Web API (an app from dev.fitbit.com), not a Google Cloud OAuth client —
|
||||
* a doc that says otherwise sends users to the wrong registry and fails with
|
||||
* `unauthorized_client — Invalid client_id`.
|
||||
*/
|
||||
const readDoc = (name: string): string =>
|
||||
readFileSync(join(process.cwd(), "docs", "integrations", name), "utf8");
|
||||
|
||||
describe("Fitbit setup docs stay in sync with the code", () => {
|
||||
const fitbitDoc = readDoc("fitbit.md");
|
||||
|
||||
it("documents every OAuth scope the client actually requests", () => {
|
||||
for (const scope of FITBIT_OAUTH_SCOPE.split(" ")) {
|
||||
expect(fitbitDoc).toContain(scope);
|
||||
}
|
||||
});
|
||||
|
||||
it("points at the classic Fitbit endpoints and dev.fitbit.com", () => {
|
||||
expect(fitbitDoc).toContain("dev.fitbit.com");
|
||||
expect(fitbitDoc).toContain("www.fitbit.com/oauth2/authorize");
|
||||
expect(fitbitDoc).toContain("api.fitbit.com/oauth2/token");
|
||||
expect(fitbitDoc).toContain("/api/fitbit/callback");
|
||||
});
|
||||
|
||||
it("never instructs the user to create a Google Cloud OAuth client (the #396 mistake)", () => {
|
||||
// The "do this" markers of the wrong walkthrough: the Google Cloud console
|
||||
// and the Google Health scope URLs. Mentioning `apps.googleusercontent.com`
|
||||
// as a "don't paste this" warning is allowed and intentional.
|
||||
expect(fitbitDoc).not.toContain("console.cloud.google");
|
||||
expect(fitbitDoc).not.toContain("googleapis.com/auth/googlehealth");
|
||||
});
|
||||
|
||||
it("keeps the old google-health page as a pointer to the Fitbit setup", () => {
|
||||
const legacy = readDoc("google-health.md");
|
||||
expect(legacy).toContain("fitbit.md");
|
||||
// The legacy page must not re-introduce the wrong Google Cloud walkthrough.
|
||||
expect(legacy).not.toContain("console.cloud.google");
|
||||
expect(legacy).not.toContain("googleapis.com/auth/googlehealth");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user