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);
}
+105 -2
View File
@@ -26,6 +26,7 @@ import { z } from "zod/v4";
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
import { MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS } from "@/lib/record-settings";
import { connectionHealth } from "./integrations";
import { MANAGED_OAUTH_PROVIDERS } from "@/lib/integrations/oauth-kit/managed-provider";
import {
dataEnvelope,
errorEnvelope,
@@ -112,7 +113,7 @@ const recordSettingsIntegrationsResponse = z
.meta({
id: "RecordSettingsIntegrationsResponse",
description:
"Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.",
"Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. No credential projection. A Guardian connects or disconnects an OAuth provider for the record through `POST /api/record-settings/integrations/{provider}/connect` and `/disconnect`. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.",
});
// ── The six family DTOs ──────────────────────────────────────────────
@@ -334,6 +335,45 @@ const familyParameter = {
"Which configuration family to read or write. An unrecognised value is a 404 — and note that the family check runs BEFORE authentication, so an unknown family answers 404 to an unauthenticated caller where a known one would answer 401.",
};
const managedProviderEnum = z.enum(MANAGED_OAUTH_PROVIDERS);
const managedProviderParameter = {
name: "provider",
in: "path" as const,
required: true,
schema: {
type: "string" as const,
enum: [...MANAGED_OAUTH_PROVIDERS],
},
description:
"The OAuth provider. An unrecognised value is a 404, answered before authentication.",
};
const managedConnectStartResponse = z
.object({
recordId: z.string(),
provider: managedProviderEnum,
authorizationUrl: z
.string()
.describe("The provider's consent URL, with the state the record owns."),
})
.meta({
id: "ManagedConnectStartResponse",
description:
"Where to send the browser to connect a provider for the managed record.",
});
const managedDisconnectResponse = z
.object({
recordId: z.string(),
provider: managedProviderEnum,
disconnected: z.literal(true),
})
.meta({
id: "ManagedDisconnectResponse",
description: "The provider is no longer connected to the managed record.",
});
export const recordSettingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/record-settings": {
get: {
@@ -363,7 +403,7 @@ export const recordSettingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Account sharing"],
summary: "Read the managed record's integration connection states",
description:
"Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.",
"Whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. No credential projection. Connecting and disconnecting an OAuth provider are the two POST routes below this path. Cookie session or Bearer, while acting on a managed record the caller guards.",
responses: {
...recordRefusal(),
"200": {
@@ -381,6 +421,69 @@ export const recordSettingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/record-settings/integrations/{provider}/connect": {
post: {
tags: ["Account sharing"],
summary: "Start connecting an OAuth provider for the managed record",
description:
"Mints the OAuth state for the record the session is acting on, sets it as an httpOnly cookie, and answers with the provider's consent URL; the client then sends the browser there. A fetch rather than a navigation, so the record-session fence applies. The state names the managed record: the provider's callback lands the tokens on it after checking the browser still belongs to a Guardian holding a live MANAGE grant on that record. When the record has no provider app of its own and the deployment has none, the Guardian's own app is copied onto the record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed record the caller guards.",
parameters: [managedProviderParameter],
responses: {
...recordRefusal(),
"200": {
description: "The consent URL to send the browser to.",
content: {
"application/json": {
schema: dataEnvelope(
managedConnectStartResponse,
"ManagedConnectStartEnvelope",
),
},
},
},
"404": {
description:
"The path named a provider outside the closed set. Answered before the caller is authenticated.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"Neither the record, the Guardian, nor the deployment has an OAuth app for this provider.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/record-settings/integrations/{provider}/disconnect": {
post: {
tags: ["Account sharing"],
summary: "Disconnect an OAuth provider from the managed record",
description:
"Runs the same disconnect the owner of a record runs: the grant is dropped, the provider told where it can (Withings webhooks, Strava deauthorisation), and the integration parked at `disconnected`. Imported data stays. Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed record the caller guards.",
parameters: [managedProviderParameter],
responses: {
...recordRefusal(),
"200": {
description: "The provider is disconnected.",
content: {
"application/json": {
schema: dataEnvelope(
managedDisconnectResponse,
"ManagedDisconnectEnvelope",
),
},
},
},
"404": {
description:
"The path named a provider outside the closed set, or the record has no connection to it.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/record-settings/{family}": {
get: {
tags: ["Account sharing"],