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

@@ -672,6 +672,14 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/managed-integration-status.tsx",
destroys:
"a managed profile's stored Withings, WHOOP, Google Health, Polar, Oura or Strava grant, disconnected by its guardian — reconnecting means authorising again",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/nightscout-card.tsx",
destroys:
@@ -20,9 +20,11 @@ describe("record settings route guard", () => {
.map((entry) => `app/api/record-settings/${entry}`)
.sort();
expect(discovered).toHaveLength(3);
expect(discovered).toHaveLength(5);
expect(discovered).toEqual([
"app/api/record-settings/[family]/route.ts",
"app/api/record-settings/integrations/[provider]/connect/route.ts",
"app/api/record-settings/integrations/[provider]/disconnect/route.ts",
"app/api/record-settings/integrations/route.ts",
"app/api/record-settings/route.ts",
]);
@@ -32,6 +34,8 @@ describe("record settings route guard", () => {
const routes = [
"route.ts",
"integrations/route.ts",
"integrations/[provider]/connect/route.ts",
"integrations/[provider]/disconnect/route.ts",
"[family]/route.ts",
] as const;
const sources = await Promise.all(
+12 -3
View File
@@ -27,7 +27,7 @@ function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
}
describe("managed record settings UI", () => {
it("gates shared deep links and renders integration status without controls", async () => {
it("gates shared deep links and controls integrations only through the managed-record routes", async () => {
const [
statusView,
managedSettingsView,
@@ -58,8 +58,17 @@ describe("managed record settings UI", () => {
expect(statusView).toContain("recordSettingsIntegrations");
expect(statusView).toContain("/api/record-settings/integrations");
expect(statusView).toContain("assertRecordSettingsResponseForRecord");
expect(statusView).not.toContain("<Button");
expect(statusView).not.toContain("<Link");
// A guardian's controls go through the fenced managed-record routes, never
// the owner's provider routes and never a navigation to the API.
expect(statusView).toContain(
"/api/record-settings/integrations/${provider}/connect",
);
expect(statusView).toContain(
"/api/record-settings/integrations/${provider}/disconnect",
);
expect(statusView).not.toMatch(/["`]\/api\/\$\{provider\}\//);
expect(statusView).not.toMatch(/loadDocument\(\s*["`]\/api/);
expect(statusView).not.toMatch(/<Link[\s>]/);
expect(settingsPage).toContain("RecordSettingsSectionGate");
expect(sectionGate).toContain("if (!inSharedRecord) return children");
expect(sectionGate).toContain("ManagedIntegrationStatus");
@@ -115,6 +115,14 @@ describe("S1 — the direct session-resolution set is frozen", () => {
"consent decision on a route outside apiHandler",
// Provider OAuth callbacks. The browser arrives as a top-level
// navigation, so the code is bound to the session user and redirected.
// v1.44 — Withings and Google Health join: a navigation cannot carry the
// record-session header a record resolver needs, and the session user may
// be a guardian finishing a managed profile's connect, which
// `verifyCallbackActor` checks against a live MANAGE grant.
"app/api/google-health/callback/route.ts":
"binds the OAuth code to the session user",
"app/api/withings/callback/route.ts":
"binds the OAuth code to the session user",
"app/api/oura/callback/route.ts":
"binds the OAuth code to the session user",
"app/api/polar/callback/route.ts":
+8 -1
View File
@@ -2044,6 +2044,10 @@ const GUARDIAN_ROUTES: Record<string, string> = {
"Verified integration connection status for the managed record, without any provider control surface.",
"app/api/record-settings/[family]/route.ts":
"The closed, field-specific managed-record configuration DTOs; identity and credentials remain excluded.",
"app/api/record-settings/integrations/[provider]/connect/route.ts":
"Starts an OAuth connect for the managed record a guardian administers. It returns the consent URL and the state names the managed record; the callback re-checks the browser's live MANAGE grant before any token lands. Refused on an adult record by the marker, at every grant level.",
"app/api/record-settings/integrations/[provider]/disconnect/route.ts":
"Disconnects a provider from the managed record a guardian administers, through the same disconnect the owner runs. Imported data stays. Refused on an adult record by the marker, at every grant level.",
};
/**
@@ -2164,8 +2168,11 @@ const ACTOR_ROUTES: Record<string, string> = {
* v1.43 -- AUDIT-C's create and soft delete join the record list and the
* manage literal under `profile`, the level allergies are written at.
* 262 -> 266.
*
* v1.44 -- a guardian connects and disconnects a managed profile's OAuth
* providers: two guardian-only entries. 266 -> 268.
*/
const FROZEN_ENTRY_COUNT = 266;
const FROZEN_ENTRY_COUNT = 268;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+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(
@@ -1,5 +1,6 @@
/**
* A managed record's connections on the shared integration card, read-only.
* A managed record's connections on the shared integration card: a guardian
* connects and disconnects the record's OAuth providers, the rest is status.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -32,6 +33,8 @@ vi.mock("@tanstack/react-query", () => ({
isError: false,
refetch: vi.fn(),
}),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
@@ -77,7 +80,7 @@ describe("<ManagedIntegrationStatus>", () => {
loading = false;
});
it("renders one shared card per provider from health, with no control on any", () => {
it("renders one shared card per provider from health, with controls on the OAuth ones", () => {
payload = withHealth({
oura: healthFixture({
state: "action_required",
@@ -100,20 +103,37 @@ describe("<ManagedIntegrationStatus>", () => {
expect(html).toMatch(
/data-testid="managed-integration-oura"[\s\S]*?data-slot="integration-health-badge"[^>]*data-state="action_required"/,
);
// Read-only even where the state asks for an action.
expect(html).not.toContain('data-slot="integration-primary-action"');
expect(html).not.toContain('data-slot="integration-menu-trigger"');
// The reconnect a broken grant asks for, on the card that asks for it.
expect(html).toContain('data-testid="oura-reconnect-button"');
// A connected OAuth card carries the disconnect menu, and every OAuth card
// says whose record it connects for.
expect(html).toContain('data-testid="withings-menu"');
expect(html.match(/Connects for Managed record/g)).toHaveLength(6);
// Each unconnected OAuth provider offers one connect for the record;
// Fitbit (retired) and Nightscout (no OAuth) stay status only.
for (const key of ["whoop", "polar", "google-health", "strava"]) {
expect(html).toContain(
`data-testid="managed-integration-${key}-connect"`,
);
}
expect(html).not.toContain(
'data-testid="managed-integration-fitbit-connect"',
);
expect(html).not.toContain(
'data-testid="managed-integration-nightscout-connect"',
);
expect(html.match(/>Connect for Managed record</g)).toHaveLength(4);
// The legacy `state: "connected"` on every row does not make a card
// connected; only `health` does.
expect(html.match(/>Not connected\.</g)).toHaveLength(6);
});
it("says once why nothing here can be changed, and links the explanation", () => {
it("says once where connected data goes, and links the explanation", () => {
payload = withHealth({});
const html = render();
expect(html).toContain('data-slot="managed-integration-read-only"');
expect(html).toContain('data-slot="managed-integration-target"');
expect(html).toContain(
"A managed profile shows its connected services as a status only.",
"Services you connect here send their data to Managed record&#x27;s record, not to yours.",
);
expect(html).toContain(
'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"',
@@ -1,17 +1,32 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Droplet, ExternalLink, Plug } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Droplet, ExternalLink, Link2, Plug, Unplug } from "lucide-react";
import { IntegrationCard } from "@/components/settings/integrations/integration-card";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
IntegrationCard,
type IntegrationMenuAction,
} from "@/components/settings/integrations/integration-card";
import { Button } from "@/components/ui/button";
import { OAUTH_INTEGRATIONS } from "@/components/settings/integrations/oauth-integration-card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { apiGet } from "@/lib/api/api-fetch";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import { queryKeys } from "@/lib/query-keys";
import {
isManagedOAuthProvider,
type ManagedOAuthProvider,
} from "@/lib/integrations/oauth-kit/managed-provider";
import { loadDocument } from "@/lib/navigation/load-document";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response";
type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES;
@@ -49,15 +64,18 @@ function iconFor(key: ManagedIntegrationKey) {
* The managed record's connections, on the same card every integration in
* Settings renders, from the same server-resolved `health`.
*
* Read-only by construction: a guardian cannot connect, reconnect, resume,
* sync, test or disconnect a managed record's provider, because an OAuth flow
* cannot safely carry a record selector. So the cards carry no menu and no
* button, and one sentence above them says why, with the page that explains
* the boundary.
* A guardian connects and disconnects the record's OAuth providers here. The
* connect starts with a fetch (`POST /api/record-settings/integrations/
* <provider>/connect`, which carries the record-session headers a navigation
* cannot) and then sends the browser to the provider; tokens and data land on
* the managed record. Every card says whose record it connects for. Syncing,
* testing and provider credentials stay out: the record syncs on its own
* schedule once connected.
*/
export function ManagedIntegrationStatus() {
const { t } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
const activeRecord = user?.accountAccess?.active ?? null;
const recordId = activeRecord?.accountId ?? null;
const statusQuery = useQuery({
@@ -72,6 +90,57 @@ export function ManagedIntegrationStatus() {
enabled: activeRecord?.recordKind === "managed" && recordId !== null,
});
const recordName = activeRecord?.displayName ?? "";
const invalidateStatus = () =>
queryClient.invalidateQueries({
queryKey: queryKeys.recordSettingsIntegrations(recordId ?? ""),
});
const connect = useMutation({
mutationFn: async (provider: ManagedOAuthProvider) =>
apiPost<{ authorizationUrl: string }>(
`/api/record-settings/integrations/${provider}/connect`,
),
onSuccess: (started) => loadDocument(started.authorizationUrl),
onError: (failure, provider) =>
showErrorToast(
failure instanceof ApiError && failure.status === 409
? t("settings.sharedRecord.integrationNoClientApp", {
name: recordName,
provider: INTEGRATION_DISPLAY_NAMES[provider],
})
: t("settings.sharedRecord.integrationConnectFailed", {
provider: INTEGRATION_DISPLAY_NAMES[provider],
}),
{ cause: failure },
),
});
const disconnect = useMutation({
mutationFn: async (provider: ManagedOAuthProvider) =>
apiPost(`/api/record-settings/integrations/${provider}/disconnect`),
onSuccess: (_result, provider) => {
void invalidateStatus();
// A record-settings write; the capability answer reads the record's
// connections, so it is evicted with the rest of the AI inputs.
void invalidateKeys(queryClient, aiInputDependentKeys);
toastWrittenOutcome(
"success",
t("settings.integrationDisconnected", {
provider: INTEGRATION_DISPLAY_NAMES[provider],
}),
);
},
onError: (failure, provider) =>
showErrorToast(
t("settings.integrationDisconnectFailed", {
provider: INTEGRATION_DISPLAY_NAMES[provider],
}),
{ cause: failure },
),
});
if (statusQuery.isError) {
return (
<QueryErrorCard
@@ -88,6 +157,68 @@ export function ManagedIntegrationStatus() {
(integration) => ({ integration, health: null }),
);
/** What a guardian can do with one OAuth provider of the record. */
function managedControls(provider: ManagedOAuthProvider) {
const providerName = INTEGRATION_DISPLAY_NAMES[provider];
const pending =
(connect.isPending && connect.variables === provider) ||
(disconnect.isPending && disconnect.variables === provider);
const forRecord = (
<p
className="text-muted-foreground text-xs"
data-slot="managed-integration-for"
>
{t("settings.sharedRecord.integrationConnectsFor", {
name: recordName,
})}
</p>
);
const menu: IntegrationMenuAction[] = [
{
id: "disconnect",
label: t("settings.sharedRecord.integrationDisconnect"),
icon: Unplug,
destructive: true,
disabled: pending,
onSelect: () => disconnect.mutate(provider),
confirm: {
title: t("settings.sharedRecord.integrationDisconnectTitle", {
provider: providerName,
name: recordName,
}),
body: t("settings.sharedRecord.integrationDisconnectBody", {
name: recordName,
}),
confirmLabel: t("settings.sharedRecord.integrationDisconnect"),
},
},
];
return {
children: forRecord,
menu,
primary: {
onReconnect: () => connect.mutate(provider),
pending,
},
connectAction: (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => connect.mutate(provider)}
disabled={pending}
aria-busy={pending || undefined}
data-testid={`managed-integration-${provider}-connect`}
>
<Link2 className="size-3.5" aria-hidden="true" />
{t("settings.sharedRecord.integrationConnectFor", {
name: recordName,
})}
</Button>
),
};
}
return (
<section
className="space-y-4"
@@ -103,8 +234,10 @@ export function ManagedIntegrationStatus() {
title={t("settings.sections.integrations.title")}
subtitle={t("settings.sharedRecord.integrationStatusDescription")}
/>
<p className="text-sm" data-slot="managed-integration-read-only">
{t("settings.sharedRecord.integrationStatusDetail")}{" "}
<p className="text-sm" data-slot="managed-integration-target">
{t("settings.sharedRecord.integrationStatusDetail", {
name: recordName,
})}{" "}
<a
href={SHARING_DOCS_HREF}
target="_blank"
@@ -129,6 +262,9 @@ export function ManagedIntegrationStatus() {
connected={entry.health !== null}
health={entry.health}
testId={`managed-integration-${entry.integration}`}
{...(isManagedOAuthProvider(entry.integration)
? managedControls(entry.integration)
: {})}
/>
</li>
))}
@@ -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"],