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

+17 -46
View File
@@ -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,
);
});
+2 -18
View File
@@ -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 });
});
+11 -23
View File
@@ -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,
);
});
+2 -21
View File
@@ -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 });
});
+11 -23
View File
@@ -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,
);
});
+2 -19
View File
@@ -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 });
});
+11 -23
View File
@@ -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,
);
});
+3 -27
View File
@@ -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 });
});
+19 -44
View File
@@ -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,
);
});
+2 -18
View File
@@ -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 });
});
+17 -41
View File
@@ -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,
);
});
+2 -41
View File
@@ -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 });
});