AuthInterceptor returns JSON for HTML navigations — users see raw {"status":"Unauthorized"} #2

Closed
opened 2026-04-25 11:53:24 +02:00 by uwe.admin · 5 comments
Owner

Problem

AuthInterceptor::intercept always responds with application/json (makeUnauthorized / makeForbidden) when a request is rejected. That's correct for /api/* callers, but when a browser navigates to a non-public HTML route (e.g. an expired/invalid /set-password?token=… link, a stale /admin bookmark after logout, or any deep link before login), the user sees a raw JSON blob in the browser window instead of a meaningful page.

Observed while debugging the /set-password 401 in palibu (fixed in 46971ac by stripping the query string — but a valid path with an invalid token still returns JSON to the browser).

Proposed approach

Content-negotiate in AuthInterceptor:

  • If the request looks like an API call — path starts with /api/, or Accept header prefers application/json, or X-Requested-With: XMLHttpRequest is present → keep current JSON 401/403.
  • Otherwise (browser HTML navigation) → respond with a small HTML page or a 302 redirect to a configurable login/error route (e.g. /login?next=… or /).

The target route should be supplied by IAuthPolicy / IRuntimeConfig so each downstream app can decide where to send unauthenticated browsers (template apps could default to /).

Acceptance criteria

  • Browser navigation to a protected HTML route while unauthenticated → user lands on a human-readable page, never raw JSON.
  • fetch() / axios calls from the SPA still receive 401/403 JSON so client-side error handling keeps working.
  • Behaviour is configurable per app (no hard-coded /login path in the library).
  • Covered by an integration test in oatpp-authkit's test suite.

Context

Found while investigating the /set-password UX in palibu (see oatpp-authkit commit 46971ac for the related query-string fix).

## Problem `AuthInterceptor::intercept` always responds with `application/json` (`makeUnauthorized` / `makeForbidden`) when a request is rejected. That's correct for `/api/*` callers, but when a *browser navigates* to a non-public HTML route (e.g. an expired/invalid `/set-password?token=…` link, a stale `/admin` bookmark after logout, or any deep link before login), the user sees a raw JSON blob in the browser window instead of a meaningful page. Observed while debugging the `/set-password` 401 in palibu (fixed in 46971ac by stripping the query string — but a *valid* path with an *invalid* token still returns JSON to the browser). ## Proposed approach Content-negotiate in `AuthInterceptor`: - If the request looks like an API call — path starts with `/api/`, or `Accept` header prefers `application/json`, or `X-Requested-With: XMLHttpRequest` is present → keep current JSON 401/403. - Otherwise (browser HTML navigation) → respond with a small HTML page or a `302` redirect to a configurable login/error route (e.g. `/login?next=…` or `/`). The target route should be supplied by `IAuthPolicy` / `IRuntimeConfig` so each downstream app can decide where to send unauthenticated browsers (template apps could default to `/`). ## Acceptance criteria - Browser navigation to a protected HTML route while unauthenticated → user lands on a human-readable page, never raw JSON. - `fetch()` / `axios` calls from the SPA still receive `401`/`403` JSON so client-side error handling keeps working. - Behaviour is configurable per app (no hard-coded `/login` path in the library). - Covered by an integration test in oatpp-authkit's test suite. ## Context Found while investigating the `/set-password` UX in palibu (see oatpp-authkit commit 46971ac for the related query-string fix).
Author
Owner

Agent Evaluation

Feasibility: Straightforward. AuthInterceptor::intercept (header-only, ~180 LOC) is the only call site for makeUnauthorized / makeForbidden. Adding a content-negotiation helper plus a policy hook is a localised change.

