mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
b60fa0123b
commit
65e0cba0bf
14 files changed
+483
-344
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user