From 376da73205deedd96e769fc0662ab63d3b1c3e14 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sat, 19 Sep 2026 15:52:10 +0200 Subject: [PATCH] fix(auth): report the status when a request is rejected without a body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request rejected by a servlet filter carries no ProblemDetail body, so unwrap threw Error('Missing response body') and the form fell back to a generic "Login failed" — hiding a 401 from the resource-server filter. A non-ok response is now treated as a rejection even with no error payload, and the status is reported in a synthesized ProblemDetail. --- .../auth/__tests__/authService.test.ts | 15 +++++++++ src/features/auth/services/authService.ts | 33 ++++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/features/auth/__tests__/authService.test.ts b/src/features/auth/__tests__/authService.test.ts index e463c56..400d314 100644 --- a/src/features/auth/__tests__/authService.test.ts +++ b/src/features/auth/__tests__/authService.test.ts @@ -171,6 +171,21 @@ describe('authService', () => { await expect(sessionLogin('alice', 'wrong')).rejects.toEqual(errorData) }) + it('sessionLogin reports the status when a filter rejects the request without a body', async () => { + // The resource-server filter answers a stale bearer token with a bodyless + // 401, so the client reports no error payload at all. + clientMocks.sessionLogin.mockResolvedValue({ + data: undefined, + error: undefined, + response: new Response(null, { status: 401, statusText: 'Unauthorized' }), + }) + + await expect(sessionLogin('alice', 'correct-password')).rejects.toMatchObject({ + status: 401, + detail: 'The server rejected the request with status 401.', + }) + }) + it('confirmEmail throws on client error', async () => { const errorData = { title: 'Gone', status: 410 } clientMocks.confirmEmail.mockResolvedValue(fail(errorData)) diff --git a/src/features/auth/services/authService.ts b/src/features/auth/services/authService.ts index c7289c6..44c174f 100644 --- a/src/features/auth/services/authService.ts +++ b/src/features/auth/services/authService.ts @@ -14,6 +14,7 @@ type RegisterData = RegisterUserRequest interface ClientResult { data: T | undefined error: unknown | undefined + response?: Response } const messageResponseSchema = z.record(z.string(), z.string()).transform((data) => ({ @@ -68,10 +69,34 @@ function apiOptions(): { } } +// A request rejected by a servlet filter carries no ProblemDetail body, so the +// status is the only thing the user can be told. Reporting it beats the blanket +// "Login failed" that hid a 401 from the resource-server filter. +function problemFromStatus(response: Response): unknown { + return { + type: 'about:blank', + title: response.statusText || 'Request failed', + status: response.status, + detail: `The server rejected the request with status ${response.status}.`, + } +} + +function rejectionOf(result: ClientResult): unknown { + const { error, response } = result + const hasProblemBody = typeof error === 'object' && error !== null && 'status' in error + if (hasProblemBody) return error + if (response !== undefined && !response.ok) return problemFromStatus(response) + return error ?? new Error('Missing response body') +} + +function isRejected(result: ClientResult): boolean { + return result.error !== undefined || (result.response !== undefined && !result.response.ok) +} + async function unwrap(result: Promise>): Promise> { const response = await result - if (response.error !== undefined) { - throw response.error + if (isRejected(response)) { + throw rejectionOf(response) } if (response.data == null) { throw new Error('Missing response body') @@ -81,8 +106,8 @@ async function unwrap(result: Promise>): Promise>): Promise { const response = await result - if (response.error !== undefined) { - throw response.error + if (isRejected(response)) { + throw rejectionOf(response) } }