mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user