mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The fixture granted the scopes Oura was asked for before workouts and intraday heart rate joined; a full grant now names those two as well.
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
/**
|
|
* A browser that delivers the same OAuth callback twice must cause one code
|
|
* exchange, against real Postgres.
|
|
*
|
|
* Chrome was seen sending `/api/oura/callback` twice with the same code about
|
|
* 150 ms apart. Both requests exchanged the code; the second was refused, and
|
|
* Oura answered the reuse by revoking the token the first exchange had issued,
|
|
* so the first sync failed with 401 everywhere. The callback now claims its
|
|
* state before the exchange: one request exchanges, the other follows it to
|
|
* the same page and touches nothing.
|
|
*
|
|
* Covered for a signed-state flow (Oura) and a ledger flow (Withings), the two
|
|
* shapes every connect callback takes.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
process.env.API_TOKEN_HMAC_KEY ??=
|
|
"integration-hmac-key-0123456789abcdef0123456789";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const USER = "user-oauth-single-use";
|
|
const OURA_TOKEN_URL = "https://moi.ouraring.com/oauth/v2/ext/oauth-token";
|
|
const OURA_ISS = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous";
|
|
const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2";
|
|
const WITHINGS_NOTIFY_URL = "https://wbsapi.withings.net/notify";
|
|
|
|
/** Long enough that two callbacks fired together overlap at the provider. */
|
|
const PROVIDER_LATENCY_MS = 150;
|
|
|
|
function urlOf(input: unknown): string {
|
|
return typeof input === "string" ? input : (input as Request).url;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
|
delete process.env.OURA_REDIRECT_URI;
|
|
|
|
const { encrypt } = await import("@/lib/crypto");
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: USER,
|
|
username: "oauth-single-use",
|
|
email: "oauth-single-use@example.test",
|
|
role: "USER",
|
|
ouraClientIdEncrypted: encrypt("oura-client"),
|
|
ouraClientSecretEncrypted: encrypt("oura-secret"),
|
|
withingsClientIdEncrypted: encrypt("withings-client"),
|
|
withingsClientSecretEncrypted: encrypt("withings-secret"),
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function ouraCallback(state: string, code: string | null = "oura-code") {
|
|
const { GET } = await import("@/app/api/oura/callback/route");
|
|
const params = new URLSearchParams(
|
|
code === null
|
|
? { error: "access_denied", state, iss: OURA_ISS }
|
|
: { code, state, iss: OURA_ISS },
|
|
);
|
|
return GET(
|
|
new NextRequest(`http://localhost/api/oura/callback?${params}`, {
|
|
method: "GET",
|
|
headers: { cookie: `oura_oauth_state=${state}` },
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function mintOuraState(): Promise<string> {
|
|
const { mintSignedState } = await import("@/lib/oauth/signed-state");
|
|
return mintSignedState("oura", USER);
|
|
}
|
|
|
|
describe("Oura callback delivered twice", () => {
|
|
it("exchanges the code once and sends both requests to the connected page", async () => {
|
|
const tokenCalls: string[] = [];
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
|
const url = urlOf(input);
|
|
if (url === OURA_TOKEN_URL) {
|
|
tokenCalls.push(String(init?.body ?? ""));
|
|
await new Promise((r) => setTimeout(r, PROVIDER_LATENCY_MS));
|
|
// A reused code is refused, as Oura does.
|
|
if (tokenCalls.length > 1) {
|
|
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
|
status: 400,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: "oura-access",
|
|
refresh_token: "oura-refresh",
|
|
expires_in: 86400,
|
|
scope:
|
|
"extapi:daily extapi:personal extapi:spo2 extapi:heart_health extapi:stress extapi:workout extapi:heartrate",
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
const state = await mintOuraState();
|
|
const [a, b] = await Promise.all([
|
|
ouraCallback(state),
|
|
ouraCallback(state),
|
|
]);
|
|
|
|
expect(tokenCalls).toHaveLength(1);
|
|
expect(a.headers.get("location")).toContain("oura=connected");
|
|
expect(b.headers.get("location")).toBe(a.headers.get("location"));
|
|
|
|
const { decrypt } = await import("@/lib/crypto");
|
|
const user = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: USER },
|
|
select: {
|
|
ouraAccessTokenEncrypted: true,
|
|
ouraRefreshTokenEncrypted: true,
|
|
ouraScopeDenied: true,
|
|
},
|
|
});
|
|
expect(decrypt(user.ouraAccessTokenEncrypted!)).toBe("oura-access");
|
|
expect(decrypt(user.ouraRefreshTokenEncrypted!)).toBe("oura-refresh");
|
|
// Every requested scope was granted, so nothing awaits a reconnect.
|
|
expect(user.ouraScopeDenied).toBeNull();
|
|
});
|
|
|
|
it("notes the collections a grant leaves out, straight from the token response", async () => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
if (urlOf(input) !== OURA_TOKEN_URL) throw new Error("unexpected");
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: "oura-access",
|
|
refresh_token: "oura-refresh",
|
|
expires_in: 86400,
|
|
scope:
|
|
"extapi:daily extapi:personal extapi:spo2 extapi:heart_health extapi:workout extapi:heartrate",
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
});
|
|
|
|
const res = await ouraCallback(await mintOuraState());
|
|
|
|
expect(res.headers.get("location")).toContain("oura=connected");
|
|
const user = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: USER },
|
|
select: { ouraScopeDenied: true },
|
|
});
|
|
expect(user.ouraScopeDenied).toEqual(["resilience"]);
|
|
});
|
|
|
|
it("lets both requests see a failed exchange, and a new connect still works", async () => {
|
|
let tokenCalls = 0;
|
|
let refuse = true;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
if (urlOf(input) !== OURA_TOKEN_URL) throw new Error("unexpected");
|
|
tokenCalls++;
|
|
await new Promise((r) => setTimeout(r, PROVIDER_LATENCY_MS));
|
|
if (refuse) {
|
|
return new Response(JSON.stringify({ error: "invalid_request" }), {
|
|
status: 400,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: "second-access",
|
|
refresh_token: "second-refresh",
|
|
expires_in: 86400,
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
});
|
|
|
|
const first = await mintOuraState();
|
|
const [a, b] = await Promise.all([
|
|
ouraCallback(first),
|
|
ouraCallback(first),
|
|
]);
|
|
expect(tokenCalls).toBe(1);
|
|
expect(a.headers.get("location")).toContain("oura=error&reason=token");
|
|
expect(b.headers.get("location")).toBe(a.headers.get("location"));
|
|
|
|
// Starting over mints a new state, which is a new claim.
|
|
refuse = false;
|
|
const retry = await ouraCallback(await mintOuraState(), "fresh-code");
|
|
expect(tokenCalls).toBe(2);
|
|
expect(retry.headers.get("location")).toContain("oura=connected");
|
|
});
|
|
});
|
|
|
|
describe("Oura consent denied, then allowed", () => {
|
|
it("still exchanges the code when the person returns to the same authorize URL", async () => {
|
|
let tokenCalls = 0;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
if (urlOf(input) !== OURA_TOKEN_URL) throw new Error("unexpected");
|
|
tokenCalls++;
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: "oura-access",
|
|
refresh_token: "oura-refresh",
|
|
expires_in: 86400,
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
});
|
|
|
|
const state = await mintOuraState();
|
|
// Deny: Oura returns without a code. Nothing is exchanged or claimed.
|
|
const denied = await ouraCallback(state, null);
|
|
expect(denied.headers.get("location")).toContain(
|
|
"oura=error&reason=nocode",
|
|
);
|
|
expect(tokenCalls).toBe(0);
|
|
|
|
// Allow on the same authorize URL: same state, now with a code.
|
|
const allowed = await ouraCallback(state, "allowed-code");
|
|
expect(tokenCalls).toBe(1);
|
|
expect(allowed.headers.get("location")).toContain("oura=connected");
|
|
});
|
|
});
|
|
|
|
describe("Withings callback delivered twice", () => {
|
|
it("exchanges the code once and sends the duplicate where the first went", async () => {
|
|
const prisma = getPrismaClient();
|
|
const session = await prisma.session.create({
|
|
data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
let tokenCalls = 0;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
const url = urlOf(input);
|
|
if (url === WITHINGS_TOKEN_URL) {
|
|
tokenCalls++;
|
|
await new Promise((r) => setTimeout(r, PROVIDER_LATENCY_MS));
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: 0,
|
|
body: {
|
|
userid: "withings-single-use",
|
|
access_token: "w-access",
|
|
refresh_token: "w-refresh",
|
|
expires_in: 3600,
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
if (url === WITHINGS_NOTIFY_URL) {
|
|
return new Response(JSON.stringify({ status: 0 }), { status: 200 });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
const nonce = "single-use-nonce-22chr";
|
|
await prisma.withingsOAuthState.create({
|
|
data: {
|
|
nonce,
|
|
userId: USER,
|
|
expiresAt: new Date(Date.now() + 600_000),
|
|
},
|
|
});
|
|
|
|
const { GET } = await import("@/app/api/withings/callback/route");
|
|
const request = () =>
|
|
new NextRequest(
|
|
`http://localhost/api/withings/callback?code=w-code&state=${nonce}`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
cookie: `healthlog_session=${session.id}; withings_state=${nonce}`,
|
|
},
|
|
},
|
|
);
|
|
const [a, b] = await Promise.all([GET(request()), GET(request())]);
|
|
|
|
expect(tokenCalls).toBe(1);
|
|
expect(a.headers.get("location")).toContain("withings=connected");
|
|
expect(b.headers.get("location")).toBe(a.headers.get("location"));
|
|
});
|
|
});
|