From f1655513c22b0c7173f54832b603edfe9d1ecae5 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 29 Jul 2026 23:25:57 -0400 Subject: [PATCH] fix(core): return a code instead of an empty response for an empty failure body The auth-flow routes forward the API's failure body rather than interpreting it, and an empty body left nothing to forward: the caller got a bare 4xx with no content, and the React SDK fell back to a generic message with no way to tell an expired session from a rate limit from an upstream outage. The proxy routes already had a fallback, so the two families disagreed on the one case where the caller had least to go on. readPassthroughFailure forwards a body when there is one and returns upstream_error when there is not. A present body is still untouched, including the top-level code the SDK reads for OAuth failures. Closes #125 --- .changeset/empty-upstream-failure-body.md | 13 ++++++ packages/core/src/handlers/finishLogin.ts | 3 +- packages/core/src/handlers/finishRegister.ts | 6 +-- packages/core/src/handlers/login.ts | 3 +- packages/core/src/handlers/logout.ts | 23 ++++++---- packages/core/src/handlers/oauthHandlers.ts | 7 +-- .../pollMagicLinkConfirmationHandler.ts | 3 +- packages/core/src/handlers/register.ts | 3 +- .../src/handlers/requestMagicLinkHandler.ts | 3 +- .../core/src/handlers/requestOtpHandler.ts | 3 +- .../src/handlers/switchOrganizationHandler.ts | 3 +- .../src/handlers/verifyLoginOtpHandler.ts | 3 +- .../src/handlers/verifyMagicLinkHandler.ts | 3 +- packages/core/src/upstreamError.ts | 25 ++++++++++ .../core/tests/passthroughFailure.test.js | 46 +++++++++++++++++++ .../express/tests/failureWireFormat.test.js | 13 ++++++ 16 files changed, 137 insertions(+), 23 deletions(-) create mode 100644 .changeset/empty-upstream-failure-body.md create mode 100644 packages/core/tests/passthroughFailure.test.js diff --git a/.changeset/empty-upstream-failure-body.md b/.changeset/empty-upstream-failure-body.md new file mode 100644 index 0000000..b105e35 --- /dev/null +++ b/.changeset/empty-upstream-failure-body.md @@ -0,0 +1,13 @@ +--- +"@seamless-auth/core": patch +--- + +Return a code instead of an empty response when the auth API fails with no body. + +The auth-flow routes forward the API's failure body rather than interpreting it, and an empty body left nothing to forward: the caller got a bare 4xx with no content, and `seamless-auth-react` fell back to its per-call generic message with no way to tell an expired session from a rate limit from an upstream outage. The proxy routes already handled this, so the two families disagreed on the one case where the caller had least to go on. + +An empty failure body now becomes `{ "error": "upstream_error" }`. A body that is present is still forwarded untouched, including the top-level `code` the React SDK reads to tell OAuth failures apart. + +New exports: `readPassthroughFailure` and `UPSTREAM_ERROR_CODE`. + +Closes #125. diff --git a/packages/core/src/handlers/finishLogin.ts b/packages/core/src/handlers/finishLogin.ts index e689e3c..a8e8451 100644 --- a/packages/core/src/handlers/finishLogin.ts +++ b/packages/core/src/handlers/finishLogin.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import type { ResultFailure } from "../result.js"; import type { CookiePayload } from "../ensureCookies.js"; import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; @@ -46,7 +47,7 @@ export async function finishLoginHandler( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } diff --git a/packages/core/src/handlers/finishRegister.ts b/packages/core/src/handlers/finishRegister.ts index 0a7294e..9b6ce1b 100644 --- a/packages/core/src/handlers/finishRegister.ts +++ b/packages/core/src/handlers/finishRegister.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import type { ResultFailure } from "../result.js"; import type { CookiePayload } from "../ensureCookies.js"; import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; @@ -47,7 +48,7 @@ export async function finishRegisterHandler( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } @@ -65,8 +66,7 @@ export async function finishRegisterHandler( throw new Error("Signature mismatch with data payload"); } - const sessionId = - typeof verified.sid === "string" ? verified.sid : undefined; + const sessionId = typeof verified.sid === "string" ? verified.sid : undefined; return { status: 204, diff --git a/packages/core/src/handlers/login.ts b/packages/core/src/handlers/login.ts index 990d39c..5cb54f3 100644 --- a/packages/core/src/handlers/login.ts +++ b/packages/core/src/handlers/login.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import type { ResultFailure } from "../result.js"; import type { CookiePayload } from "../ensureCookies.js"; import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; @@ -47,7 +48,7 @@ export async function loginHandler( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } diff --git a/packages/core/src/handlers/logout.ts b/packages/core/src/handlers/logout.ts index 88833a7..c756eeb 100644 --- a/packages/core/src/handlers/logout.ts +++ b/packages/core/src/handlers/logout.ts @@ -22,14 +22,19 @@ function getLogoutPath(scope: LogoutScope) { return scope === "all_sessions" ? "/logout/all" : "/logout"; } -export async function logoutHandler(opts: LogoutOptions): Promise { +export async function logoutHandler( + opts: LogoutOptions, +): Promise { const scope = opts.scope ?? "all_sessions"; - const upstream = await authFetch(`${opts.authServerUrl}${getLogoutPath(scope)}`, { - method: "DELETE", - authorization: opts.authorization, - serviceAuthorization: opts.serviceAuthorization, - forwardedClientIp: opts.forwardedClientIp, - }); + const upstream = await authFetch( + `${opts.authServerUrl}${getLogoutPath(scope)}`, + { + method: "DELETE", + authorization: opts.authorization, + serviceAuthorization: opts.serviceAuthorization, + forwardedClientIp: opts.forwardedClientIp, + }, + ); return { status: upstream.ok ? 204 : upstream.status, @@ -41,7 +46,9 @@ export async function logoutHandler(opts: LogoutOptions): Promise }; } -export function logoutCurrentSessionHandler(opts: Omit) { +export function logoutCurrentSessionHandler( + opts: Omit, +) { return logoutHandler({ ...opts, scope: "current_session" }); } diff --git a/packages/core/src/handlers/oauthHandlers.ts b/packages/core/src/handlers/oauthHandlers.ts index 3901246..3c7e924 100644 --- a/packages/core/src/handlers/oauthHandlers.ts +++ b/packages/core/src/handlers/oauthHandlers.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import type { ResultFailure } from "../result.js"; import type { CookiePayload } from "../ensureCookies.js"; import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; @@ -40,7 +41,7 @@ export async function listOAuthProvidersHandler( return { status: up.status, - ...(up.ok ? { body: data } : { errorBody: data }), + ...(up.ok ? { body: data } : readPassthroughFailure(data)), }; } @@ -62,7 +63,7 @@ export async function startOAuthLoginHandler( return { status: up.status, - ...(up.ok ? { body: data } : { errorBody: data }), + ...(up.ok ? { body: data } : readPassthroughFailure(data)), }; } @@ -85,7 +86,7 @@ export async function finishOAuthLoginHandler( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } diff --git a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts index 9f98718..80d974c 100644 --- a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts +++ b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import type { ResultFailure } from "../result.js"; import type { CookiePayload } from "../ensureCookies.js"; import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; @@ -50,7 +51,7 @@ export async function pollMagicLinkConfirmationHandler( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } diff --git a/packages/core/src/handlers/register.ts b/packages/core/src/handlers/register.ts index 5602e72..be3b7a1 100644 --- a/packages/core/src/handlers/register.ts +++ b/packages/core/src/handlers/register.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import { EXTERNAL_DELIVERY_HEADERS } from "../apiContract.js"; import type { ResultFailure } from "../result.js"; import type { CookiePayload } from "../ensureCookies.js"; @@ -48,7 +49,7 @@ export async function registerHandler( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } diff --git a/packages/core/src/handlers/requestMagicLinkHandler.ts b/packages/core/src/handlers/requestMagicLinkHandler.ts index fac59e5..70a1420 100644 --- a/packages/core/src/handlers/requestMagicLinkHandler.ts +++ b/packages/core/src/handlers/requestMagicLinkHandler.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import { EXTERNAL_DELIVERY_HEADERS } from "../apiContract.js"; import type { ResultFailure } from "../result.js"; @@ -39,7 +40,7 @@ export async function requestMagicLinkHandler( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } diff --git a/packages/core/src/handlers/requestOtpHandler.ts b/packages/core/src/handlers/requestOtpHandler.ts index e7c3c32..e77c559 100644 --- a/packages/core/src/handlers/requestOtpHandler.ts +++ b/packages/core/src/handlers/requestOtpHandler.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import { EXTERNAL_DELIVERY_HEADERS } from "../apiContract.js"; import type { ResultFailure } from "../result.js"; @@ -51,7 +52,7 @@ export async function requestOtpHandler( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } diff --git a/packages/core/src/handlers/switchOrganizationHandler.ts b/packages/core/src/handlers/switchOrganizationHandler.ts index 949e6b6..ecaa57a 100644 --- a/packages/core/src/handlers/switchOrganizationHandler.ts +++ b/packages/core/src/handlers/switchOrganizationHandler.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import type { ResultFailure } from "../result.js"; import type { CookiePayload } from "../ensureCookies.js"; import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; @@ -47,7 +48,7 @@ export async function switchOrganizationHandler( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } diff --git a/packages/core/src/handlers/verifyLoginOtpHandler.ts b/packages/core/src/handlers/verifyLoginOtpHandler.ts index 65a0e78..10da93a 100644 --- a/packages/core/src/handlers/verifyLoginOtpHandler.ts +++ b/packages/core/src/handlers/verifyLoginOtpHandler.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import type { ResultFailure } from "../result.js"; import type { CookiePayload } from "../ensureCookies.js"; import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; @@ -53,7 +54,7 @@ async function verifyOtp( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } diff --git a/packages/core/src/handlers/verifyMagicLinkHandler.ts b/packages/core/src/handlers/verifyMagicLinkHandler.ts index 65e1e56..588cbfc 100644 --- a/packages/core/src/handlers/verifyMagicLinkHandler.ts +++ b/packages/core/src/handlers/verifyMagicLinkHandler.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { readPassthroughFailure } from "../upstreamError.js"; import type { ResultFailure } from "../result.js"; export interface VerifyMagicLinkInput { @@ -34,7 +35,7 @@ export async function verifyMagicLinkHandler( if (!up.ok) { return { status: up.status, - errorBody: data, + ...readPassthroughFailure(data), }; } diff --git a/packages/core/src/upstreamError.ts b/packages/core/src/upstreamError.ts index 91a5260..175c267 100644 --- a/packages/core/src/upstreamError.ts +++ b/packages/core/src/upstreamError.ts @@ -4,6 +4,31 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } +/** + * Last-resort code for a failure the auth API sent with no body at all. + * + * A passthrough route has nothing else to say: it does not interpret the + * response, so when there is no body there is no detail to forward. + */ +export const UPSTREAM_ERROR_CODE = "upstream_error"; + +/** + * Reads an upstream failure for a route that forwards the auth API's response + * rather than interpreting it. + * + * The body goes through verbatim whenever there is one, because callers read + * fields off it directly and reshaping it breaks them. An empty body has nothing + * to forward, and returning it as-is produced a bare status with no body, which + * left the caller with nothing to act on and the SDK falling back to a generic + * message (#125). That case becomes a code instead. + */ +export function readPassthroughFailure( + data: unknown, + fallback: string = UPSTREAM_ERROR_CODE, +): ResultFailure { + return isObject(data) ? { errorBody: data } : { errorCode: fallback }; +} + /** * Reads an upstream error body into the `{ error, details }` pair the proxy * handlers return. diff --git a/packages/core/tests/passthroughFailure.test.js b/packages/core/tests/passthroughFailure.test.js new file mode 100644 index 0000000..8001ab8 --- /dev/null +++ b/packages/core/tests/passthroughFailure.test.js @@ -0,0 +1,46 @@ +// A passthrough route forwards the auth API's failure body rather than +// interpreting it. The one case it cannot forward is an empty body, which used +// to produce a bare status with nothing in it (#125). +const { readPassthroughFailure, UPSTREAM_ERROR_CODE } = await import( + "../dist/upstreamError.js" +); + +describe("readPassthroughFailure", () => { + it.each([ + ["a coded body", { error: "account_locked" }], + [ + "an OAuth body with a sibling code", + { error: "oauth_profile_error", code: "oauth_email_not_verified" }, + ], + ["a validation body", { name: "ZodError", message: "bad" }], + ["a message-only body", { message: "Too many requests." }], + ])("forwards %s untouched", (_label, body) => { + expect(readPassthroughFailure(body)).toEqual({ errorBody: body }); + }); + + it.each([ + ["undefined", undefined], + ["null", null], + ["a bare string", "nope"], + ["a number", 0], + ])("falls back to a code for %s", (_label, body) => { + expect(readPassthroughFailure(body)).toEqual({ + errorCode: UPSTREAM_ERROR_CODE, + }); + }); + + it("accepts a caller-supplied fallback", () => { + expect(readPassthroughFailure(undefined, "login_unavailable")).toEqual({ + errorCode: "login_unavailable", + }); + }); + + it("never returns both a body and a code", () => { + for (const body of [undefined, null, {}, { error: "x" }, "s"]) { + const result = readPassthroughFailure(body); + expect( + result.errorBody === undefined || result.errorCode === undefined, + ).toBe(true); + } + }); +}); diff --git a/packages/express/tests/failureWireFormat.test.js b/packages/express/tests/failureWireFormat.test.js index 61585c8..53be58d 100644 --- a/packages/express/tests/failureWireFormat.test.js +++ b/packages/express/tests/failureWireFormat.test.js @@ -100,6 +100,19 @@ describe("failure wire format", () => { }); }); + // An empty upstream body has nothing to forward. Returning it as-is produced a + // bare status with no body, so the SDK showed a generic message (#125). + it("falls back to a code when the upstream body is empty", async () => { + global.fetch.mockResolvedValue(createJsonResponse(400, undefined)); + + const res = await request(createApp()) + .post("/auth/login") + .send({ identifier: "someone@example.com" }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "upstream_error" }); + }); + describe("a proxy route normalizes to a code", () => { async function patchUser(upstream) { global.fetch.mockResolvedValue(createJsonResponse(400, upstream));