refactor(integrations): start and end every OAuth connection through the kit

Minting the state, building the consent URL and dropping a grant each had a
copy per provider route. The routes keep their auth, rate limits and
redirects and hand the rest to one implementation, which the managed-profile
routes reuse.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 19:49:06 +02:00
1 parent b60fa0123b
commit 65e0cba0bf
14 files changed
+483 -344

No files matched your search

@@ -0,0 +1,240 @@
/**
* Starting an OAuth connect: resolve the record's provider app, mint the
* state, and build the consent URL. One implementation for every way a
* connect starts, the person's own browser navigation (`GET
* /api/<provider>/connect`) and a guardian's request for a managed profile
* (`POST /api/record-settings/integrations/<provider>/connect`).
*
* The state names exactly one record, `userId`, and is either HMAC-signed
* (Oura, Polar, Strava; `src/lib/oauth/signed-state.ts`) or a ten-minute
* server-side ledger row (Withings, WHOOP, Google Health). It is set twice:
* as the `state` URL parameter and as an httpOnly cookie, and the callback
* requires both to agree before anything else.
*/
import type { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { getEvent } from "@/lib/logging/context";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
import { getAuthorizationUrl as withingsAuthorizationUrl } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import {
WITHINGS_OAUTH_STATE_COOKIE,
WITHINGS_OAUTH_STATE_TTL_MS,
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
import { getAuthorizationUrl as whoopAuthorizationUrl } from "@/lib/whoop/client";
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
import {
WHOOP_OAUTH_STATE_COOKIE,
WHOOP_OAUTH_STATE_TTL_MS,
mintWhoopOAuthStateNonce,
} from "@/lib/whoop/oauth-state";
import {
generatePkcePair,
getAuthorizationUrl as googleHealthAuthorizationUrl,
} 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 { getAuthorizationUrl as ouraAuthorizationUrl } from "@/lib/oura/client";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getAuthorizationUrl as polarAuthorizationUrl } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getAuthorizationUrl as stravaAuthorizationUrl } from "@/lib/strava/client";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import type { OAuthKitProvider } from "./token";
export interface ConnectStateCookie {
readonly name: string;
readonly value: string;
readonly maxAgeSeconds: number;
}
export type ConnectStartResult =
| {
readonly ok: true;
readonly authorizationUrl: string;
readonly cookie: ConnectStateCookie;
}
| {
readonly ok: false;
/** `nocreds`: no provider app; `connect`: the state could not be stored. */
readonly reason: "nocreds" | "connect";
};
export interface ConnectStartOptions {
/** WHOOP only: a validated native return scheme, carried on the ledger row. */
readonly returnScheme?: string | null;
}
/** Can the record resolve a provider app (its own or the deployment's)? */
export async function recordHasClientApp(
provider: OAuthKitProvider,
userId: string,
): Promise<boolean> {
switch (provider) {
case "withings":
return (await getUserWithingsCredentials(userId)) !== null;
case "whoop":
return (await getUserWhoopCredentials(userId)) !== null;
case "google-health":
return (await getUserGoogleHealthCredentials(userId)) !== null;
case "oura":
return (await getOuraClientCredentials(userId)) !== null;
case "polar":
return (await getPolarClientCredentials(userId)) !== null;
case "strava":
return (await getStravaClientCredentials(userId)) !== null;
}
}
function signedStart(
provider: "oura" | "polar" | "strava",
userId: string,
url: (state: string) => string,
): ConnectStartResult {
const state = mintSignedState(provider, userId);
return {
ok: true,
authorizationUrl: url(state),
cookie: {
name: oauthStateCookieName(provider),
value: state,
maxAgeSeconds: Math.floor(OAUTH_STATE_TTL_MS / 1000),
},
};
}
async function ledgerStart(
write: () => Promise<unknown>,
cookie: ConnectStateCookie,
url: () => string,
): Promise<ConnectStartResult> {
try {
await write();
} catch (err) {
getEvent()?.setError(err);
return { ok: false, reason: "connect" };
}
return { ok: true, authorizationUrl: url(), cookie };
}
/** Mint the state for `userId` and build the provider's consent URL. */
export async function startOAuthConnect(
provider: OAuthKitProvider,
userId: string,
opts: ConnectStartOptions = {},
): Promise<ConnectStartResult> {
switch (provider) {
case "withings": {
const creds = await getUserWithingsCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
const nonce = mintWithingsOAuthStateNonce();
return ledgerStart(
() =>
prisma.withingsOAuthState.create({
data: {
nonce,
userId,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
}),
{
name: WITHINGS_OAUTH_STATE_COOKIE,
value: nonce,
maxAgeSeconds: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000),
},
() => withingsAuthorizationUrl(nonce, creds),
);
}
case "whoop": {
const creds = await getUserWhoopCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
const nonce = mintWhoopOAuthStateNonce();
return ledgerStart(
() =>
prisma.whoopOAuthState.create({
data: {
nonce,
userId,
expiresAt: new Date(Date.now() + WHOOP_OAUTH_STATE_TTL_MS),
returnScheme: opts.returnScheme ?? null,
},
}),
{
name: WHOOP_OAUTH_STATE_COOKIE,
value: nonce,
maxAgeSeconds: Math.floor(WHOOP_OAUTH_STATE_TTL_MS / 1000),
},
() => whoopAuthorizationUrl(nonce, creds),
);
}
case "google-health": {
const creds = await getUserGoogleHealthCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
const nonce = mintGoogleHealthOAuthStateNonce();
const { verifier, challenge } = generatePkcePair();
return ledgerStart(
() =>
prisma.googleHealthOAuthState.create({
data: {
nonce,
userId,
codeVerifier: verifier,
expiresAt: new Date(
Date.now() + GOOGLE_HEALTH_OAUTH_STATE_TTL_MS,
),
},
}),
{
name: GOOGLE_HEALTH_OAUTH_STATE_COOKIE,
value: nonce,
maxAgeSeconds: Math.floor(GOOGLE_HEALTH_OAUTH_STATE_TTL_MS / 1000),
},
() => googleHealthAuthorizationUrl(nonce, creds, challenge),
);
}
case "oura": {
const creds = await getOuraClientCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
return signedStart("oura", userId, (s) => ouraAuthorizationUrl(s, creds));
}
case "polar": {
const creds = await getPolarClientCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
return signedStart("polar", userId, (s) =>
polarAuthorizationUrl(s, creds),
);
}
case "strava": {
const creds = await getStravaClientCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
return signedStart("strava", userId, (s) =>
stravaAuthorizationUrl(s, creds),
);
}
}
}
/** Set the state cookie a started connect needs on its response. */
export function setConnectStateCookie(
response: NextResponse,
cookie: ConnectStateCookie,
): NextResponse {
response.cookies.set(cookie.name, cookie.value, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: cookie.maxAgeSeconds,
path: "/",
});
return response;
}
@@ -0,0 +1,144 @@
/**
* Ending an OAuth connection: tell the provider where it listens, drop the
* grant, audit it, and park the integration at `disconnected`. One
* implementation for the person's own disconnect (`POST
* /api/<provider>/disconnect`) and a guardian's for a managed profile
* (`POST /api/record-settings/integrations/<provider>/disconnect`). Imported
* data stays; a reconnect resumes the sync.
*
* The audit row is filed under the record; when a guardian acts, `auditLog`
* reads the guardian off the request and records them as the actor.
*/
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { auditLog } from "@/lib/auth/audit";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
import { unsubscribeWebhook } from "@/lib/withings/client";
import {
WITHINGS_NOTIFY_APPLIS,
getWithingsWebhookCallbackUrl,
} from "@/lib/withings/sync";
import { deauthorize } from "@/lib/strava/client";
import {
STRAVA_DISCONNECTED,
getStravaConnection,
} from "@/lib/strava/credentials";
import type { OAuthKitProvider } from "./token";
export type DisconnectOutcome = "disconnected" | "not_connected";
/** The audit action each provider has always filed a disconnect under. */
const AUDIT_ACTION: Record<OAuthKitProvider, string> = {
withings: "withings.disconnect",
whoop: "whoop.disconnect",
"google-health": "google_health.disconnect",
oura: "oura.disconnect",
polar: "polar.disconnect",
strava: "strava.disconnect",
};
async function dropGrant(
provider: OAuthKitProvider,
userId: string,
): Promise<boolean> {
switch (provider) {
case "withings": {
const connection = await prisma.withingsConnection.findUnique({
where: { userId },
});
if (!connection) return false;
// Withings keeps calling a subscribed URL after the grant is gone, so
// the subscriptions go first. Best effort: a refused unsubscribe must
// not keep the person connected.
try {
const accessToken = decrypt(connection.accessToken);
const callbackUrl = getWithingsWebhookCallbackUrl();
for (const appli of WITHINGS_NOTIFY_APPLIS) {
try {
await unsubscribeWebhook(accessToken, callbackUrl, appli);
} catch {
// Already gone, or the token expired; the delete below stands.
}
}
} catch {
// An undecryptable token cannot unsubscribe anything.
}
await prisma.withingsConnection.delete({ where: { userId } });
return true;
}
case "whoop": {
const connection = await prisma.whoopConnection.findUnique({
where: { userId },
});
if (!connection) return false;
await prisma.whoopConnection.delete({ where: { userId } });
return true;
}
case "google-health": {
const connection = await prisma.googleHealthConnection.findUnique({
where: { userId },
});
if (!connection) return false;
await prisma.googleHealthConnection.delete({ where: { userId } });
return true;
}
case "oura": {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { ouraAccessTokenEncrypted: true },
});
if (!row?.ouraAccessTokenEncrypted) return false;
await prisma.user.update({
where: { id: userId },
data: {
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
ouraScopeDenied: Prisma.DbNull,
},
});
return true;
}
case "polar": {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { polarAccessTokenEncrypted: true },
});
if (!row?.polarAccessTokenEncrypted) return false;
await prisma.user.update({
where: { id: userId },
data: { polarAccessTokenEncrypted: null, polarUserIdEncrypted: null },
});
return true;
}
case "strava": {
const connection = await getStravaConnection(userId);
if (!connection) return false;
// Strava keeps the app authorised until it is told otherwise.
try {
await deauthorize(connection.accessToken);
} catch (err) {
getEvent()?.addWarning(
`strava: deauthorize failed on disconnect: ${err}`,
);
}
await prisma.user.update({
where: { id: userId },
data: STRAVA_DISCONNECTED,
});
return true;
}
}
}
/** Disconnect `provider` from the record `userId`. */
export async function disconnectOAuth(
provider: OAuthKitProvider,
userId: string,
): Promise<DisconnectOutcome> {
if (!(await dropGrant(provider, userId))) return "not_connected";
await auditLog(AUDIT_ACTION[provider], { userId });
await markDisconnected(userId, provider);
return "disconnected";
}