Files
HealthLog/src/app/api/polar/callback/route.ts
T
Marc-André Bombeck 38c7604ce4 feat(integrations): per-user Polar and Oura OAuth credentials
Polar and Oura resolved their OAuth client id/secret from the server
environment only, forcing every user on a deploy to share one app. Both
now read per-user BYO keys first, falling back to the shared env app when
unset, mirroring the WHOOP and Fitbit model.

- Add DB-first-then-env resolvers in src/lib/{polar,oura}/credentials.ts,
  encrypting the client id/secret at rest on User.
- Repoint the connect, callback, status routes and the Oura refresh path
  to the user-scoped resolver.
- Add /api/{polar,oura}/credentials (GET/PUT/DELETE) with Zod-validated
  input and userId narrowed from the session.
- Render an optional 'your OAuth app credentials' form on the Polar and
  Oura cards; the status read exposes hasOwnCredentials for the saved
  placeholder. Existing env-only deploys keep working unchanged.
- Cover both resolvers (DB hit, env fallback, no creds) and the card form
  with unit tests; add the new strings across all six locales.
2026-06-14 23:19:47 +02:00

115 lines
4.1 KiB
TypeScript

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, registerUser } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
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 = await getPolarClientCredentials(userId);
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, so no refresh column is stored.
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");
}
});