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