Impact: Direct UX win for any downstream that mixes server-rendered or static HTML routes with the API (palibu, fewo-webapp's set-password and reset flows). Today the user sees a raw {"status":"Unauthorized"} blob; afterwards they land on a real page. No effect on JSON-only consumers.

Effort: Small.

Recommendation: Accept.

Implementation plan

  1. AuthInterceptor.hpp — content-negotiation helper. Add static bool wantsJson(const IncomingRequest&): true if path starts with /api/, OR X-Requested-With: XMLHttpRequest, OR Accept header prefers application/json over text/html (substring scan is fine — the only goal is to distinguish fetch/axios from a browser navigation).
  2. IAuthPolicy.hpp — new virtual. Add virtual std::optional<std::string> unauthenticatedRedirect(const std::string& path) { return std::nullopt; } (default: keep current behaviour). Consumers that want browser redirects override it to return e.g. "/login?next=" + urlencode(path) or "/". Returning nullopt means "no override — fall through to JSON 401".
  3. makeUnauthorized / makeForbidden — branch on negotiation. New signature takes the request. If wantsJson(req) → existing JSON response. Else if policy->unauthenticatedRedirect(path) returns a value → 302 with Location: set. Else → minimal HTML error page (<!doctype html><title>401</title><h1>Unauthorized</h1>) with Content-Type: text/html; charset=utf-8.
  4. 403 path. Same treatment for makeForbidden so logged-in-but-unauthorized browser nav also gets a real page rather than JSON. Use a separate policy hook forbiddenRedirect (or reuse the same one — see Decision needed).
  5. Test coverage. oatpp-authkit currently has no test suite (no test/ directory; CI/build is header-only find_package only). Adding the first integration test means standing up a minimal oatpp test harness — non-trivial scope creep. Pragmatic alternative: add the test in the consumer that motivated this (palibu or fewo-webapp), where a Selenium/HTTP harness already exists. Note this in the PR description.
  6. Docs. One-paragraph note in README.md under the IAuthPolicy section showing the new override and how to wire Accept-based negotiation.

Decision needed

Check one (edit this comment):

  • Option A — Single redirect hook for both 401 and 403unauthenticatedRedirect(path) covers both cases; logged-in-but-forbidden browser nav also redirects there (typical pattern: bounce to /). Simpler API.
  • Option B — Separate hooks for 401 vs 403unauthenticatedRedirect(path) (login page) and forbiddenRedirect(path) (e.g. a /forbidden info page). More flexible, two virtuals to override.

Test coverage decision

Check one (edit this comment):

  • Option T1 — Add the test in the consumer (palibu/fewo-webapp) — keep oatpp-authkit header-only with no test harness; motivating consumer's existing Selenium suite covers it.
  • Option T2 — Bootstrap an oatpp-authkit test harness now — adds CMake enable_testing() + a minimal oatpp app fixture; future tests have somewhere to land but this PR grows considerably.
## Agent Evaluation **Feasibility:** Straightforward. `AuthInterceptor::intercept` (header-only, ~180 LOC) is the only call site for `makeUnauthorized` / `makeForbidden`. Adding a content-negotiation helper plus a policy hook is a localised change. **Impact:** Direct UX win for any downstream that mixes server-rendered or static HTML routes with the API (palibu, fewo-webapp's set-password and reset flows). Today the user sees a raw `{"status":"Unauthorized"}` blob; afterwards they land on a real page. No effect on JSON-only consumers. **Effort:** Small. **Recommendation:** Accept. ### Implementation plan 1. **`AuthInterceptor.hpp` — content-negotiation helper.** Add `static bool wantsJson(const IncomingRequest&)`: true if path starts with `/api/`, OR `X-Requested-With: XMLHttpRequest`, OR `Accept` header prefers `application/json` over `text/html` (substring scan is fine — the only goal is to distinguish `fetch`/`axios` from a browser navigation). 2. **`IAuthPolicy.hpp` — new virtual.** Add `virtual std::optional<std::string> unauthenticatedRedirect(const std::string& path) { return std::nullopt; }` (default: keep current behaviour). Consumers that want browser redirects override it to return e.g. `"/login?next=" + urlencode(path)` or `"/"`. Returning `nullopt` means "no override — fall through to JSON 401". 3. **`makeUnauthorized` / `makeForbidden` — branch on negotiation.** New signature takes the request. If `wantsJson(req)` → existing JSON response. Else if `policy->unauthenticatedRedirect(path)` returns a value → `302` with `Location:` set. Else → minimal HTML error page (`<!doctype html><title>401</title><h1>Unauthorized</h1>`) with `Content-Type: text/html; charset=utf-8`. 4. **403 path.** Same treatment for `makeForbidden` so logged-in-but-unauthorized browser nav also gets a real page rather than JSON. Use a separate policy hook `forbiddenRedirect` (or reuse the same one — see Decision needed). 5. **Test coverage.** oatpp-authkit currently has no test suite (no `test/` directory; CI/build is header-only `find_package` only). Adding the first integration test means standing up a minimal oatpp test harness — non-trivial scope creep. Pragmatic alternative: add the test in the consumer that motivated this (palibu or fewo-webapp), where a Selenium/HTTP harness already exists. Note this in the PR description. 6. **Docs.** One-paragraph note in `README.md` under the `IAuthPolicy` section showing the new override and how to wire `Accept`-based negotiation. ### Decision needed Check one (edit this comment): - [x] **Option A — Single redirect hook for both 401 and 403** — `unauthenticatedRedirect(path)` covers both cases; logged-in-but-forbidden browser nav also redirects there (typical pattern: bounce to `/`). Simpler API. - [ ] **Option B — Separate hooks for 401 vs 403** — `unauthenticatedRedirect(path)` (login page) and `forbiddenRedirect(path)` (e.g. a `/forbidden` info page). More flexible, two virtuals to override. ### Test coverage decision Check one (edit this comment): - [ ] **Option T1 — Add the test in the consumer (palibu/fewo-webapp)** — keep oatpp-authkit header-only with no test harness; motivating consumer's existing Selenium suite covers it. - [x] **Option T2 — Bootstrap an oatpp-authkit test harness now** — adds CMake `enable_testing()` + a minimal oatpp app fixture; future tests have somewhere to land but this PR grows considerably.
Author
Owner

Evaluated #2 — Small, recommend accept; two decision checkboxes (single vs split redirect hook; consumer-side test vs bootstrap test harness).

Evaluated #2 — Small, recommend accept; two decision checkboxes (single vs split redirect hook; consumer-side test vs bootstrap test harness).
uwe.admin added the
evaluated
label 2026-04-25 11:57:52 +02:00
u.schuster added the
accepted
label 2026-04-25 13:15:02 +02:00
Author
Owner

Re-evaluated #2 — owner picked T2 (bootstrap test harness); A/B redirect-hook decision still pending, will default to A next cycle if unchecked.

Re-evaluated #2 — owner picked T2 (bootstrap test harness); A/B redirect-hook decision still pending, will default to A next cycle if unchecked.
Author
Owner

Re-evaluation

Owner has accepted the issue and selected Option T2 — Bootstrap an oatpp-authkit test harness now for test coverage. Acknowledged: I will land CMake enable_testing() + a minimal oatpp app fixture in this PR so future tests have somewhere to live.

One decision still pending: Option A vs Option B (single vs split redirect hook). The hook signature is part of the public IAuthPolicy API, so flipping it later is a breaking change for any consumer that overrides it. Worth nailing down before implementation.

Default if not picked: Option A (single unauthenticatedRedirect(path) hook used for both 401 and 403). Rationale: the most common downstream wiring is "bounce browsers to / or /login" regardless of whether the failure was 401 or 403; consumers that want to differentiate can branch inside the hook on whether the request was authenticated. Two hooks add API surface for a case I have not seen in fewo-webapp / palibu / webapp-template.

Will hold off on implementation for one more poll cycle; if the A/B box is still unchecked next pass, I will proceed with Option A.

## Re-evaluation Owner has accepted the issue and selected **Option T2 — Bootstrap an oatpp-authkit test harness now** for test coverage. Acknowledged: I will land CMake `enable_testing()` + a minimal oatpp app fixture in this PR so future tests have somewhere to live. **One decision still pending:** Option A vs Option B (single vs split redirect hook). The hook signature is part of the public `IAuthPolicy` API, so flipping it later is a breaking change for any consumer that overrides it. Worth nailing down before implementation. **Default if not picked:** Option A (single `unauthenticatedRedirect(path)` hook used for both 401 and 403). Rationale: the most common downstream wiring is "bounce browsers to `/` or `/login`" regardless of whether the failure was 401 or 403; consumers that want to differentiate can branch inside the hook on whether the request was authenticated. Two hooks add API surface for a case I have not seen in fewo-webapp / palibu / webapp-template. Will hold off on implementation for one more poll cycle; if the A/B box is still unchecked next pass, I will proceed with Option A.
Author
Owner

Implemented in abf6153 — content-negotiating JSON vs HTML/redirect (Option A), test harness bootstrapped under test/ (Option T2), README updated. ctest passes.

Implemented in abf6153 — content-negotiating JSON vs HTML/redirect (Option A), test harness bootstrapped under `test/` (Option T2), README updated. ctest passes.
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: uwe.admin/oatpp-authkit#2
No description provided.