diff --git a/src/app/api/google-health/connect/route.ts b/src/app/api/google-health/connect/route.ts index f22cf1e73..a4c638c3d 100644 --- a/src/app/api/google-health/connect/route.ts +++ b/src/app/api/google-health/connect/route.ts @@ -1,20 +1,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; import { apiError } from "@/lib/api-response"; -import { prisma } from "@/lib/db"; -import { annotate, getEvent } from "@/lib/logging/context"; +import { annotate } 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 @@ -55,27 +47,14 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - 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); + const started = await startOAuthConnect("google-health", user.id); + if (!started.ok) { + if (started.reason === "nocreds") { + return apiError( + "Please configure your Google OAuth Client ID and Client Secret in Settings first.", + 400, + ); + } annotate({ action: { name: "google_health.connect.create_failed" } }); return NextResponse.redirect( new URL( @@ -85,16 +64,8 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - 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; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/google-health/disconnect/route.ts b/src/app/api/google-health/disconnect/route.ts index 41c5fbba5..0d9120fe4 100644 --- a/src/app/api/google-health/disconnect/route.ts +++ b/src/app/api/google-health/disconnect/route.ts @@ -1,9 +1,7 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; 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). @@ -20,23 +18,9 @@ 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) { + if ((await disconnectOAuth("google-health", user.id)) === "not_connected") { 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 }); }); diff --git a/src/app/api/oura/connect/route.ts b/src/app/api/oura/connect/route.ts index f56807831..4c7ce25d9 100644 --- a/src/app/api/oura/connect/route.ts +++ b/src/app/api/oura/connect/route.ts @@ -1,14 +1,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/oura/client"; -import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { - OAUTH_STATE_TTL_MS, - mintSignedState, - oauthStateCookieName, -} from "@/lib/oauth/signed-state"; + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; /** @@ -45,8 +41,8 @@ export const GET = apiHandler(async () => { ); } - const creds = await getOuraClientCredentials(user.id); - if (!creds) { + const started = await startOAuthConnect("oura", user.id); + if (!started.ok) { annotate({ action: { name: "oura.connect.no_credentials" } }); return NextResponse.redirect( new URL( @@ -56,16 +52,8 @@ export const GET = apiHandler(async () => { ); } - const state = mintSignedState("oura", user.id); - const url = getAuthorizationUrl(state, creds); - - const response = NextResponse.redirect(url); - response.cookies.set(oauthStateCookieName("oura"), state, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/oura/disconnect/route.ts b/src/app/api/oura/disconnect/route.ts index 91ae0804b..e2aca6855 100644 --- a/src/app/api/oura/disconnect/route.ts +++ b/src/app/api/oura/disconnect/route.ts @@ -1,11 +1,8 @@ -import { prisma } from "@/lib/db"; -import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { markDisconnected } from "@/lib/integrations/status"; /** * v1.17.0 — disconnect Oura for the current user. @@ -25,25 +22,9 @@ export const POST = apiHandler(async () => { }); } - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { ouraAccessTokenEncrypted: true }, - }); - if (!dbUser?.ouraAccessTokenEncrypted) { + if ((await disconnectOAuth("oura", user.id)) === "not_connected") { return apiError("No Oura connection", 404); } - await prisma.user.update({ - where: { id: user.id }, - data: { - ouraAccessTokenEncrypted: null, - ouraRefreshTokenEncrypted: null, - ouraScopeDenied: Prisma.DbNull, - }, - }); - - await auditLog("oura.disconnect", { userId: user.id }); - await markDisconnected(user.id, "oura"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts index 84b205804..5bd3fd7b4 100644 --- a/src/app/api/polar/connect/route.ts +++ b/src/app/api/polar/connect/route.ts @@ -1,14 +1,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/polar/client"; -import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { - OAUTH_STATE_TTL_MS, - mintSignedState, - oauthStateCookieName, -} from "@/lib/oauth/signed-state"; + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; /** @@ -48,8 +44,8 @@ export const GET = apiHandler(async () => { ); } - const creds = await getPolarClientCredentials(user.id); - if (!creds) { + const started = await startOAuthConnect("polar", user.id); + if (!started.ok) { annotate({ action: { name: "polar.connect.no_credentials" } }); return NextResponse.redirect( new URL( @@ -59,16 +55,8 @@ export const GET = apiHandler(async () => { ); } - const state = mintSignedState("polar", user.id); - const url = getAuthorizationUrl(state, creds); - - const response = NextResponse.redirect(url); - response.cookies.set(oauthStateCookieName("polar"), state, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/polar/disconnect/route.ts b/src/app/api/polar/disconnect/route.ts index 76b41e1a9..e1f8eb510 100644 --- a/src/app/api/polar/disconnect/route.ts +++ b/src/app/api/polar/disconnect/route.ts @@ -1,10 +1,8 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { markDisconnected } from "@/lib/integrations/status"; /** * v1.17.0 — disconnect Polar for the current user. @@ -24,24 +22,9 @@ export const POST = apiHandler(async () => { }); } - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { polarAccessTokenEncrypted: true }, - }); - if (!dbUser?.polarAccessTokenEncrypted) { + if ((await disconnectOAuth("polar", user.id)) === "not_connected") { return apiError("No Polar connection", 404); } - await prisma.user.update({ - where: { id: user.id }, - data: { - polarAccessTokenEncrypted: null, - polarUserIdEncrypted: null, - }, - }); - - await auditLog("polar.disconnect", { userId: user.id }); - await markDisconnected(user.id, "polar"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/app/api/strava/connect/route.ts b/src/app/api/strava/connect/route.ts index c76a61786..1badb402f 100644 --- a/src/app/api/strava/connect/route.ts +++ b/src/app/api/strava/connect/route.ts @@ -1,14 +1,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/strava/client"; -import { getStravaClientCredentials } from "@/lib/strava/credentials"; import { - OAUTH_STATE_TTL_MS, - mintSignedState, - oauthStateCookieName, -} from "@/lib/oauth/signed-state"; + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; /** @@ -45,8 +41,8 @@ export const GET = apiHandler(async () => { ); } - const creds = await getStravaClientCredentials(user.id); - if (!creds) { + const started = await startOAuthConnect("strava", user.id); + if (!started.ok) { annotate({ action: { name: "strava.connect.no_credentials" } }); return NextResponse.redirect( new URL( @@ -56,16 +52,8 @@ export const GET = apiHandler(async () => { ); } - const state = mintSignedState("strava", user.id); - const url = getAuthorizationUrl(state, creds); - - const response = NextResponse.redirect(url); - response.cookies.set(oauthStateCookieName("strava"), state, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/strava/disconnect/route.ts b/src/app/api/strava/disconnect/route.ts index e3634b330..e52813323 100644 --- a/src/app/api/strava/disconnect/route.ts +++ b/src/app/api/strava/disconnect/route.ts @@ -1,15 +1,8 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; -import { annotate, getEvent } from "@/lib/logging/context"; +import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { markDisconnected } from "@/lib/integrations/status"; -import { deauthorize } from "@/lib/strava/client"; -import { - getStravaConnection, - STRAVA_DISCONNECTED, -} from "@/lib/strava/credentials"; /** * v1.28.x — disconnect Strava for the current user. @@ -30,26 +23,9 @@ export const POST = apiHandler(async () => { }); } - const connection = await getStravaConnection(user.id); - if (!connection) { + if ((await disconnectOAuth("strava", user.id)) === "not_connected") { return apiError("No Strava connection", 404); } - // Best-effort revoke at Strava; a Strava-side outage must never block the - // local disconnect. - try { - await deauthorize(connection.accessToken); - } catch (err) { - getEvent()?.addWarning(`strava: deauthorize failed on disconnect: ${err}`); - } - - await prisma.user.update({ - where: { id: user.id }, - data: STRAVA_DISCONNECTED, - }); - - await auditLog("strava.disconnect", { userId: user.id }); - await markDisconnected(user.id, "strava"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/app/api/whoop/connect/route.ts b/src/app/api/whoop/connect/route.ts index 975ecad7e..6f8643d05 100644 --- a/src/app/api/whoop/connect/route.ts +++ b/src/app/api/whoop/connect/route.ts @@ -1,20 +1,15 @@ 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/whoop/client"; -import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; -import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; import { - WHOOP_OAUTH_STATE_COOKIE, - WHOOP_OAUTH_STATE_TTL_MS, - mintWhoopOAuthStateNonce, -} from "@/lib/whoop/oauth-state"; + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; import { validateReturnScheme } from "@/lib/whoop/return-scheme"; import { NextRequest, NextResponse } from "next/server"; import { relativeRedirect } from "@/lib/http/relative-redirect"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** * Redirect the user to the WHOOP OAuth authorization page (v1.11.0; native @@ -91,47 +86,27 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const creds = await getUserWhoopCredentials(userId); - if (!creds) { - annotate({ action: { name: "whoop.connect.no_credentials" } }); - return relativeRedirect( - "/settings/integrations?whoop=error&reason=nocreds", - ); - } - // Validate the optional native return scheme against the strict allowlist. // An invalid/absent value resolves to null → the callback uses the web // redirect (never an arbitrary reflected scheme). const returnScheme = validateReturnScheme(searchParams.get("return_scheme")); - const nonce = mintWhoopOAuthStateNonce(); - try { - await prisma.whoopOAuthState.create({ - data: { - nonce, - userId, - expiresAt: new Date(Date.now() + WHOOP_OAUTH_STATE_TTL_MS), - returnScheme, - }, - }); - } catch (err) { - getEvent()?.setError(err); + const started = await startOAuthConnect("whoop", userId, { returnScheme }); + if (!started.ok) { + if (started.reason === "nocreds") { + annotate({ action: { name: "whoop.connect.no_credentials" } }); + return relativeRedirect( + "/settings/integrations?whoop=error&reason=nocreds", + ); + } annotate({ action: { name: "whoop.connect.create_failed" } }); return relativeRedirect( "/settings/integrations?whoop=error&reason=connect", ); } - const url = getAuthorizationUrl(nonce, creds); - - const response = NextResponse.redirect(url); - response.cookies.set(WHOOP_OAUTH_STATE_COOKIE, nonce, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(WHOOP_OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/whoop/disconnect/route.ts b/src/app/api/whoop/disconnect/route.ts index 36f37ea8e..2be5845ac 100644 --- a/src/app/api/whoop/disconnect/route.ts +++ b/src/app/api/whoop/disconnect/route.ts @@ -1,9 +1,7 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { markDisconnected } from "@/lib/integrations/status"; /** * Disconnect the WHOOP integration for the current user (v1.11.0). @@ -20,23 +18,9 @@ export const POST = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "whoop.disconnect" } }); - const connection = await prisma.whoopConnection.findUnique({ - where: { userId: user.id }, - }); - - if (!connection) { + if ((await disconnectOAuth("whoop", user.id)) === "not_connected") { return apiError("No WHOOP connection", 404); } - await prisma.whoopConnection.delete({ - where: { userId: user.id }, - }); - - await auditLog("whoop.disconnect", { - userId: user.id, - }); - - await markDisconnected(user.id, "whoop"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/app/api/withings/connect/route.ts b/src/app/api/withings/connect/route.ts index 689c2184f..ee2b7b0c0 100644 --- a/src/app/api/withings/connect/route.ts +++ b/src/app/api/withings/connect/route.ts @@ -1,17 +1,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { prisma } from "@/lib/db"; -import { annotate, getEvent } from "@/lib/logging/context"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } 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"; + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest, NextResponse } from "next/server"; import { relativeRedirect } from "@/lib/http/relative-redirect"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** * Redirects the user to Withings OAuth authorization page. @@ -62,41 +57,22 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const creds = await getUserWithingsCredentials(user.id); - if (!creds) { - annotate({ action: { name: "withings.connect.no_credentials" } }); - return relativeRedirect( - "/settings/integrations?withings=error&reason=nocreds", - ); - } - - const nonce = mintWithingsOAuthStateNonce(); - try { - await prisma.withingsOAuthState.create({ - data: { - nonce, - userId: user.id, - expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS), - }, - }); - } catch (err) { - getEvent()?.setError(err); + const started = await startOAuthConnect("withings", user.id); + if (!started.ok) { + if (started.reason === "nocreds") { + annotate({ action: { name: "withings.connect.no_credentials" } }); + return relativeRedirect( + "/settings/integrations?withings=error&reason=nocreds", + ); + } annotate({ action: { name: "withings.connect.create_failed" } }); return relativeRedirect( "/settings/integrations?withings=error&reason=connect", ); } - const url = getAuthorizationUrl(nonce, creds); - - const response = NextResponse.redirect(url); - response.cookies.set(WITHINGS_OAUTH_STATE_COOKIE, nonce, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/withings/disconnect/route.ts b/src/app/api/withings/disconnect/route.ts index 109fa6c65..e8dd58e18 100644 --- a/src/app/api/withings/disconnect/route.ts +++ b/src/app/api/withings/disconnect/route.ts @@ -1,15 +1,7 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { decrypt } from "@/lib/crypto"; -import { unsubscribeWebhook } from "@/lib/withings/client"; -import { - WITHINGS_NOTIFY_APPLIS, - getWithingsWebhookCallbackUrl, -} from "@/lib/withings/sync"; -import { markDisconnected } from "@/lib/integrations/status"; /** * Disconnect Withings integration for the current user. @@ -18,40 +10,9 @@ export const POST = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "withings.disconnect" } }); - const connection = await prisma.withingsConnection.findUnique({ - where: { userId: user.id }, - }); - - if (!connection) { + if ((await disconnectOAuth("withings", user.id)) === "not_connected") { return apiError("No Withings connection", 404); } - // Best-effort unsubscribe across every appli category we subscribed - // to in setupWebhook. A token-expired failure on one category should - // not stop us from cleaning up the others. - try { - const accessToken = decrypt(connection.accessToken); - const callbackUrl = getWithingsWebhookCallbackUrl(); - for (const appli of WITHINGS_NOTIFY_APPLIS) { - try { - await unsubscribeWebhook(accessToken, callbackUrl, appli); - } catch { - // Ignore — token might be expired or the appli was never subscribed. - } - } - } catch { - // Ignore — token might be undecryptable. - } - - await prisma.withingsConnection.delete({ - where: { userId: user.id }, - }); - - await auditLog("withings.disconnect", { - userId: user.id, - }); - - await markDisconnected(user.id, "withings"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/lib/integrations/oauth-kit/connect-start.ts b/src/lib/integrations/oauth-kit/connect-start.ts new file mode 100644 index 000000000..068c9e764 --- /dev/null +++ b/src/lib/integrations/oauth-kit/connect-start.ts @@ -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//connect`) and a guardian's request for a managed profile + * (`POST /api/record-settings/integrations//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 { + 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, + cookie: ConnectStateCookie, + url: () => string, +): Promise { + 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 { + 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; +} diff --git a/src/lib/integrations/oauth-kit/disconnect.ts b/src/lib/integrations/oauth-kit/disconnect.ts new file mode 100644 index 000000000..b4393f9eb --- /dev/null +++ b/src/lib/integrations/oauth-kit/disconnect.ts @@ -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//disconnect`) and a guardian's for a managed profile + * (`POST /api/record-settings/integrations//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 = { + 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 { + 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 { + if (!(await dropGrant(provider, userId))) return "not_connected"; + await auditLog(AUDIT_ACTION[provider], { userId }); + await markDisconnected(userId, provider); + return "disconnected"; +}