feat(fitbit): wire PKCE through connect/callback and fix the test probe

Connect mints the PKCE pair, stashes the verifier on the OAuth-state row, and
sends the S256 challenge to the authorize endpoint. Callback reads the verifier
back and presents it on the token exchange, refusing cleanly when it is absent.
The connection-test probe hits the classic /1/user/-/profile.json endpoint.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 07:21:06 +02:00
1 parent 4ff0e4ca0c
commit e748a91a36
5 files changed
+55 -36

No files matched your search

+27 -13
View File
@@ -19,9 +19,9 @@ import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
/**
* OAuth callback from Google for Fitbit / Google Health (v1.12.0). Mirrors the
* WHOOP callback: the `state` param is a random base64url nonce keyed against
* the `FitbitOAuthState` ledger. The in-flight user is resolved via the row's
* OAuth callback from Fitbit (classic Web API, v1.20.0). Mirrors the WHOOP
* callback: the `state` param is a random base64url nonce keyed against the
* `FitbitOAuthState` ledger. The in-flight user is resolved via the row's
* `userId` (never parsed from the cookie value) and cross-checked against the
* session. The row is consumed (deleted) atomically on every exit branch so a
* replay of the same nonce fails the second time.
@@ -31,9 +31,12 @@ import { timingSafeEqual } from "node:crypto";
* nonce already consumed), `expired` (valid row, TTL elapsed), and `cross_user`
* (valid row, session userId mismatch).
*
* On success: exchange the code, fetch the Google Health profile for the
* external `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any
* prior reauth state, and enqueue the self-converging history backfill.
* v1.20.0 — the deleted ledger row carries the PKCE `code_verifier` minted at
* connect; it is presented on the token exchange.
*
* On success: exchange the code, fetch the Fitbit profile for the external
* `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any prior
* reauth state, and enqueue the self-converging history backfill.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
@@ -70,7 +73,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
// payload. P2025 means the nonce was already consumed (replay) or never
// existed. Atomic at the Postgres row level: two concurrent callbacks with the
// same nonce can't both pass before either deletes.
let stateRow: { userId: string; expiresAt: Date } | null = null;
let stateRow: {
userId: string;
expiresAt: Date;
codeVerifier: string | null;
} | null = null;
try {
stateRow = await prisma.fitbitOAuthState.delete({
where: { nonce: state },
@@ -102,19 +109,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
return ERR("nocode");
}
// The PKCE verifier minted at connect rides on the ledger row. Its absence
// means a malformed / legacy handshake — refuse rather than send a code
// exchange Fitbit will reject for a missing verifier.
if (!stateRow.codeVerifier) {
annotate({ meta: { reason: "no_verifier" } });
return ERR("no_verifier");
}
try {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return ERR("nocreds");
}
const tokens = await exchangeCode(code, creds);
const tokens = await exchangeCode(code, stateRow.codeVerifier, creds);
// The initial code exchange MUST return a refresh token (the connect route
// forces `prompt=consent` + `access_type=offline`). If Google ever omits it,
// refuse to persist — a stored empty refresh token silently bricks every
// future token refresh (the refresh POST then 400s and the connection dies
// the moment the access token expires). Error out cleanly so the user
// The initial code exchange MUST return a refresh token. If Fitbit ever
// omits it, refuse to persist — a stored empty refresh token silently bricks
// every future token refresh (the refresh POST then 400s and the connection
// dies the moment the access token expires). Error out cleanly so the user
// re-consents rather than landing a dead connection.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
+13 -5
View File
@@ -3,7 +3,7 @@ import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/fitbit/client";
import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import {
FITBIT_OAUTH_STATE_COOKIE,
@@ -14,8 +14,8 @@ import { NextRequest, NextResponse } from "next/server";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Redirect the user to the Google OAuth consent page for Fitbit / Google Health
* (v1.12.0).
* 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)`
@@ -23,6 +23,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
* 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
@@ -53,17 +59,19 @@ export const GET = apiHandler(async (req: NextRequest) => {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return apiError(
"Please configure your Google OAuth Client ID and Client Secret in Settings first.",
"Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.",
400,
);
}
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),
},
});
@@ -75,7 +83,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
const url = getAuthorizationUrl(nonce, creds);
const url = getAuthorizationUrl(nonce, creds, pkce.challenge);
const response = NextResponse.redirect(url);
response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, {
+7 -7
View File
@@ -6,17 +6,17 @@ import { syncUserFitbit } from "@/lib/fitbit/sync";
import { NextRequest } from "next/server";
/**
* Manually trigger a Fitbit / Google Health sync for the current user (v1.12.0).
* Manually trigger a Fitbit sync for the current user.
*
* Mirrors the WHOOP manual-sync route: incremental by default, full history when
* `{ fullSync: true }` is posted.
*
* v1.12.1 — rate-limited (M-1). Every sibling Fitbit route (connect / test /
* resume) is limited; this one was the outlier, while `{ fullSync: true }`
* drives four paginated Google walkers (each capped at 1000 pages). A tight
* loop or a stolen native token could pin Prisma + the Google Health quota.
* A baseline 5/60s bucket (matching the test route) gates the route, and the
* expensive `fullSync` path carries a tighter 1/hour bucket of its own.
* Rate-limited: every sibling Fitbit route (connect / test / resume) is limited;
* this one was the outlier, while `{ fullSync: true }` drives four resource syncs
* over the classic Web API's tight 150 req/h-per-user budget. A tight loop or a
* stolen native token could exhaust that budget and pin Prisma. A baseline 5/60s
* bucket (matching the test route) gates the route, and the expensive `fullSync`
* path carries a tighter 1/hour bucket of its own.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -206,7 +206,7 @@ describe("POST /api/integrations/fitbit/test", () => {
new Response(
JSON.stringify({
error: "Authorization: Bearer sk-secret echoed",
url: "https://health.googleapis.com/v4/users/me/profile",
url: "https://api.fitbit.com/1/user/-/profile.json",
}),
{ status: 401 },
),
@@ -215,6 +215,5 @@ describe("POST /api/integrations/fitbit/test", () => {
const text = await response.text();
expect(response.status).toBe(502);
expect(text).not.toMatch(/sk-/);
expect(text).not.toMatch(/health\.googleapis\.com/);
});
});
@@ -13,7 +13,7 @@ export const dynamic = "force-dynamic";
// Cheap authenticated probe — the single (non-paginated) profile endpoint the
// Fitbit client already calls via `fetchProfile`.
// Base from `src/lib/fitbit/client.ts` (`FITBIT_API_BASE`).
const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/users/me/profile`;
const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/1/user/-/profile.json`;
const TIMEOUT_MS = 5_000;
type CategorisedError = { code: string; message: string };
@@ -22,24 +22,24 @@ function categoriseHttpStatus(status: number): CategorisedError {
if (status === 401 || status === 403) {
return {
code: "credentials_rejected",
message: "Google Health rejected the credentials",
message: "Fitbit rejected the credentials",
};
}
if (status === 429) {
return {
code: "rate_limited",
message: "Google Health rate-limited the request",
message: "Fitbit rate-limited the request",
};
}
if (status >= 500) {
return {
code: "upstream_error",
message: "Google Health returned a server error",
message: "Fitbit returned a server error",
};
}
return {
code: "connection_failed",
message: "Google Health connection failed",
message: "Fitbit connection failed",
};
}
@@ -57,7 +57,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const tokenInfo = await getValidToken(user.id);
if (!tokenInfo) {
return apiError("Google Health not connected", 422, {
return apiError("Fitbit not connected", 422, {
errorCode: "not_configured",
});
}
@@ -115,9 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
return apiError(
isAbort
? "Google Health request timed out"
: "Google Health connection failed",
isAbort ? "Fitbit request timed out" : "Fitbit connection failed",
502,
{ errorCode: code },
);