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

@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { findUnique, update, findActiveGrant, auditLog } = vi.hoisted(() => ({
findUnique: vi.fn(),
update: vi.fn(async () => ({})),
findActiveGrant: vi.fn(),
auditLog: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique, update } } }));
vi.mock("@/lib/auth/audit", () => ({ auditLog }));
vi.mock("@/lib/sharing/grants", () => ({
findActiveGrant,
grantAllows: (
grant: { access: string; revokedAt: Date | null },
need: string,
) =>
grant.revokedAt === null &&
(need !== "manage" || grant.access === "MANAGE"),
}));
import { shareGuardianClientApp, verifyCallbackActor } from "../connect-target";
const MANAGED = { managedProfileAt: new Date("2026-01-01T00:00:00Z") };
const ADULT = { managedProfileAt: null };
beforeEach(() => {
vi.clearAllMocks();
});
describe("verifyCallbackActor", () => {
it("admits the record's own session without a lookup", async () => {
await expect(verifyCallbackActor("rec", "rec")).resolves.toEqual({
ok: true,
guardianUserId: null,
});
expect(findUnique).not.toHaveBeenCalled();
});
it("keeps the session-less own-account path for an ordinary account", async () => {
findUnique.mockResolvedValue(ADULT);
await expect(verifyCallbackActor("rec", null)).resolves.toEqual({
ok: true,
guardianUserId: null,
});
});
it("refuses another person's session on an ordinary account, grant or not", async () => {
findUnique.mockResolvedValue(ADULT);
findActiveGrant.mockResolvedValue({ access: "MANAGE", revokedAt: null });
await expect(verifyCallbackActor("rec", "other")).resolves.toEqual({
ok: false,
reason: "cross_user",
});
expect(findActiveGrant).not.toHaveBeenCalled();
});
it("admits a guardian with a live MANAGE grant on a managed profile", async () => {
findUnique.mockResolvedValue(MANAGED);
findActiveGrant.mockResolvedValue({ access: "MANAGE", revokedAt: null });
await expect(verifyCallbackActor("rec", "guardian")).resolves.toEqual({
ok: true,
guardianUserId: "guardian",
});
expect(findActiveGrant).toHaveBeenCalledWith({
grantorId: "rec",
granteeId: "guardian",
});
});
it("refuses a read or write grant, and a grant revoked on the consent screen", async () => {
findUnique.mockResolvedValue(MANAGED);
findActiveGrant.mockResolvedValueOnce({ access: "WRITE", revokedAt: null });
await expect(verifyCallbackActor("rec", "g")).resolves.toMatchObject({
ok: false,
});
findActiveGrant.mockResolvedValueOnce(null);
await expect(verifyCallbackActor("rec", "g")).resolves.toMatchObject({
ok: false,
});
});
it("requires a guardian's session for a managed profile", async () => {
findUnique.mockResolvedValue(MANAGED);
await expect(verifyCallbackActor("rec", null)).resolves.toEqual({
ok: false,
reason: "guardian_session_required",
});
});
it("refuses a record that does not exist", async () => {
findUnique.mockResolvedValue(null);
await expect(verifyCallbackActor("gone", null)).resolves.toMatchObject({
ok: false,
});
});
});
describe("shareGuardianClientApp", () => {
const target = { recordUserId: "rec", actorUserId: "guardian" };
it("does nothing on a person's own connect", async () => {
const has = vi.fn(async () => false);
await shareGuardianClientApp(
"oura",
{ recordUserId: "a", actorUserId: "a" },
has,
);
expect(has).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("does nothing when the profile already resolves an app", async () => {
await shareGuardianClientApp("oura", target, async () => true);
expect(findUnique).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("copies the guardian's sealed app onto the profile and audits it", async () => {
findUnique.mockResolvedValue({
ouraClientIdEncrypted: "sealed-id",
ouraClientSecretEncrypted: "sealed-secret",
});
await shareGuardianClientApp("oura", target, async () => false);
expect(update).toHaveBeenCalledWith({
where: { id: "rec" },
data: {
ouraClientIdEncrypted: "sealed-id",
ouraClientSecretEncrypted: "sealed-secret",
},
});
expect(auditLog).toHaveBeenCalledWith("integration.client_app.shared", {
userId: "rec",
actorUserId: "guardian",
details: { provider: "oura" },
});
});
it("copies nothing when the guardian has no app either", async () => {
findUnique.mockResolvedValue({
stravaClientIdEncrypted: null,
stravaClientSecretEncrypted: null,
});
await shareGuardianClientApp("strava", target, async () => false);
expect(update).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
/**
* Whose record an OAuth connect lands on, and who is allowed to finish it.
*
* A guardian connects a provider for a managed profile they manage: the
* connect route resolves through `requireGuardianAuth`, so its `user` is the
* profile and its `actor` the guardian, and the state it mints names the
* profile. Tokens and data land on the profile. The state never carries a
* record selector the browser could change: it is signed, or a server-side
* ledger row, and it names exactly one record.
*
* The callback then has to answer one question the connect could not: is the
* browser completing this flow allowed to attach a provider account to that
* record? For a person's own record the answer is unchanged (their session, or
* no session at all on the flows that allow one). For a managed profile, which
* has no login of its own, the browser must hold a session of a guardian with
* a live MANAGE grant on the profile, checked again here because ten minutes
* can pass on a consent screen and a grant can be revoked in them.
*/
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { findActiveGrant, grantAllows } from "@/lib/sharing/grants";
import type { OAuthKitProvider } from "./token";
export type CallbackActorVerdict =
| {
readonly ok: true;
/** The guardian who finished it, or null for the record's own browser. */
readonly guardianUserId: string | null;
}
| {
readonly ok: false;
readonly reason: "cross_user" | "guardian_session_required";
};
/**
* May the session `sessionUserId` (null: no session) finish a connect whose
* state names `recordUserId`? On success, which guardian acted, if one did.
*/
export async function verifyCallbackActor(
recordUserId: string,
sessionUserId: string | null,
): Promise<CallbackActorVerdict> {
if (sessionUserId === recordUserId) {
return { ok: true, guardianUserId: null };
}
const record = await prisma.user.findUnique({
where: { id: recordUserId },
select: { managedProfileAt: true },
});
if (!record) return { ok: false, reason: "cross_user" };
if (record.managedProfileAt === null) {
// An ordinary account: its own browser without a session may finish (the
// native flows), anybody else's session may not.
return sessionUserId === null
? { ok: true, guardianUserId: null }
: { ok: false, reason: "cross_user" };
}
if (sessionUserId === null) {
return { ok: false, reason: "guardian_session_required" };
}
const grant = await findActiveGrant({
grantorId: recordUserId,
granteeId: sessionUserId,
});
if (!grant || !grantAllows(grant, "manage")) {
return { ok: false, reason: "cross_user" };
}
return { ok: true, guardianUserId: sessionUserId };
}
/** The per-user columns holding a provider's own OAuth app. */
const CLIENT_COLUMNS = {
withings: ["withingsClientIdEncrypted", "withingsClientSecretEncrypted"],
whoop: ["whoopClientIdEncrypted", "whoopClientSecretEncrypted"],
oura: ["ouraClientIdEncrypted", "ouraClientSecretEncrypted"],
strava: ["stravaClientIdEncrypted", "stravaClientSecretEncrypted"],
"google-health": [
"googleHealthClientIdEncrypted",
"googleHealthClientSecretEncrypted",
],
polar: ["polarClientIdEncrypted", "polarClientSecretEncrypted"],
} as const satisfies Record<OAuthKitProvider, readonly [string, string]>;
/**
* Let a managed profile connect with its guardian's own provider app.
*
* Every provider here is bring-your-own-app: the client id and secret sit on
* the account that connects, and the background refresh reads them from that
* account. A managed profile never had a settings page to paste them into, so
* when it has no app of its own and the deployment has no shared one, the
* guardian's app is copied onto the profile, still sealed, with an audit row
* on the profile naming the guardian. Does nothing for a person's own connect, and nothing when
* the profile can already resolve an app.
*/
export async function shareGuardianClientApp(
provider: OAuthKitProvider,
target: { recordUserId: string; actorUserId: string },
recordHasApp: () => Promise<boolean>,
): Promise<void> {
if (target.recordUserId === target.actorUserId) return;
if (await recordHasApp()) return;
const [idColumn, secretColumn] = CLIENT_COLUMNS[provider];
const guardian = (await prisma.user.findUnique({
where: { id: target.actorUserId },
select: { [idColumn]: true, [secretColumn]: true },
})) as Record<string, string | null> | null;
const clientId = guardian?.[idColumn] ?? null;
const clientSecret = guardian?.[secretColumn] ?? null;
if (!clientId || !clientSecret) return;
await prisma.user.update({
where: { id: target.recordUserId },
data: { [idColumn]: clientId, [secretColumn]: clientSecret },
});
await auditLog("integration.client_app.shared", {
userId: target.recordUserId,
actorUserId: target.actorUserId,
details: { provider },
});
}
@@ -0,0 +1,26 @@
/**
* The OAuth providers a guardian may connect for a managed profile, and the
* route parameter that names one.
*
* Every kit provider qualifies. Fitbit does not: the classic Fitbit Web API is
* retired on 2026-10-30 and takes no new connections. Nightscout and Dawarich
* are not OAuth flows.
*/
import type { OAuthKitProvider } from "./token";
export const MANAGED_OAUTH_PROVIDERS = [
"withings",
"whoop",
"oura",
"polar",
"strava",
"google-health",
] as const satisfies readonly OAuthKitProvider[];
export type ManagedOAuthProvider = (typeof MANAGED_OAUTH_PROVIDERS)[number];
export function isManagedOAuthProvider(
value: string,
): value is ManagedOAuthProvider {
return (MANAGED_OAUTH_PROVIDERS as readonly string[]).includes(value);
}