feat(integrations): let a guardian connect a managed profile's providers

A guardian acting on a managed profile can now connect and disconnect its
Withings, WHOOP, Oura, Polar, Strava and Google Health from the profile's
integration cards, and each card says whose record it connects for. The
connect starts with a fenced fetch that mints a state naming the profile;
the provider's callback lands tokens and data there only after checking
that the browser belongs to a guardian with a live MANAGE grant on it.
Without a provider app of its own, the profile borrows the guardian's,
with an audit row. Withings and Google Health callbacks read the session
directly, since a provider redirect cannot carry the record-session header.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 19:49:07 +02:00
1 parent 65e0cba0bf
commit 74ce17b7de
35 files changed
+1392 -158

No files matched your search

+26 -11
View File
@@ -1,5 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, AUTH_ERROR_CODES, HttpError } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
@@ -57,7 +59,15 @@ const ERR = (reason: string) =>
);
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// The person in the browser, read off the session itself rather than
// through a record resolver: a provider redirect is a navigation, it cannot
// carry the record-session header, and the record is the one the state row
// names, checked against this person below. A session is still required.
const sessionData = await getSession();
if (!sessionData) {
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
}
const actor = sessionData.user;
annotate({ action: { name: "google_health.callback" } });
const { searchParams } = new URL(request.url);
@@ -125,8 +135,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ meta: { reason: "expired" } });
return ERR("expired");
}
if (consumed.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
// The record the connect started for; a guardian may finish it for a
// managed profile it holds a live MANAGE grant on.
const recordUserId = consumed.userId;
const actorCheck = await verifyCallbackActor(recordUserId, actor.id);
if (!actorCheck.ok) {
annotate({ meta: { reason: actorCheck.reason } });
return ERR("cross_user");
}
if (!consumed.codeVerifier) {
@@ -139,7 +153,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
try {
const creds = await getUserGoogleHealthCredentials(user.id);
const creds = await getUserGoogleHealthCredentials(recordUserId);
if (!creds) {
return ERR("nocreds");
}
@@ -154,7 +168,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// so the user re-consents rather than landing a dead connection.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
`google-health callback: token response carried no refresh_token for user ${user.id}`,
`google-health callback: token response carried no refresh_token for user ${recordUserId}`,
);
annotate({ meta: { reason: "no_refresh_token" } });
return ERR("no_refresh_token");
@@ -166,7 +180,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const scope = tokens.scope ?? getGoogleHealthScopeString();
await prisma.googleHealthConnection.upsert({
where: { userId: user.id },
where: { userId: recordUserId },
update: {
googleUserId,
accessToken: encrypt(tokens.access_token),
@@ -182,7 +196,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
backfillProgress: Prisma.DbNull,
},
create: {
userId: user.id,
userId: recordUserId,
googleUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
@@ -193,14 +207,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
await auditLog("google_health.connect", {
userId: user.id,
userId: recordUserId,
actorUserId: actorCheck.guardianUserId,
details: { googleUserId },
});
// Re-completing OAuth clears any prior reauth-required state on the status
// ledger too (the `needsReauth` column above is the connection-local flag;
// this resets the integration-status streak).
await markReconnected(user.id, "google-health");
await markReconnected(recordUserId, "google-health");
// Enqueue the self-converging history backfill. Best-effort: the boot-time
// discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a
@@ -209,7 +224,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (boss) {
await boss
.send(GOOGLE_HEALTH_BACKFILL_QUEUE, {
userId: user.id,
userId: recordUserId,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
@@ -24,6 +24,15 @@ vi.mock("@/lib/oauth/callback-claim", () => ({
duplicateOAuthCallback: async () => null,
}));
vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({
// The guardian arm is covered in its own test; here the record's own
// browser (or none) may finish, any other session may not.
verifyCallbackActor: async (record: string, session: string | null) =>
session === null || session === record
? { ok: true, guardianUserId: null }
: { ok: false, reason: "cross_user" },
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
@@ -166,7 +175,10 @@ describe("GET /api/oura/callback", () => {
const data = userUpdate.mock.calls[0]![0].data;
expect(data.ouraAccessTokenEncrypted).toBe("enc:at");
expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt");
expect(auditLog).toHaveBeenCalledWith("oura.connect", { userId: "u1" });
expect(auditLog).toHaveBeenCalledWith("oura.connect", {
userId: "u1",
actorUserId: null,
});
expect(res.headers.get("location")).toContain("oura=connected");
});
});
+16 -4
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
@@ -80,11 +81,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
const evt = getEvent();
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
// The browser finishing the flow must be the record's own, or, for a
// managed profile, a guardian's with a live MANAGE grant on it.
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
const actorCheck = await verifyCallbackActor(
userId,
sessionData?.user.id ?? null,
);
if (!actorCheck.ok) {
annotate({ meta: { reason: actorCheck.reason } });
return ERR("cross_user");
}
const guardianUserId = actorCheck.guardianUserId;
if (!code) return ERR("nocode");
@@ -104,12 +112,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
fallbackUrl: SETTINGS_URL(),
stateCookie: oauthStateCookieName("oura"),
},
() => completeCallback(userId, code, iss),
() => completeCallback(userId, guardianUserId, code, iss),
);
});
async function completeCallback(
userId: string,
guardianUserId: string | null,
code: string,
iss: string | null,
): Promise<NextResponse> {
@@ -137,7 +146,10 @@ async function completeCallback(
},
});
await auditLog("oura.connect", { userId });
await auditLog("oura.connect", {
userId,
actorUserId: guardianUserId,
});
await markReconnected(userId, "oura");
// Start the first sync now instead of leaving the user staring at an empty
@@ -46,6 +46,15 @@ vi.mock("@/lib/oauth/callback-claim", () => ({
duplicateOAuthCallback: async () => null,
}));
vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({
// The guardian arm is covered in its own test; here the record's own
// browser (or none) may finish, any other session may not.
verifyCallbackActor: async (record: string, session: string | null) =>
session === null || session === record
? { ok: true, guardianUserId: null }
: { ok: false, reason: "cross_user" },
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
+16 -4
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
@@ -70,11 +71,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
// Optional session cross-check when a web session is present.
// The browser finishing the flow must be the record's own, or, for a
// managed profile, a guardian's with a live MANAGE grant on it.
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
const actorCheck = await verifyCallbackActor(
userId,
sessionData?.user.id ?? null,
);
if (!actorCheck.ok) {
annotate({ meta: { reason: actorCheck.reason } });
return ERR("cross_user");
}
const guardianUserId = actorCheck.guardianUserId;
if (!code) return ERR("nocode");
@@ -90,12 +98,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
).toString(),
stateCookie: oauthStateCookieName("polar"),
},
() => completeCallback(userId, code),
() => completeCallback(userId, guardianUserId, code),
);
});
async function completeCallback(
userId: string,
guardianUserId: string | null,
code: string,
): Promise<NextResponse> {
try {
@@ -126,7 +135,10 @@ async function completeCallback(
},
});
await auditLog("polar.connect", { userId });
await auditLog("polar.connect", {
userId,
actorUserId: guardianUserId,
});
await markReconnected(userId, "polar");
// Start the first sync now instead of leaving the user staring at an empty
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NextRequest } from "next/server";
const m = vi.hoisted(() => ({
requireGuardianAuth: vi.fn(),
access: vi.fn(),
share: vi.fn(async () => undefined),
hasApp: vi.fn(async () => true),
start: vi.fn(),
disconnect: vi.fn(),
rate: vi.fn(async () => ({ allowed: true })),
audit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireGuardianAuth: m.requireGuardianAuth,
SharingAccessDeniedError: class SharingAccessDeniedError extends Error {},
}));
vi.mock("@/lib/record-settings", () => ({
resolveGuardianRecordSettingsAccess: m.access,
}));
vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({
shareGuardianClientApp: m.share,
}));
vi.mock("@/lib/integrations/oauth-kit/connect-start", async (orig) => ({
...(await orig<
typeof import("@/lib/integrations/oauth-kit/connect-start")
>()),
recordHasClientApp: m.hasApp,
startOAuthConnect: m.start,
}));
vi.mock("@/lib/integrations/oauth-kit/disconnect", () => ({
disconnectOAuth: m.disconnect,
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: m.rate,
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: m.audit }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
import { POST as connect } from "../connect/route";
import { POST as disconnect } from "../disconnect/route";
const params = (provider: string) => ({
params: Promise.resolve({ provider }),
});
const req = {} as NextRequest;
const GUARDED = {
actorId: "guardian",
recordId: "profile",
recordKind: "managed",
relationship: "guardian",
};
beforeEach(() => {
vi.clearAllMocks();
m.requireGuardianAuth.mockResolvedValue({});
m.access.mockReturnValue(GUARDED);
m.rate.mockResolvedValue({ allowed: true });
});
describe("POST /api/record-settings/integrations/[provider]/connect", () => {
it("refuses a provider outside the set before authenticating", async () => {
const res = await connect(req, params("fitbit"));
expect(res.status).toBe(404);
expect(m.requireGuardianAuth).not.toHaveBeenCalled();
});
it("refuses a caller who is not a guardian of a managed record", async () => {
m.access.mockReturnValue(null);
await expect(connect(req, params("oura"))).rejects.toThrow();
expect(m.start).not.toHaveBeenCalled();
});
it("mints the state for the managed record and answers with the consent URL", async () => {
m.start.mockResolvedValue({
ok: true,
authorizationUrl: "https://cloud.ouraring.com/oauth/authorize?state=s",
cookie: { name: "oura_oauth_state", value: "s", maxAgeSeconds: 600 },
});
const res = await connect(req, params("oura"));
expect(m.share).toHaveBeenCalledWith(
"oura",
{ recordUserId: "profile", actorUserId: "guardian" },
expect.any(Function),
);
expect(m.start).toHaveBeenCalledWith("oura", "profile");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({
recordId: "profile",
provider: "oura",
authorizationUrl: "https://cloud.ouraring.com/oauth/authorize?state=s",
});
expect(res.headers.get("set-cookie")).toContain("oura_oauth_state=s");
expect(res.headers.get("set-cookie")?.toLowerCase()).toContain("httponly");
expect(m.audit).toHaveBeenCalledWith("integration.connect.started", {
userId: "profile",
details: { provider: "oura" },
});
});
it("answers 409 when no provider app can be found", async () => {
m.start.mockResolvedValue({ ok: false, reason: "nocreds" });
const res = await connect(req, params("strava"));
expect(res.status).toBe(409);
});
it("is rate limited per record", async () => {
m.rate.mockResolvedValue({ allowed: false });
const res = await connect(req, params("withings"));
expect(res.status).toBe(429);
expect(m.rate).toHaveBeenCalledWith(
"withings:connect:profile",
expect.any(Number),
expect.any(Number),
);
});
});
describe("POST /api/record-settings/integrations/[provider]/disconnect", () => {
it("disconnects the managed record", async () => {
m.disconnect.mockResolvedValue("disconnected");
const res = await disconnect(req, params("google-health"));
expect(m.disconnect).toHaveBeenCalledWith("google-health", "profile");
expect(res.status).toBe(200);
});
it("answers 404 when the record is not connected", async () => {
m.disconnect.mockResolvedValue("not_connected");
const res = await disconnect(req, params("whoop"));
expect(res.status).toBe(404);
});
it("refuses a provider outside the set", async () => {
const res = await disconnect(req, params("nightscout"));
expect(res.status).toBe(404);
expect(m.disconnect).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
import type { NextRequest } from "next/server";
import {
apiHandler,
requireGuardianAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import {
recordHasClientApp,
setConnectStateCookie,
startOAuthConnect,
} from "@/lib/integrations/oauth-kit/connect-start";
import { shareGuardianClientApp } from "@/lib/integrations/oauth-kit/connect-target";
import { isManagedOAuthProvider } from "@/lib/integrations/oauth-kit/managed-provider";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ provider: string }> };
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
/**
* A guardian starts connecting a provider for the managed profile they are
* acting on.
*
* A fetch, not a navigation: the record-session fence needs the headers the
* app transport sends, which a browser navigation cannot carry. So this
* answers with the consent URL and sets the state cookie, and the page then
* sends the browser to the provider. The state names the profile; the
* provider's callback lands the tokens there after checking the browser is
* still a guardian of it (`verifyCallbackActor`).
*
* Without a provider app of its own, the profile borrows the guardian's.
*/
export const POST = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { provider } = await params;
if (!isManagedOAuthProvider(provider)) {
return apiError("Integration not found", 404);
}
const context = await requireGuardianAuth();
const access = resolveGuardianRecordSettingsAccess(context);
if (!access) throw new SharingAccessDeniedError();
annotate({
action: { name: "record-settings.integration.connect.start" },
meta: { provider },
});
const rl = await checkRateLimit(
`${provider}:connect:${access.recordId}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
await shareGuardianClientApp(
provider,
{ recordUserId: access.recordId, actorUserId: access.actorId },
() => recordHasClientApp(provider, access.recordId),
);
const started = await startOAuthConnect(provider, access.recordId);
if (!started.ok) {
return started.reason === "nocreds"
? apiError(
"Neither this profile nor your own account has an app for this provider.",
409,
)
: apiError("The connect could not be started.", 500);
}
await auditLog("integration.connect.started", {
userId: access.recordId,
details: { provider },
});
return setConnectStateCookie(
apiSuccess({
recordId: access.recordId,
provider,
authorizationUrl: started.authorizationUrl,
}),
started.cookie,
);
},
);
@@ -0,0 +1,61 @@
import type { NextRequest } from "next/server";
import {
apiHandler,
requireGuardianAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect";
import { isManagedOAuthProvider } from "@/lib/integrations/oauth-kit/managed-provider";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ provider: string }> };
/**
* A guardian disconnects a provider from the managed profile they are acting
* on, through the same disconnect a person runs for their own account
* (`disconnectOAuth`). The profile's imported data stays.
*/
export const POST = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { provider } = await params;
if (!isManagedOAuthProvider(provider)) {
return apiError("Integration not found", 404);
}
const context = await requireGuardianAuth();
const access = resolveGuardianRecordSettingsAccess(context);
if (!access) throw new SharingAccessDeniedError();
annotate({
action: { name: "record-settings.integration.disconnect" },
meta: { provider },
});
const rl = await checkRateLimit(
`${provider}-disconnect:${access.recordId}`,
20,
60_000,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
if (
(await disconnectOAuth(provider, access.recordId)) === "not_connected"
) {
return apiError("No connection", 404);
}
return apiSuccess({
recordId: access.recordId,
provider,
disconnected: true,
});
},
);
@@ -30,9 +30,11 @@ const MANAGED_INTEGRATIONS: readonly IntegrationKey[] = [
];
/**
* A managed profile may disclose whether its existing integrations are
* connected, but this endpoint deliberately has no control or credential
* projection. OAuth and provider state cannot safely carry a record selector.
* Whether a managed profile's integrations are connected and how each is
* doing. No credential projection. A guardian connects and disconnects the
* profile's OAuth providers through `./[provider]/connect` and
* `./[provider]/disconnect`, whose state names the profile itself rather than
* carrying a record selector.
*/
export const GET = apiHandler(async () => {
const context = await requireGuardianAuth();
@@ -49,6 +49,15 @@ vi.mock("@/lib/oauth/callback-claim", () => ({
duplicateOAuthCallback: async () => null,
}));
vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({
// The guardian arm is covered in its own test; here the record's own
// browser (or none) may finish, any other session may not.
verifyCallbackActor: async (record: string, session: string | null) =>
session === null || session === record
? { ok: true, guardianUserId: null }
: { ok: false, reason: "cross_user" },
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
+16 -4
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
@@ -69,11 +70,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
const evt = getEvent();
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
// The browser finishing the flow must be the record's own, or, for a
// managed profile, a guardian's with a live MANAGE grant on it.
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
const actorCheck = await verifyCallbackActor(
userId,
sessionData?.user.id ?? null,
);
if (!actorCheck.ok) {
annotate({ meta: { reason: actorCheck.reason } });
return ERR("cross_user");
}
const guardianUserId = actorCheck.guardianUserId;
if (!code) return ERR("nocode");
@@ -89,12 +97,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
).toString(),
stateCookie: oauthStateCookieName("strava"),
},
() => completeCallback(userId, code),
() => completeCallback(userId, guardianUserId, code),
);
});
async function completeCallback(
userId: string,
guardianUserId: string | null,
code: string,
): Promise<NextResponse> {
try {
@@ -131,7 +140,10 @@ async function completeCallback(
},
});
await auditLog("strava.connect", { userId });
await auditLog("strava.connect", {
userId,
actorUserId: guardianUserId,
});
await markReconnected(userId, "strava");
// Enqueue the self-converging history backfill at connect time so deep
@@ -11,6 +11,15 @@ vi.mock("@/lib/oauth/callback-claim", async (importOriginal) => ({
duplicateOAuthCallback: async () => null,
}));
vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({
// The guardian arm is covered in its own test; here the record's own
// browser (or none) may finish, any other session may not.
verifyCallbackActor: async (record: string, session: string | null) =>
session === null || session === record
? { ok: true, guardianUserId: null }
: { ok: false, reason: "cross_user" },
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
+14 -3
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
@@ -180,11 +181,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
// Optional session cross-check: only when a web session cookie is present.
// The native ticket path carries no session — the nonce cookie + atomic
// single-use delete already bind the row to its originator.
// A guardian finishing a managed profile's connect needs a live MANAGE
// grant on it; a managed profile has no session of its own.
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
const actorCheck = await verifyCallbackActor(
userId,
sessionData?.user.id ?? null,
);
if (!actorCheck.ok) {
annotate({ meta: { reason: actorCheck.reason } });
return outcomeRedirect(returnScheme, "error", "cross_user");
}
const guardianUserId = actorCheck.guardianUserId;
if (!code) {
return outcomeRedirect(returnScheme, "error", "nocode");
@@ -237,7 +245,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
throw err;
}
await auditLog("whoop.connect", { userId });
await auditLog("whoop.connect", {
userId,
actorUserId: guardianUserId,
});
// Re-completing OAuth clears any prior reauth-required state.
await markReconnected(userId, "whoop");
@@ -31,12 +31,18 @@ vi.mock("@/lib/oauth/callback-claim", async (importOriginal) => ({
duplicateOAuthCallback: async () => null,
}));
vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({
// The guardian arm is covered in its own test; here the record's own
// browser (or none) may finish, any other session may not.
verifyCallbackActor: async (record: string, session: string | null) =>
session === null || session === record
? { ok: true, guardianUserId: null }
: { ok: false, reason: "cross_user" },
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "user-1" },
session: { id: "sess-1" },
})),
AUTH_ERROR_CODES: { missing: "auth.missing" },
HttpError: class HttpError extends Error {
constructor(
public status: number,
@@ -47,6 +53,13 @@ vi.mock("@/lib/api-handler", () => ({
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(async () => ({
user: { id: "user-1" },
session: { id: "sess-1" },
})),
}));
vi.mock("@/lib/db", () => {
const prisma = {
withingsOAuthState: {
+25 -10
View File
@@ -1,5 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, AUTH_ERROR_CODES, HttpError } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
@@ -52,7 +54,15 @@ import { timingSafeEqual } from "node:crypto";
* lands on `replay`.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// The person in the browser, read off the session itself rather than
// through a record resolver: a provider redirect is a navigation, it cannot
// carry the record-session header, and the record is the one the state row
// names, checked against this person below. A session is still required.
const sessionData = await getSession();
if (!sessionData) {
throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing);
}
const actor = sessionData.user;
annotate({ action: { name: "withings.callback" } });
const { searchParams } = new URL(request.url);
@@ -161,8 +171,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
),
);
}
if (consumed.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
// The record the connect started for; a guardian may finish it for a
// managed profile it holds a live MANAGE grant on.
const recordUserId = consumed.userId;
const actorCheck = await verifyCallbackActor(recordUserId, actor.id);
if (!actorCheck.ok) {
annotate({ meta: { reason: actorCheck.reason } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=cross_user",
@@ -181,7 +195,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
try {
const creds = await getUserWithingsCredentials(user.id);
const creds = await getUserWithingsCredentials(recordUserId);
if (!creds) {
return NextResponse.redirect(
new URL(
@@ -204,7 +218,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// entirely. Storing the requested set is therefore a safe
// representation of what the connection holds.
await prisma.withingsConnection.upsert({
where: { userId: user.id },
where: { userId: recordUserId },
update: {
withingsUserId: tokens.userid,
accessToken: encrypt(tokens.access_token),
@@ -221,7 +235,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
sleepStageReads: Prisma.DbNull,
},
create: {
userId: user.id,
userId: recordUserId,
withingsUserId: tokens.userid,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
@@ -233,18 +247,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
// Reconcile every category from a clean state after connect/reconnect.
setupWebhook(user.id).catch(() =>
setupWebhook(recordUserId).catch(() =>
getEvent()?.addWarning("Webhook setup failed"),
);
await auditLog("withings.connect", {
userId: user.id,
userId: recordUserId,
actorUserId: actorCheck.guardianUserId,
details: { withingsUserId: tokens.userid },
});
// Re-completing OAuth clears any prior `error_reauth` state. We
// don't write a fresh success-time — that's the next sync's job.
await markReconnected(user.id, "withings");
await markReconnected(recordUserId, "withings");
const response = NextResponse.redirect(
new URL(