feat(google-health): wire the OAuth routes, sync jobs, and source priority

Add the request surface and the background sync for the Google Health
provider, all built on the shared integration plumbing.

- Routes under /api/google-health: connect (mint the PKCE pair and state
  nonce, redirect to Google), callback (timing-safe CSRF, atomic state
  consume, token exchange, connection upsert, clear the reauth flag,
  enqueue backfill), credentials (encrypt the operator's BYO client id
  and secret field-by-field), disconnect, status, and a rate-limited
  manual sync.
- Jobs: a boot-time backfill, an hourly poll cohort, and a daily
  oauth-state cleanup, registered under the existing dead-queue contract.
- Rank GOOGLE_HEALTH just after Fitbit in the per-metric source ladders;
  it is the successor API for the same devices.
- Add GOOGLE_HEALTH to the readable measurement-source enum but leave it
  out of the writable set — the provider is server-owned, so a client
  cannot attribute a write to it.
- Register the two new encrypted token columns and the two BYO-client
  columns in the encryption-rotation registry and the rotation script.
This commit is contained in:
Marc-André Bombeck committed 2026-07-03 14:13:28 +02:00
1 parent fb17a4e9c6
commit b26a18d35e
15 files changed
+1107 -10

No files matched your search

+208
View File
@@ -0,0 +1,208 @@
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,
resolveGoogleHealthUserId,
getGoogleHealthScopeString,
} from "@/lib/google-health/client";
import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials";
import { GOOGLE_HEALTH_OAUTH_STATE_COOKIE } from "@/lib/google-health/oauth-state";
import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-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 the Google Health integration (v1.26.0).
* Mirrors the Fitbit callback with PKCE: the `state` param is a random
* base64url nonce keyed against the `GoogleHealthOAuthState` ledger, which also
* carries the PKCE `code_verifier`. 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 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 with the stored verifier, fetch the Google
* Health profile for the external `googleUserId`, persist the encrypted
* `GoogleHealthConnection`, clear any prior reauth state (`needsReauth:false` +
* the status ledger), and enqueue the self-converging history backfill.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
new URL(
`/settings/integrations?googleHealth=error&reason=${reason}`,
process.env.NEXT_PUBLIC_APP_URL!,
),
);
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "google_health.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const storedState = request.cookies.get(
GOOGLE_HEALTH_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 (and the PKCE verifier read)
// run against the consumed payload. P2025 means the nonce was already
// consumed (replay) or never existed — benign. 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;
codeVerifier: string | null;
} | null = null;
try {
stateRow = await prisma.googleHealthOAuthState.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 (!stateRow.codeVerifier) {
annotate({ meta: { reason: "no_verifier" } });
return ERR("no_verifier");
}
if (!code) {
return ERR("nocode");
}
try {
const creds = await getUserGoogleHealthCredentials(user.id);
if (!creds) {
return ERR("nocreds");
}
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 re-consents rather than landing a dead connection.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
`google-health callback: token response carried no refresh_token for user ${user.id}`,
);
annotate({ meta: { reason: "no_refresh_token" } });
return ERR("no_refresh_token");
}
const profile = await fetchProfile(tokens.access_token);
const googleUserId = resolveGoogleHealthUserId(profile);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
const scope = tokens.scope ?? getGoogleHealthScopeString();
await prisma.googleHealthConnection.upsert({
where: { userId: user.id },
update: {
googleUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope,
// Fresh consent clears the soft-disconnect flag so the card drops the
// reconnect CTA and the sync path stops short-circuiting.
needsReauth: false,
backfillCompletedAt: null,
},
create: {
userId: user.id,
googleUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope,
needsReauth: false,
},
});
await auditLog("google_health.connect", {
userId: user.id,
details: { googleUserId },
});
// Re-completing OAuth clears any prior reauth-required state on the status
// ledger too (the `needsReauth` column above is the connection-local flag;
// this resets the integration-status streak).
await markReconnected(user.id, "google-health");
// 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(GOOGLE_HEALTH_BACKFILL_QUEUE, {
userId: user.id,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
getEvent()?.addWarning(
`google-health-backfill enqueue failed: ${err}`,
),
);
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?googleHealth=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(GOOGLE_HEALTH_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
});
+100
View File
@@ -0,0 +1,100 @@
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 {
generatePkcePair,
getAuthorizationUrl,
} from "@/lib/google-health/client";
import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials";
import {
GOOGLE_HEALTH_OAUTH_STATE_COOKIE,
GOOGLE_HEALTH_OAUTH_STATE_TTL_MS,
mintGoogleHealthOAuthStateNonce,
} from "@/lib/google-health/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 the Google Health
* integration (v1.26.0).
*
* Mirrors the Fitbit / WHOOP connect route with PKCE added: a fully-random
* base64url state nonce backed by a 10-minute `GoogleHealthOAuthState` ledger
* row carries the `(nonce → userId)` mapping AND the PKCE `code_verifier`, so
* neither the user id nor the verifier ever 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` and reads the verifier off the same row.
*
* 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: "google_health.connect" } });
const rl = await checkRateLimit(
`google-health:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "google_health.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?googleHealth=error&reason=rate_limited",
req.url,
),
);
}
const creds = await getUserGoogleHealthCredentials(user.id);
if (!creds) {
return apiError(
"Please configure your Google OAuth Client ID and Client Secret in Settings first.",
400,
);
}
const nonce = mintGoogleHealthOAuthStateNonce();
const { verifier, challenge } = generatePkcePair();
try {
await prisma.googleHealthOAuthState.create({
data: {
nonce,
userId: user.id,
codeVerifier: verifier,
expiresAt: new Date(Date.now() + GOOGLE_HEALTH_OAUTH_STATE_TTL_MS),
},
});
} catch (err) {
getEvent()?.setError(err);
annotate({ action: { name: "google_health.connect.create_failed" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?googleHealth=error&reason=connect",
req.url,
),
);
}
const url = getAuthorizationUrl(nonce, creds, challenge);
const response = NextResponse.redirect(url);
response.cookies.set(GOOGLE_HEALTH_OAUTH_STATE_COOKIE, nonce, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(GOOGLE_HEALTH_OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
});
@@ -0,0 +1,103 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* Per-user Google Health BYO-key credentials. Each self-hoster registers their
* own Google Cloud OAuth client (the Restricted-scope brand verification + CASA
* assessment is per-OAuth-client, so a single shared app is unworkable for a
* multi-operator product) and pastes the client id/secret into Settings. Stored
* encrypted on `User`.
*/
const googleHealthCredentialsSchema = z.object({
clientId: z.string().min(1).max(200),
clientSecret: z.string().min(1).max(200),
});
/**
* Check whether the user has Google Health BYO-key credentials stored
* (v1.26.0).
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "google_health.credentials.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
googleHealthClientIdEncrypted: true,
googleHealthClientSecretEncrypted: true,
},
});
return apiSuccess({
hasCredentials:
!!dbUser?.googleHealthClientIdEncrypted &&
!!dbUser?.googleHealthClientSecretEncrypted,
});
});
/**
* Save Google Health API credentials (encrypted at rest).
*/
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "google_health.credentials.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(googleHealthCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
}
// Field-by-field assignment — never spread the parsed object, so no future
// schema addition can silently mass-assign an unexpected column.
await prisma.user.update({
where: { id: user.id },
data: {
googleHealthClientIdEncrypted: encrypt(result.data.clientId),
googleHealthClientSecretEncrypted: encrypt(result.data.clientSecret),
},
});
return apiSuccess({ updated: true });
});
/**
* Delete Google Health credentials and the active connection.
*
* Audits the teardown and parks the integration ledger at `disconnected` for
* parity with the Fitbit / Polar / Oura credential-DELETE so a sensitive op
* leaves a uniform audit trail and the status snapshot does not linger at a
* stale state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "google_health.credentials.delete" } });
await prisma.googleHealthConnection
.delete({ where: { userId: user.id } })
.catch(() => {});
await prisma.user.update({
where: { id: user.id },
data: {
googleHealthClientIdEncrypted: null,
googleHealthClientSecretEncrypted: null,
},
});
await auditLog("google_health.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "google-health");
return apiSuccess({ deleted: true });
});
@@ -0,0 +1,42 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
/**
* Disconnect the Google Health integration for the current user (v1.26.0).
*
* Clears the stored OAuth tokens by deleting the `GoogleHealthConnection` row
* and parks the integration status at `disconnected`. There is no per-user
* subscription to tear down (Google Health is poll-only at launch — no Pub/Sub
* push), so no upstream unsubscribe call is needed; deleting the encrypted
* token pair is the effective revocation. The BYO-key credentials on `User` are
* left intact so a reconnect doesn't force the user to re-paste them; use the
* credentials DELETE endpoint to remove those too.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "google_health.disconnect" } });
const connection = await prisma.googleHealthConnection.findUnique({
where: { userId: user.id },
});
if (!connection) {
return apiError("No Google Health connection", 404);
}
await prisma.googleHealthConnection.delete({
where: { userId: user.id },
});
await auditLog("google_health.disconnect", {
userId: user.id,
});
await markDisconnected(user.id, "google-health");
return apiSuccess({ disconnected: true });
});
+62
View File
@@ -0,0 +1,62 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
/**
* Get Google Health connection status for the current user (v1.26.0).
*
* Mirrors the Fitbit status route: `configured` checks the per-user BYO-key
* credentials; the connection row reports last-sync + token expiry + backfill
* progress. `tokenExpired` is reported straight off the row; the sync path
* refreshes lazily via `getValidToken`. `needsReauth` surfaces the soft
* disconnect (a refresh that failed with `invalid_grant` — the Testing-mode
* 7-day refresh expiry) so the card can render a reconnect CTA.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "google_health.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
googleHealthClientIdEncrypted: true,
googleHealthClientSecretEncrypted: true,
},
});
const configured =
!!dbUser?.googleHealthClientIdEncrypted &&
!!dbUser?.googleHealthClientSecretEncrypted;
const connection = await prisma.googleHealthConnection.findUnique({
where: { userId: user.id },
select: {
googleUserId: true,
lastSyncedAt: true,
tokenExpiresAt: true,
backfillCompletedAt: true,
needsReauth: true,
createdAt: true,
scope: true,
},
});
if (!connection) {
return apiSuccess({ connected: false, configured });
}
const tokenExpired = connection.tokenExpiresAt.getTime() <= Date.now();
return apiSuccess({
connected: true,
configured,
lastSyncedAt: connection.lastSyncedAt,
connectedAt: connection.createdAt,
tokenExpired,
tokenExpiresAt: connection.tokenExpiresAt,
backfillCompleted: !!connection.backfillCompletedAt,
needsReauth: connection.needsReauth,
scope: connection.scope,
});
});
+60
View File
@@ -0,0 +1,60 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserGoogleHealth } from "@/lib/google-health/sync";
import { NextRequest } from "next/server";
/**
* Manually trigger a Google Health sync for the current user (v1.26.0).
*
* Mirrors the Fitbit manual-sync route: incremental by default, full history
* when `{ fullSync: true }` is posted.
*
* Rate-limited: a baseline 5/60s bucket gates the route, and the expensive
* `fullSync` path (which drives paginated Google Health walkers across every
* data type, each capped at 1000 pages) carries a tighter 1/hour bucket of its
* own so a re-trigger loop cannot pin Prisma or churn the Google Health quota.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "google_health.sync" } });
const rl = await checkRateLimit(`google-health-sync:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many sync requests", 429, {
errorCode: "rate_limited_self",
});
}
let fullSync = false;
try {
const raw = await request.text();
// Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
fullSync = body?.fullSync === true;
} catch {
// no body provided -> default incremental sync
}
// The full-history walk is the expensive path; cap it well below the
// incremental bucket so a re-trigger loop cannot churn the Google quota.
if (fullSync) {
const fullRl = await checkRateLimit(
`google-health-sync-full:${user.id}`,
1,
60 * 60_000,
);
if (!fullRl.allowed) {
return apiError("Full sync is limited to once per hour", 429, {
errorCode: "rate_limited_self",
});
}
}
const imported = await syncUserGoogleHealth(user.id, { fullSync });
return apiSuccess({ imported, fullSync });
});