Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/adapter-non-json-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@seamless-auth/core": patch
"@seamless-auth/express": patch
---

Don't crash on non-JSON upstream responses. `authFetch` now parses response bodies
defensively, so a plain-text error (e.g. a rate-limited `429 Too many requests`) or an
empty body (`204`) no longer throws in handlers that read the body before checking the
status — which previously surfaced as an unhandled rejection that took down the adapter
process. Non-JSON bodies are returned as `{ message: <text> }`; empty bodies as
`undefined`. Fixes #41.
30 changes: 29 additions & 1 deletion packages/core/src/authFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,37 @@ export async function authFetch(
: {}),
};

return fetch(url, {
const response = await fetch(url, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});

return makeJsonTolerant(response);
}

// Upstream responses aren't always JSON: a rate-limited request comes back as plain
// text ("Too many requests…") and a 204 has no body. Native Response.json() throws on
// both, which would crash callers that parse the body before checking the status. Make
// json() tolerant so callers always get a value — parsed JSON, { message: text } for a
// non-JSON body, or undefined for an empty one.
function makeJsonTolerant(response: Response): Response {
if (typeof response.text !== "function") {
return response;
}

const readText = response.text.bind(response);
response.json = async () => {
const text = await readText();
if (!text) {
return undefined;
}
try {
return JSON.parse(text);
} catch {
return { message: text };
}
};

return response;
}
45 changes: 45 additions & 0 deletions packages/core/tests/authFetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,49 @@ describe("authFetch", () => {
}),
);
});

it("does not throw when the response body is non-JSON (e.g. a 429)", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 429,
text: async () => "Too many requests, please try again later.",
});

const { authFetch } = await import("../dist/authFetch.js");
const res = await authFetch("https://auth.example.com/otp/verify-email-otp", {
method: "POST",
});

await expect(res.json()).resolves.toEqual({
message: "Too many requests, please try again later.",
});
});

it("parses a JSON body normally", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({ token: "abc", sub: "user-1" }),
});

const { authFetch } = await import("../dist/authFetch.js");
const res = await authFetch("https://auth.example.com/login", { method: "POST" });

await expect(res.json()).resolves.toEqual({ token: "abc", sub: "user-1" });
});

it("returns undefined for an empty body (e.g. a 204)", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 204,
text: async () => "",
});

const { authFetch } = await import("../dist/authFetch.js");
const res = await authFetch("https://auth.example.com/magic-link/check", {
method: "GET",
});

await expect(res.json()).resolves.toBeUndefined();
});
});
Loading