mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
1 parent
65e0cba0bf
commit
74ce17b7de
35 files changed
+1392
-158
No files matched your search
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user