feat(fitbit): add Google OAuth, credentials, and token refresh

Land the OAuth + credentials half of the Fitbit / Google Health provider,
cloning the WHOOP provider structure and adapting it to the Google identity
flow.

- Client OAuth half (src/lib/fitbit/client.ts): Google authorize-URL builder
  (access_type=offline + prompt=consent), authorization-code exchange, token
  refresh, and a profile fetch for the connection's external user id. Client
  credentials ride in an HTTP Basic-auth header on the token endpoint; the
  four launch Restricted read scopes are pinned in FITBIT_OAUTH_SCOPE. All
  egress goes through safeFetch.
- Non-rotating refresh tokens: getValidToken refreshes at expiry-minus-five-
  minutes, persists the new access token + expiry, and overwrites the stored
  refresh token ONLY when the response carries a fresh one — Google keeps the
  refresh token stable, unlike WHOOP.
- BYO credentials route (PUT/GET/DELETE /api/fitbit/credentials): per-user
  client id/secret stored encrypted on the User columns, built field-by-field
  under requireAuth.
- OAuth state + connect + callback routes: a random base64url nonce backed by
  a 10-minute FitbitOAuthState ledger row carries the (nonce -> userId)
  mapping; the callback validates the cookie/URL state with timingSafeEqual,
  consumes the row atomically (replay/expired/cross-user reason tags),
  exchanges the code, upserts the encrypted FitbitConnection, clears prior
  reauth state, and enqueues the self-converging history backfill.
- Response classifier mirrors WHOOP's HTTP-status buckets; Google signals a
  revoked grant with a 401, so there is no invalid_grant body special-case.

Tests cover the authorize URL, Basic-auth token exchange, the no-rotation
refresh branch (token preserved when omitted, overwritten when present), the
classifier status table, and the OAuth-state nonce mint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 21:49:39 +02:00
1 parent d4801d4646
commit 7ab2cbaa56
14 files changed
+1561

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
exchangeCode,
fetchProfile,
resolveFitbitUserId,
FITBIT_OAUTH_SCOPE,
} from "@/lib/fitbit/client";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import { FITBIT_OAUTH_STATE_COOKIE } from "@/lib/fitbit/oauth-state";
import { FITBIT_BACKFILL_QUEUE } from "@/lib/jobs/fitbit-backfill";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { markReconnected } from "@/lib/integrations/status";
import { Prisma } from "@/generated/prisma/client";
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
* `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.
*
* Reason tags distinguish the four post-delete branches for the audit trail:
* `csrf1` (URL/cookie mismatch, short-circuit before delete), `replay` (P2025 —
* 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.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
new URL(
`/settings/integrations?fitbit=error&reason=${reason}`,
process.env.NEXT_PUBLIC_APP_URL!,
),
);
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const storedState = request.cookies.get(FITBIT_OAUTH_STATE_COOKIE)?.value;
// CSRF leg 1: URL state must match the cookie state byte-for-byte
// (timing-safe). Runs BEFORE the atomic delete so a probe with arbitrary
// state values can't grief legitimate ledger rows.
if (
!state ||
!storedState ||
state.length !== storedState.length ||
!timingSafeEqual(Buffer.from(state), Buffer.from(storedState))
) {
annotate({ meta: { reason: "csrf1" } });
return ERR("csrf1");
}
// CSRF leg 2: atomically consume the ledger row. `delete` returns the row on
// success so the `expiresAt` + `userId` checks run against the consumed
// 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;
try {
stateRow = await prisma.fitbitOAuthState.delete({
where: { nonce: state },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2025"
) {
annotate({ meta: { reason: "replay" } });
return ERR("replay");
}
const errName = err instanceof Error ? err.name : "unknown";
getEvent()?.addWarning(`oauth-state-delete failed: ${errName}`);
annotate({ meta: { reason: "state" } });
return ERR("state");
}
if (stateRow.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return ERR("expired");
}
if (stateRow.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
if (!code) {
return ERR("nocode");
}
try {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return ERR("nocreds");
}
const tokens = await exchangeCode(code, creds);
const profile = await fetchProfile(tokens.access_token);
const fitbitUserId = resolveFitbitUserId(profile);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
// The initial code exchange always returns a refresh token (the connect
// route forces `prompt=consent` + `access_type=offline`). Fall back to the
// existing stored token defensively if Google ever omits it on a re-consent.
await prisma.fitbitConnection.upsert({
where: { userId: user.id },
update: {
fitbitUserId,
accessToken: encrypt(tokens.access_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? FITBIT_OAUTH_SCOPE,
backfillCompletedAt: null,
...(tokens.refresh_token
? { refreshToken: encrypt(tokens.refresh_token) }
: {}),
},
create: {
userId: user.id,
fitbitUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token ?? ""),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? FITBIT_OAUTH_SCOPE,
},
});
await auditLog("fitbit.connect", {
userId: user.id,
details: { fitbitUserId },
});
// Re-completing OAuth clears any prior reauth-required state.
await markReconnected(user.id, "fitbit");
// Enqueue the self-converging history backfill. Best-effort: the boot-time
// discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a
// missing boss instance here doesn't strand the connection.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(FITBIT_BACKFILL_QUEUE, {
userId: user.id,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
getEvent()?.addWarning(`fitbit-backfill enqueue failed: ${err}`),
);
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?fitbit=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(FITBIT_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
});
+90
View File
@@ -0,0 +1,90 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
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 { 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 { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Redirect the user to the Google OAuth consent page for Fitbit / Google Health
* (v1.12.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`.
*
* 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.
*/
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,
),
);
}
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return apiError(
"Please configure your Google OAuth Client ID and Client Secret in Settings first.",
400,
);
}
const nonce = mintFitbitOAuthStateNonce();
try {
await prisma.fitbitOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + FITBIT_OAUTH_STATE_TTL_MS),
},
});
} catch (err) {
getEvent()?.setError(err);
annotate({ action: { name: "fitbit.connect.create_failed" } });
return NextResponse.redirect(
new URL("/settings/integrations?fitbit=error&reason=connect", req.url),
);
}
const url = getAuthorizationUrl(nonce, creds);
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;
});
+79
View File
@@ -0,0 +1,79 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { fitbitCredentialsSchema } from "@/lib/validations/fitbit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* Check whether the user has Fitbit / Google Health BYO-key credentials stored
* (v1.12.0).
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.credentials.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
},
});
return apiSuccess({
hasCredentials:
!!dbUser?.fitbitClientIdEncrypted &&
!!dbUser?.fitbitClientSecretEncrypted,
});
});
/**
* Save Fitbit / Google Health API credentials (encrypted at rest).
*/
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.credentials.update" } });
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const result = z.safeParse(fitbitCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
}
await prisma.user.update({
where: { id: user.id },
data: {
fitbitClientIdEncrypted: encrypt(result.data.clientId),
fitbitClientSecretEncrypted: encrypt(result.data.clientSecret),
},
});
return apiSuccess({ updated: true });
});
/**
* Delete Fitbit credentials and the active connection.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.credentials.delete" } });
await prisma.fitbitConnection
.delete({ where: { userId: user.id } })
.catch(() => {});
await prisma.user.update({
where: { id: user.id },
data: {
fitbitClientIdEncrypted: null,
fitbitClientSecretEncrypted: null,
},
});
return apiSuccess({ deleted: true });
});