feat(withings): rate-limit connect + redirect on row-create failure

The connect endpoint minted a fresh OAuth state row per call with
no guard. Add checkRateLimit (10 calls / 60 s per user) mirroring
disable-coach. Wrap the row create in try/catch — a DB-glitch now
redirects to /settings/integrations?withings=error&reason=connect
instead of bubbling a 500. Closes L5 + L14 in v1.4.48 backlog.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 10:24:12 +02:00
1 parent 0564d86373
commit 7c31e51fe4
2 files changed
+186 -10

No files matched your search

@@ -0,0 +1,136 @@
/**
* v1.4.48 L5 + L14 — GET /api/withings/connect
*
* Pins:
* 1. Happy path: mints a nonce, creates a `WithingsOAuthState` row,
* redirects to the Withings authorize URL with the state cookie
* attached.
* 2. Rate-limit: the 11th call in a 60 s window redirects to
* `/settings/integrations?withings=error&reason=rate_limited`
* and does not call `prisma.withingsOAuthState.create`.
* 3. Row-create failure: a Prisma error inside the try/catch
* redirects to
* `/settings/integrations?withings=error&reason=connect`
* instead of bubbling a 500.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
withingsOAuthState: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/withings/credentials", () => ({
getUserWithingsCredentials: vi.fn(),
}));
vi.mock("@/lib/withings/client", () => ({
getAuthorizationUrl: vi.fn(
() => "https://account.withings.com/oauth2_user/authorize2?state=nonce-stub",
),
}));
vi.mock("@/lib/withings/oauth-state", () => ({
WITHINGS_OAUTH_STATE_COOKIE: "withings_oauth_state",
WITHINGS_OAUTH_STATE_TTL_MS: 10 * 60 * 1000,
mintWithingsOAuthStateNonce: vi.fn(() => "nonce-stub"),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => ({
setError: vi.fn(),
addWarning: vi.fn(),
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
function connectRequest(): NextRequest {
return new NextRequest("http://localhost/api/withings/connect", {
method: "GET",
});
}
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.withingsOAuthState.create).mockReset();
vi.mocked(prisma.withingsOAuthState.create).mockResolvedValue(
{} as never,
);
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
clientId: "client-id",
clientSecret: "client-secret",
} as never);
});
describe("GET /api/withings/connect", () => {
it("redirects to the Withings authorize URL on the happy path", async () => {
const response = await GET(connectRequest());
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain(
"account.withings.com/oauth2_user/authorize2",
);
expect(prisma.withingsOAuthState.create).toHaveBeenCalledTimes(1);
expect(checkRateLimit).toHaveBeenCalledWith(
"withings:connect:u-1",
10,
60_000,
);
});
it("redirects to reason=rate_limited when the 11th call within 60 s is rejected", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await GET(connectRequest());
expect(response.status).toBe(307);
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("withings=error");
expect(location).toContain("reason=rate_limited");
expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled();
});
it("redirects to reason=connect when the row create throws", async () => {
vi.mocked(prisma.withingsOAuthState.create).mockRejectedValueOnce(
new Error("Prisma transient failure"),
);
const response = await GET(connectRequest());
expect(response.status).toBe(307);
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("withings=error");
expect(location).toContain("reason=connect");
});
});
+50 -10
View File
@@ -1,7 +1,8 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import {
@@ -9,7 +10,7 @@ import {
WITHINGS_OAUTH_STATE_TTL_MS,
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
/**
* Redirects the user to Withings OAuth authorization page.
@@ -21,11 +22,39 @@ import { NextResponse } from "next/server";
* nonce, and the callback resolves the user via the row's `userId`
* column. Closes the v1.4.43 security audit L-1 finding where the
* user id was recoverable from log entries / network captures.
*
* v1.4.48 L5 + L14 — endpoint is now rate-limited per-user (10 calls
* per 60 s) so a logged-in user with valid creds can't spam ledger
* rows for the full 10-min TTL window. The row-create call is wrapped
* in try/catch; a transient DB glitch now lands on the existing
* `/settings/integrations?withings=error&reason=connect` surface
* instead of bubbling a 500. Both rate-limit and create-failure paths
* redirect rather than returning JSON because the entry point is a
* browser navigation — a 429 envelope would surface as a blank page.
*/
export const GET = apiHandler(async () => {
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async (req: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "withings.connect" } });
const rl = await checkRateLimit(
`withings:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "withings.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=rate_limited",
req.url,
),
);
}
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
return apiError(
@@ -35,13 +64,24 @@ export const GET = apiHandler(async () => {
}
const nonce = mintWithingsOAuthStateNonce();
await prisma.withingsOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
});
try {
await prisma.withingsOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
});
} catch (err) {
getEvent()?.setError(err);
annotate({ action: { name: "withings.connect.create_failed" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=connect",
req.url,
),
);
}
const url = getAuthorizationUrl(nonce, creds);