Standardize error responses in Middleware. Add case for unprovisioned users and session expiration#360
Closed
tarratsco wants to merge 1 commit into
Conversation
…dd case for unprovisioned users and session expiration
tarratsco
pushed a commit
to tarratsco/ztmf-ui
that referenced
this pull request
Jun 22, 2026
…authenticated users now hit a terminal "contact your administrator" state at /signin instead of looping through "Your session has expired".
7 tasks
tarratsco
pushed a commit
to tarratsco/ztmf-ui
that referenced
this pull request
Jun 23, 2026
…authenticated users now hit a terminal "contact your administrator" state at /signin instead of looping through "Your session has expired".
Collaborator
|
Thanks @tarratsco. Rehomed onto an in-repo branch as #362 so Snyk/CI (org secrets unavailable on forks) can run; your commit is preserved as-authored. One small addition there: the no-auth 401 emberfall assertion needed updating to match the new JSON error body. Continuing review on #362. |
danielbowne
added a commit
that referenced
this pull request
Jun 23, 2026
…d expired sessions (#362) Rehome of #360 onto an in-repo branch so Snyk/CI run. Original work and credit: @tarratsco (Onix Tarrats Calderon); his commit is preserved as-authored. ## What this does Middleware now returns a single standardized JSON error shape (`{ "error": ..., "code": ... }`) on every rejection, replacing the prior plaintext `http.Error` responses. This lets the frontend interceptor branch on a stable `code` rather than parsing status alone (see ztmf-ui#403). Rejection cases are now distinguished: - Missing/invalid session: 401 `UNAUTHORIZED` ("your session has expired") - Authenticated identity with no ZTMF account, or a soft-deleted account: 403 `ACCOUNT_NOT_PROVISIONED` (terminal "contact your administrator", no retry loop) - CSRF origin mismatch: 403 `FORBIDDEN_ORIGIN` - Upstream lookup failure (DB blip, decode error): 500 with an opaque body and no code, so a transient failure never triggers the terminal provisioning copy Previously a not-found user and a DB error both collapsed into 401, which the frontend could not tell apart from an expired session. ## Changes layered on top of the original PR - Added `test(emberfall)`: the no-auth 401 assertion on `/users/current` was matching the old plaintext `unauthorized` body; updated it to match the new JSON `code` field. (The original fork PR could not run the E2E suite because Snyk/CI org secrets are unavailable on forks, so this drift was not caught there.) ## Testing - `make test-unit` (auth package): pass, 8/8 including the new `TestMiddleware` cases - `make test-e2e` (isolated ephemeral DB): pass, 118/118 Closes #360 --------- Co-authored-by: Onix Tarrats Calderon <onix.tarratscalderon@aquia.us>
danielbowne
added a commit
to CMS-Enterprise/ztmf-ui
that referenced
this pull request
Jun 23, 2026
…d header (#428) * enshancement(auth): Paired CMS-Enterprise/ztmf#360 Unprovisioned IdP-authenticated users now hit a terminal "contact your administrator" state at /signin instead of looping through "Your session has expired". * fix(auth): surface backend error message for expired session in cold-load 401 path * fix(auth): Remove misleading session expired message for unprovisioned users --------- Co-authored-by: Onix Tarrats Calderon <onix.tarratscalderon@aquia.us>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the BE half of
CMS-Enterprise/ztmf-ui#403.Pre-existing bug: the auth middleware collapsed three semantically distinct failures into one response —
http.Error(w, "unauthorized", 401)for a missing/invalid session, for an IdP-authed identity with no ZTMF row, and for a soft-deleted account. The FE had no signal to tell them apart, so a valid IdP session with no app account rendered as "Your session has expired" with the infinite Sign in loop documented in the issue.This PR splits those cases into distinguishable responses: 401 +
UNAUTHORIZEDfor no/invalid session (semantics unchanged), 403 +ACCOUNT_NOT_PROVISIONEDfor an authenticated identity with no usable app account (never-provisioned and soft-deleted collapse to the same code, distinguished in server logs only), and 500 for upstream lookup failures that aren't a credential problem. CSRF rejections also gain a typed code (FORBIDDEN_ORIGIN).Frontend dependency
Pairs with the FE handling change:
CMS-Enterprise/ztmf-ui#418. The newcodefield only has user-visible effect once the FE starts reading it.Deploy order: FE first, BE second. Mirrors the deploy order stated in #418. With #418 deployed first against the current (unchanged) BE, every new FE code path stays dormant, since the BE never emits
code, so the FE falls back to existing behavior and matches today. Once this BE lands,ACCOUNT_NOT_PROVISIONEDdrives theNO_ACCOUNTterminal state and the loop is broken. The reverse order (this BE first, #418 second) is worse than the current bug: the new 403 + code is unrecognized by the old FE interceptor, which surfaces the BE message via the OOS toast on subsequent/api/*calls, causing a confusing toast on a stuck page instead of the familiar (if misleading) loop. Coordinated single-deploy is best.Changes
backend/cmd/api/internal/auth/middleware.goCodeUnauthorized,CodeForbiddenOrigin,CodeAccountNotProvisioned. Mirrored on the FE inutils/authCodes.ts.http.Error(...)plain-text rejections withwriteJSONError(...). Single shape{error, code}across 401 / 403 / 500. SetsX-Content-Type-Options: nosniffon the response.errors.Is(err, model.ErrNoData)→ 403ACCOUNT_NOT_PROVISIONED; any other non-nil err → 500 (opaque, no code, so the FE falls through toServerErrorPagerather than misclassifying as auth-expired). Local-dev auto-create path unchanged.ACCOUNT_NOT_PROVISIONED(was 401). Same response as never-provisioned; log line distinguishes them for ops/support without exposing the distinction to clients.FORBIDDEN_ORIGIN(was plain "forbidden").findUserByID/findUserByEmailvars so tests can stub without a database. Compile-time signature assertion at the bottom of the file guards against future signature drift in themodelpackage.backend/cmd/api/internal/auth/middleware_test.goTestMiddlewarecovering the three contract cases from #403 plus three regression bonuses:UNAUTHORIZEDUNAUTHORIZEDACCOUNT_NOT_PROVISIONEDACCOUNT_NOT_PROVISIONEDErrNoData) → 500 (no code)Test plan
Backend unit tests:
middleware_test.TestMiddlewarecovers all six cases above.TestClaimsFromRequest,TestIsSafeMethod,TestSameOrigin, plusauth/{ratelimit,session,token}_test.goandcontroller/auth_test.gocontinue to pass.go test ./cmd/api/...andgo vet ./cmd/api/...green.Container smoke (
compose-devrebuilt against this branch). First, mint a bearer for the local admin:UNAUTHORIZEDUNAUTHORIZED(same shape)ACCOUNT_NOT_PROVISIONEDACCOUNT_NOT_PROVISIONEDThe local-dev auto-create branch masks this path under
ENVIRONMENT=local, so flip the env temporarily.Mint a non-admin bearer (e.g.
Admiral.Piett@executor.empire, roleISSO), hit an admin-only route:End-to-end with #418 applied locally:
NoAccountTerminalrenders the BE message verbatim, no Sign in button, no infinite loop. Restore the row → normal flow resumes.Notes for reviewers
{error, code}. Controller-rejected responses (everything throughcontroller.respond) stay at{data, error}with nocodefield. Presence/absence ofcodeis the FE's discriminator. Wideningcodeto controller responses is a separate future change; not needed for this ticket.erroris user-facing copy here. Because the paired FE renders the BE message verbatim on theNoAccountTerminalscreen, the strings use sentence case with periods rather than the lowercase-no-period style oferrors.New(...)sentinels elsewhere in the codebase.ST1005does not apply, because these are response-body strings insidewriteJSONError(...)calls, not error sentinels. The FE keeps aNO_ACCOUNT_FALLBACK_MESSAGEfor resilience if the body is ever empty.ACCOUNT_REVOKED) later if a client-visible distinction is wanted.ErrNoDatalookup errors now 500. Previously a DB connection blip during the user lookup was reported to the FE as 401, misclassifying server failures as auth problems and contributing to the misleading "session expired" UX. The FE now correctly falls through toServerErrorPagefor these.FORBIDDEN_ORIGINso the FE can surface a defensive message rather than the generic 403 toast. Paired FE PR wires this to aconsole.error+ permission toast.