mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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.
115 lines
4.1 KiB
TypeScript
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");
|
|
}
|
|
});
|