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
13 changes: 13 additions & 0 deletions .changeset/empty-upstream-failure-body.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/core/src/handlers/finishLogin.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -46,7 +47,7 @@ export async function finishLoginHandler(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/handlers/finishRegister.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,7 +48,7 @@ export async function finishRegisterHandler(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/handlers/login.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,7 +48,7 @@ export async function loginHandler(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand Down
23 changes: 15 additions & 8 deletions packages/core/src/handlers/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,19 @@ function getLogoutPath(scope: LogoutScope) {
return scope === "all_sessions" ? "/logout/all" : "/logout";
}

export async function logoutHandler(opts: LogoutOptions): Promise<LogoutResult> {
export async function logoutHandler(
opts: LogoutOptions,
): Promise<LogoutResult> {
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,
Expand All @@ -41,7 +46,9 @@ export async function logoutHandler(opts: LogoutOptions): Promise<LogoutResult>
};
}

export function logoutCurrentSessionHandler(opts: Omit<LogoutOptions, "scope">) {
export function logoutCurrentSessionHandler(
opts: Omit<LogoutOptions, "scope">,
) {
return logoutHandler({ ...opts, scope: "current_session" });
}

Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/handlers/oauthHandlers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -40,7 +41,7 @@ export async function listOAuthProvidersHandler(

return {
status: up.status,
...(up.ok ? { body: data } : { errorBody: data }),
...(up.ok ? { body: data } : readPassthroughFailure(data)),
};
}

Expand All @@ -62,7 +63,7 @@ export async function startOAuthLoginHandler(

return {
status: up.status,
...(up.ok ? { body: data } : { errorBody: data }),
...(up.ok ? { body: data } : readPassthroughFailure(data)),
};
}

Expand All @@ -85,7 +86,7 @@ export async function finishOAuthLoginHandler(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -50,7 +51,7 @@ export async function pollMagicLinkConfirmationHandler(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/handlers/register.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -48,7 +49,7 @@ export async function registerHandler(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/handlers/requestMagicLinkHandler.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -39,7 +40,7 @@ export async function requestMagicLinkHandler(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/handlers/requestOtpHandler.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -51,7 +52,7 @@ export async function requestOtpHandler(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/handlers/switchOrganizationHandler.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,7 +48,7 @@ export async function switchOrganizationHandler(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/handlers/verifyLoginOtpHandler.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -53,7 +54,7 @@ async function verifyOtp(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/handlers/verifyMagicLinkHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { authFetch } from "../authFetch.js";
import { readPassthroughFailure } from "../upstreamError.js";
import type { ResultFailure } from "../result.js";

export interface VerifyMagicLinkInput {
Expand Down Expand Up @@ -34,7 +35,7 @@ export async function verifyMagicLinkHandler(
if (!up.ok) {
return {
status: up.status,
errorBody: data,
...readPassthroughFailure(data),
};
}

Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/upstreamError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ function isObject(value: unknown): value is Record<string, unknown> {
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.
Expand Down
46 changes: 46 additions & 0 deletions packages/core/tests/passthroughFailure.test.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
13 changes: 13 additions & 0 deletions packages/express/tests/failureWireFormat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading