Tech debt: callApi's blanket 403 handler conflates "session expired" with "no access to this resource" #154

Closed
opened 2026-09-02 08:01:55 +02:00 by lena · 1 comment
lena commented 2026-09-02 08:01:55 +02:00 (Migrated from git.butzei.de)

Story: distinguish session-expiry 403s from resource-authorization 403s in callApi

As a user whose session is still valid,
I want to never get force-logged-out just because one specific request 403'd for an
authorization reason unrelated to my session,
so that an unrelated access error never looks like "you got logged out".

Background

Found during #151 ("deleted list on direct URL shows login screen instead of redirect").
ReactUi/src/api/api.tsx's callApi force-clears userId (logs the whole app out) on any
403 response that isn't marked skipErrorToast:

if (x.status === 403) {
    useStore.getState().setUserId(null);
    throw new Error(err);
}

The backend returns 403 for two genuinely different reasons that this code treats identically:

  1. The session itself is no longer valid (the actual "please log in again" case).
  2. AuthorizeTodoListAccessQueryHandler (and its Shopping/Pantry/MasterPacking equivalents)
    throwing UnauthorizedAccessException because the requested resource doesn't exist or the
    user has no access to it - e.g. a deleted/foreign list id - which also maps to 403 via
    ExceptionHandler.cs.

#151 fixed the direct-navigation symptom with a client-side guard in Application.tsx (redirect
to the overview before any list-scoped call can fire for an id not in the loaded list), but that
guard only covers the one route-level entry point it was added to. It does not fix the
underlying conflation, so the same force-logout can still be reproduced by:

  • A future list-scoped call added anywhere else in the app (e.g. following the existing
    CsvExportDialog.tsx/InvitePanel.tsx pattern of calling callApi with a raw list id and no
    existence check) that fires before #151's guard has resolved.
  • A request already in flight (or triggered by an open dialog reacting to the same delete event)
    at the exact moment a list is deleted live - the dialog's in-flight/queued call still resolves
    against the now-deleted resource and 403s.

Acceptance criteria:

  • The backend distinguishes the two 403 causes in a way the frontend can act on (e.g. a
    distinct error code/body field, or a different status code such as 404 for "resource
    doesn't exist/no access" vs 401/403 reserved for "no valid session").
  • callApi only force-clears userId for the genuine session-expiry case.
  • Regression test: a resource-authorization 403 (e.g. an API call against a deleted/foreign
    list id) does not clear userId or show the login screen while the session is otherwise
    valid.

Out of scope for this story:

  • Re-litigating #151's client-side guard itself - that stays as defense-in-depth regardless of
    this fix.
  • Any change to how sessions themselves expire or get invalidated - only the signal the
    frontend uses to detect it.
# Story: distinguish session-expiry 403s from resource-authorization 403s in `callApi` **As a** user whose session is still valid, **I want to** never get force-logged-out just because one specific request 403'd for an authorization reason unrelated to my session, **so that** an unrelated access error never looks like "you got logged out". ## Background Found during #151 ("deleted list on direct URL shows login screen instead of redirect"). `ReactUi/src/api/api.tsx`'s `callApi` force-clears `userId` (logs the whole app out) on **any** 403 response that isn't marked `skipErrorToast`: ```ts if (x.status === 403) { useStore.getState().setUserId(null); throw new Error(err); } ``` The backend returns 403 for two genuinely different reasons that this code treats identically: 1. The session itself is no longer valid (the actual "please log in again" case). 2. `AuthorizeTodoListAccessQueryHandler` (and its Shopping/Pantry/MasterPacking equivalents) throwing `UnauthorizedAccessException` because the requested resource doesn't exist or the user has no access to it - e.g. a deleted/foreign list id - which also maps to 403 via `ExceptionHandler.cs`. #151 fixed the direct-navigation symptom with a client-side guard in `Application.tsx` (redirect to the overview before any list-scoped call can fire for an id not in the loaded list), but that guard only covers the one route-level entry point it was added to. It does **not** fix the underlying conflation, so the same force-logout can still be reproduced by: - A future list-scoped call added anywhere else in the app (e.g. following the existing `CsvExportDialog.tsx`/`InvitePanel.tsx` pattern of calling `callApi` with a raw list id and no existence check) that fires before #151's guard has resolved. - A request already in flight (or triggered by an open dialog reacting to the same delete event) at the exact moment a list is deleted live - the dialog's in-flight/queued call still resolves against the now-deleted resource and 403s. **Acceptance criteria:** - [ ] The backend distinguishes the two 403 causes in a way the frontend can act on (e.g. a distinct error code/body field, or a different status code such as 404 for "resource doesn't exist/no access" vs 401/403 reserved for "no valid session"). - [ ] `callApi` only force-clears `userId` for the genuine session-expiry case. - [ ] Regression test: a resource-authorization 403 (e.g. an API call against a deleted/foreign list id) does not clear `userId` or show the login screen while the session is otherwise valid. **Out of scope for this story:** - Re-litigating #151's client-side guard itself - that stays as defense-in-depth regardless of this fix. - Any change to how sessions themselves expire or get invalidated - only the *signal* the frontend uses to detect it.
lena commented 2026-09-02 08:11:17 +02:00 (Migrated from git.butzei.de)

Resolved as part of #151 itself, not as a separate follow-up: commit b6a6d55807 (a concurrent session working the same issue) already did exactly what this issue asked for - swapped the "not logged in" branch of all 15 Authorize*QueryHandler.cs files from UnauthorizedAccessException (403) to AuthenticationException (401), and simplified callApi's frontend handling to a plain 401 check. This issue was drafted before that commit was discovered (two sessions worked #151 concurrently without realizing it). Closing as a duplicate-in-effect rather than leaving it open.

Resolved as part of #151 itself, not as a separate follow-up: commit b6a6d558071847630ede1b3d40d919402027a6c0 (a concurrent session working the same issue) already did exactly what this issue asked for - swapped the "not logged in" branch of all 15 Authorize*QueryHandler.cs files from UnauthorizedAccessException (403) to AuthenticationException (401), and simplified callApi's frontend handling to a plain 401 check. This issue was drafted before that commit was discovered (two sessions worked #151 concurrently without realizing it). Closing as a duplicate-in-effect rather than leaving it open.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#154
No description provided.