mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(integrations): add Polar AccessLink OAuth sync
Connect a Polar account over AccessLink v3: an authorization-code OAuth flow (shared app from POLAR_CLIENT_ID / POLAR_CLIENT_SECRET) backed by a stateless HMAC-signed state token doubling as an httpOnly cookie for CSRF. The callback verifies the signature, expiry, provider binding, and the byte-exact cookie match, then encrypts the access token plus member id onto the user row. An hourly poll-cohort job maps Nightly Recharge recovery, sleep stages, and daily activity to canonical measurements tagged source POLAR, idempotent on (userId, type, source, externalId). The recovery band feeds RECOVERY_SCORE through the canonical ladder, which now ranks native readiness above the computed proxy. Settings gains a connect / disconnect card. Tokens are never logged or returned; every egress goes through safeFetch.
This commit is contained in:
1 parent
e561f8d893
commit
31abd307fd
31 files changed
+2341
-13
No files matched your search
@@ -0,0 +1,118 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
getPolarCredentials,
|
||||
registerUser,
|
||||
} from "@/lib/polar/client";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
stateMatchesCookie,
|
||||
verifySignedState,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — OAuth callback from Polar AccessLink.
|
||||
*
|
||||
* Identity + CSRF: the `state` URL param is a stateless HMAC-signed token. The
|
||||
* callback enforces (1) the param equals the httpOnly cookie byte-for-byte
|
||||
* (double-submit), and (2) the signature + expiry + `polar` provider binding
|
||||
* are valid. The `userId` is read from INSIDE the verified token — tamper-
|
||||
* evident — so identity never comes from the ambient session. When a web
|
||||
* session cookie is also present we cross-check it matches, so a logged-in user
|
||||
* can't complete another user's in-flight handshake.
|
||||
*
|
||||
* On success: exchange the code (Basic-auth), register the user with the Polar
|
||||
* app, persist the encrypted access token + member id on `User`, clear any
|
||||
* prior reauth state, and redirect back to Settings.
|
||||
*/
|
||||
const ERR = (reason: string) =>
|
||||
NextResponse.redirect(
|
||||
new URL(
|
||||
`/settings/integrations?polar=error&reason=${reason}`,
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "polar.callback" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const cookieState = request.cookies.get(oauthStateCookieName("polar"))?.value;
|
||||
|
||||
// CSRF double-submit: the URL state must equal the cookie (constant-time).
|
||||
if (!stateMatchesCookie(state, cookieState)) {
|
||||
annotate({ meta: { reason: "csrf1" } });
|
||||
return ERR("csrf1");
|
||||
}
|
||||
|
||||
// Verify signature + expiry + provider binding; userId rides inside.
|
||||
const verified = verifySignedState("polar", state);
|
||||
if (!verified) {
|
||||
annotate({ meta: { reason: "state" } });
|
||||
return ERR("state");
|
||||
}
|
||||
const userId = verified.userId;
|
||||
|
||||
const evt = getEvent();
|
||||
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
|
||||
|
||||
// Optional session cross-check when a web session is present.
|
||||
const sessionData = await getSession();
|
||||
if (sessionData && sessionData.user.id !== userId) {
|
||||
annotate({ meta: { reason: "cross_user" } });
|
||||
return ERR("cross_user");
|
||||
}
|
||||
|
||||
if (!code) return ERR("nocode");
|
||||
|
||||
try {
|
||||
const creds = getPolarCredentials();
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
// Guard against an off-spec token body without `x_user_id`: persisting the
|
||||
// string "undefined" as the member id would break every later data read
|
||||
// (`/v3/users/undefined/...`) with no clear cause.
|
||||
if (typeof tokens.x_user_id !== "number" || !Number.isFinite(tokens.x_user_id)) {
|
||||
return ERR("token");
|
||||
}
|
||||
const polarUserId = String(tokens.x_user_id);
|
||||
|
||||
// Register the user with the Polar app (idempotent; 409 = already linked).
|
||||
await registerUser(tokens.access_token, polarUserId);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
polarAccessTokenEncrypted: encrypt(tokens.access_token),
|
||||
// Polar issues no refresh token; the column stays null.
|
||||
polarRefreshTokenEncrypted: null,
|
||||
polarUserIdEncrypted: encrypt(polarUserId),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("polar.connect", { userId });
|
||||
await markReconnected(userId, "polar");
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?polar=connected",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
response.cookies.delete(oauthStateCookieName("polar"));
|
||||
return response;
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return ERR("token");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAuthorizationUrl, getPolarCredentials } from "@/lib/polar/client";
|
||||
import {
|
||||
OAUTH_STATE_TTL_MS,
|
||||
mintSignedState,
|
||||
oauthStateCookieName,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — redirect the user to the Polar AccessLink consent screen.
|
||||
*
|
||||
* Full OAuth from env: the shared client id/secret come from `POLAR_CLIENT_ID`
|
||||
* / `POLAR_CLIENT_SECRET` (not per-user BYO). The CSRF defence is a stateless
|
||||
* signed state (`src/lib/oauth/signed-state.ts`): the same token is the `state`
|
||||
* URL param AND an httpOnly cookie, and the callback enforces both signature
|
||||
* validity and a byte-exact cookie match.
|
||||
*
|
||||
* Rate-limited per user so a session can't spam the consent redirect.
|
||||
*/
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.connect" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`polar:connect:${user.id}`,
|
||||
CONNECT_RATE_LIMIT,
|
||||
CONNECT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({ action: { name: "polar.connect.rate_limited" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?polar=error&reason=rate_limited",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const creds = getPolarCredentials();
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Polar integration is not configured on this server.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const state = mintSignedState("polar", user.id);
|
||||
const url = getAuthorizationUrl(state, creds);
|
||||
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set(oauthStateCookieName("polar"), state, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
|
||||
path: "/",
|
||||
});
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — disconnect Polar for the current user.
|
||||
*
|
||||
* Clears the stored OAuth token + member id on `User` and parks the integration
|
||||
* status at `disconnected`. Imported `source = POLAR` measurements are left
|
||||
* intact (they are the user's historical readings); a reconnect resumes sync.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.disconnect" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { polarAccessTokenEncrypted: true },
|
||||
});
|
||||
if (!dbUser?.polarAccessTokenEncrypted) {
|
||||
return apiError("No Polar connection", 404);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
polarAccessTokenEncrypted: null,
|
||||
polarRefreshTokenEncrypted: null,
|
||||
polarUserIdEncrypted: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("polar.disconnect", { userId: user.id });
|
||||
await markDisconnected(user.id, "polar");
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/polar/client", () => ({
|
||||
getPolarCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
getIntegrationStatus: vi.fn(async () => ({
|
||||
integration: "polar",
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-06-10T00:00:00.000Z",
|
||||
lastAttemptAt: "2026-06-10T00:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailuresByKind: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getPolarCredentials } from "@/lib/polar/client";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getPolarCredentials as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Body = {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
available: boolean;
|
||||
state?: string;
|
||||
};
|
||||
const call = () =>
|
||||
(GET as unknown as () => Promise<{ data: Body }>)().then((r) => r.data);
|
||||
|
||||
describe("GET /api/polar/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
|
||||
});
|
||||
|
||||
it("reports not-connected but available when env is set and no token stored", async () => {
|
||||
userFind.mockResolvedValue({ polarAccessTokenEncrypted: null });
|
||||
const res = await call();
|
||||
expect(res.connected).toBe(false);
|
||||
expect(res.available).toBe(true);
|
||||
});
|
||||
|
||||
it("reports available=false when the server has no Polar app configured", async () => {
|
||||
credsMock.mockReturnValue(null);
|
||||
userFind.mockResolvedValue(null);
|
||||
const res = await call();
|
||||
expect(res.available).toBe(false);
|
||||
expect(res.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("reports connected + ledger state when a token is stored", async () => {
|
||||
userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc" });
|
||||
const res = await call();
|
||||
expect(res.connected).toBe(true);
|
||||
expect(res.configured).toBe(true);
|
||||
expect(res.state).toBe("connected");
|
||||
});
|
||||
|
||||
it("never returns the token", async () => {
|
||||
userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc" });
|
||||
const res = (await call()) as Record<string, unknown>;
|
||||
expect(JSON.stringify(res)).not.toContain("enc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getPolarCredentials } from "@/lib/polar/client";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — Polar connection status for the current user.
|
||||
*
|
||||
* `available` reports whether the server has the shared OAuth app configured
|
||||
* (env client id/secret) so the card can grey out the connect button when the
|
||||
* operator hasn't set it up. `connected` is whether a token is stored. The
|
||||
* ledger snapshot (state / lastSuccessAt / lastError) comes off the shared
|
||||
* `polar` IntegrationKey. The access token + member id are NEVER returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.status" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { polarAccessTokenEncrypted: true },
|
||||
});
|
||||
|
||||
const available = !!getPolarCredentials();
|
||||
const connected = !!dbUser?.polarAccessTokenEncrypted;
|
||||
|
||||
if (!connected) {
|
||||
return apiSuccess({ connected: false, configured: false, available });
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "polar");
|
||||
|
||||
return apiSuccess({
|
||||
connected: true,
|
||||
configured: true,
|
||||
available,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user