#2: Browser-friendly 401/403 — content-negotiate JSON vs HTML/redirect
AuthInterceptor previously returned application/json for every rejection,
which is wrong for browser navigation: the user followed a /set-password
link and saw a raw {"status":"Unauthorized"} blob.
Add wantsJson() negotiation (path /api/* OR X-Requested-With OR Accept
prefers application/json over text/html) and an IAuthPolicy hook
unauthenticatedRedirect(path) that lets consumers bounce browser
navigations to a landing/login page. JSON callers (fetch/axios) still
get JSON 401/403. Default policy returns nullopt → minimal HTML error
page, never raw JSON to a browser.
Same hook covers both 401 and 403 (decision Option A on the issue) so
consumers wire one redirect target for both unauth and forbidden cases.
Bootstrap a minimal test harness (decision Option T2): CMake option
OATPP_AUTHKIT_BUILD_TESTS gates enable_testing() + a tests subdir.
Adds test_negotiation covering wantsJson + urlEncode. No third-party
test framework — assertions use <cassert> + a tiny REQUIRE macro so the
suite stays dependency-free for future tests.
Closes #2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
46971acf99
commit
abf6153439
6 changed files with 255 additions and 11 deletions
|
|
@ -44,3 +44,12 @@ install(FILES
|
||||||
"${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config.cmake"
|
"${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config.cmake"
|
||||||
"${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config-version.cmake"
|
"${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config-version.cmake"
|
||||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/oatpp-authkit)
|
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/oatpp-authkit)
|
||||||
|
|
||||||
|
# ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
# Off by default so consumers pulling us in via FetchContent don't pay the
|
||||||
|
# cost. Enable with -DOATPP_AUTHKIT_BUILD_TESTS=ON.
|
||||||
|
option(OATPP_AUTHKIT_BUILD_TESTS "Build oatpp-authkit unit tests" OFF)
|
||||||
|
if(OATPP_AUTHKIT_BUILD_TESTS)
|
||||||
|
enable_testing()
|
||||||
|
add_subdirectory(test)
|
||||||
|
endif()
|
||||||
|
|
|
||||||
37
README.md
37
README.md
|
|
@ -34,6 +34,43 @@ find_package(oatpp-authkit 0.1 REQUIRED)
|
||||||
target_link_libraries(app PRIVATE oatpp::authkit)
|
target_link_libraries(app PRIVATE oatpp::authkit)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Browser-friendly 401/403
|
||||||
|
|
||||||
|
By default `AuthInterceptor` returns `application/json` for every rejection,
|
||||||
|
which is correct for `/api/*` callers but breaks browser navigation: a user
|
||||||
|
following a stale link or an expired password-reset URL sees a raw
|
||||||
|
`{"status":"Unauthorized"}` instead of a real page.
|
||||||
|
|
||||||
|
Override `IAuthPolicy::unauthenticatedRedirect(path)` to redirect browser
|
||||||
|
navigations to a login or landing page while keeping JSON responses for
|
||||||
|
`fetch`/`axios` callers (detected via path prefix `/api/`,
|
||||||
|
`X-Requested-With: XMLHttpRequest`, or an `Accept` header that prefers
|
||||||
|
`application/json`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class AppAuthPolicy : public oatpp_authkit::IAuthPolicy {
|
||||||
|
public:
|
||||||
|
std::optional<std::string>
|
||||||
|
unauthenticatedRedirect(const std::string& path) override {
|
||||||
|
return "/?next=" + oatpp_authkit::AuthInterceptor::urlEncode(path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Returning `std::nullopt` (the default) preserves the legacy JSON behaviour
|
||||||
|
for all responses.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -B build -DOATPP_AUTHKIT_BUILD_TESTS=ON
|
||||||
|
cmake --build build
|
||||||
|
ctest --test-dir build --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests are off by default so consumers pulling the library in via
|
||||||
|
`FetchContent` don't pay the cost.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- **v0.2** — `AuthInterceptor` + `requireAdmin` ported onto three seams
|
- **v0.2** — `AuthInterceptor` + `requireAdmin` ported onto three seams
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,101 @@ private:
|
||||||
r->putHeader("Content-Type", "application/json");
|
r->putHeader("Content-Type", "application/json");
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
std::shared_ptr<OutgoingResponse> makeUnauthorized() {
|
|
||||||
return makeJsonError(Status::CODE_401, "{\"status\":\"Unauthorized\"}");
|
std::shared_ptr<OutgoingResponse> makeHtmlError(Status status, const std::string& title) {
|
||||||
|
std::string body = "<!doctype html><meta charset=\"utf-8\"><title>"
|
||||||
|
+ title + "</title><h1>" + title + "</h1>";
|
||||||
|
auto r = ResponseFactory::createResponse(status, body.c_str());
|
||||||
|
r->putHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
std::shared_ptr<OutgoingResponse> makeForbidden(const std::string& msg = "") {
|
|
||||||
if (msg.empty()) return makeJsonError(Status::CODE_403, "{\"status\":\"Forbidden\"}");
|
std::shared_ptr<OutgoingResponse> makeRedirect(const std::string& location) {
|
||||||
return makeJsonError(Status::CODE_403,
|
auto r = ResponseFactory::createResponse(Status::CODE_302, "");
|
||||||
"{\"status\":\"Forbidden\",\"message\":\"" + msg + "\"}");
|
r->putHeader("Location", location.c_str());
|
||||||
|
r->putHeader("Cache-Control", "no-store");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Heuristic: does this caller expect a JSON error body?
|
||||||
|
*
|
||||||
|
* True when any of:
|
||||||
|
* - path begins with `/api/` (API surface — always JSON)
|
||||||
|
* - `X-Requested-With: XMLHttpRequest` (jQuery/axios/explicit AJAX)
|
||||||
|
* - `Accept` mentions `application/json` and does NOT prefer `text/html`
|
||||||
|
*
|
||||||
|
* Otherwise treated as a browser navigation that should get HTML or a
|
||||||
|
* redirect. Exposed as a static so the negotiation rule is unit-testable
|
||||||
|
* without spinning up a request.
|
||||||
|
*/
|
||||||
|
static bool wantsJson(const std::string& path,
|
||||||
|
const std::string& xRequestedWith,
|
||||||
|
const std::string& accept)
|
||||||
|
{
|
||||||
|
if (path.size() >= 5 && path.compare(0, 5, "/api/") == 0) return true;
|
||||||
|
if (!xRequestedWith.empty()) return true;
|
||||||
|
bool hasJson = accept.find("application/json") != std::string::npos;
|
||||||
|
bool hasHtml = accept.find("text/html") != std::string::npos;
|
||||||
|
if (hasJson && !hasHtml) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Percent-encode the unreserved subset for use in a `next=` param.
|
||||||
|
* Static + side-effect-free so consumers and tests can reuse it.
|
||||||
|
*/
|
||||||
|
static std::string urlEncode(const std::string& s) {
|
||||||
|
static const char* hex = "0123456789ABCDEF";
|
||||||
|
std::string out;
|
||||||
|
out.reserve(s.size());
|
||||||
|
for (unsigned char c : s) {
|
||||||
|
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||||
|
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||||
|
out.push_back(static_cast<char>(c));
|
||||||
|
} else {
|
||||||
|
out.push_back('%');
|
||||||
|
out.push_back(hex[c >> 4]);
|
||||||
|
out.push_back(hex[c & 0xF]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool requestWantsJson(const std::shared_ptr<IncomingRequest>& req,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
auto xrw = req->getHeader("X-Requested-With");
|
||||||
|
auto accept = req->getHeader("Accept");
|
||||||
|
return wantsJson(path,
|
||||||
|
xrw ? *xrw : std::string{},
|
||||||
|
accept ? *accept : std::string{});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<OutgoingResponse> makeUnauthorized(
|
||||||
|
const std::shared_ptr<IncomingRequest>& req, const std::string& path)
|
||||||
|
{
|
||||||
|
if (requestWantsJson(req, path))
|
||||||
|
return makeJsonError(Status::CODE_401, "{\"status\":\"Unauthorized\"}");
|
||||||
|
if (auto loc = m_policy->unauthenticatedRedirect(path))
|
||||||
|
return makeRedirect(*loc);
|
||||||
|
return makeHtmlError(Status::CODE_401, "Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<OutgoingResponse> makeForbidden(
|
||||||
|
const std::shared_ptr<IncomingRequest>& req, const std::string& path,
|
||||||
|
const std::string& msg = "")
|
||||||
|
{
|
||||||
|
if (requestWantsJson(req, path)) {
|
||||||
|
if (msg.empty()) return makeJsonError(Status::CODE_403, "{\"status\":\"Forbidden\"}");
|
||||||
|
return makeJsonError(Status::CODE_403,
|
||||||
|
"{\"status\":\"Forbidden\",\"message\":\"" + msg + "\"}");
|
||||||
|
}
|
||||||
|
if (auto loc = m_policy->unauthenticatedRedirect(path))
|
||||||
|
return makeRedirect(*loc);
|
||||||
|
return makeHtmlError(Status::CODE_403, "Forbidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
void writeBundle(const std::shared_ptr<IncomingRequest>& req, const AuthPrincipal& p) {
|
void writeBundle(const std::shared_ptr<IncomingRequest>& req, const AuthPrincipal& p) {
|
||||||
|
|
@ -127,7 +215,7 @@ public:
|
||||||
writeBundle(request, *p);
|
writeBundle(request, *p);
|
||||||
if (isReadonly(p->role) && isMutation(method)) {
|
if (isReadonly(p->role) && isMutation(method)) {
|
||||||
logEvent(403, method, path, "readonly cert user mutation");
|
logEvent(403, method, path, "readonly cert user mutation");
|
||||||
return makeForbidden();
|
return makeForbidden(request, path);
|
||||||
}
|
}
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +225,7 @@ public:
|
||||||
std::string token = extractToken(request);
|
std::string token = extractToken(request);
|
||||||
if (token.empty()) {
|
if (token.empty()) {
|
||||||
logEvent(401, method, path, "no token");
|
logEvent(401, method, path, "no token");
|
||||||
return makeUnauthorized();
|
return makeUnauthorized(request, path);
|
||||||
}
|
}
|
||||||
std::string hash = m_hashToken(token);
|
std::string hash = m_hashToken(token);
|
||||||
|
|
||||||
|
|
@ -149,7 +237,7 @@ public:
|
||||||
viaSession = false;
|
viaSession = false;
|
||||||
} else {
|
} else {
|
||||||
logEvent(401, method, path, "invalid token");
|
logEvent(401, method, path, "invalid token");
|
||||||
return makeUnauthorized();
|
return makeUnauthorized(request, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF defence-in-depth: session cookie + mutation requires X-Requested-With.
|
// CSRF defence-in-depth: session cookie + mutation requires X-Requested-With.
|
||||||
|
|
@ -157,7 +245,7 @@ public:
|
||||||
auto xrw = request->getHeader("X-Requested-With");
|
auto xrw = request->getHeader("X-Requested-With");
|
||||||
if (!xrw || xrw->empty()) {
|
if (!xrw || xrw->empty()) {
|
||||||
logEvent(403, method, path, "missing X-Requested-With");
|
logEvent(403, method, path, "missing X-Requested-With");
|
||||||
return makeForbidden("Missing X-Requested-With header");
|
return makeForbidden(request, path, "Missing X-Requested-With header");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +253,7 @@ public:
|
||||||
|
|
||||||
if (isReadonly(p->role) && isMutation(method)) {
|
if (isReadonly(p->role) && isMutation(method)) {
|
||||||
logEvent(403, method, path, "readonly user mutation");
|
logEvent(403, method, path, "readonly user mutation");
|
||||||
return makeForbidden();
|
return makeForbidden(request, path);
|
||||||
}
|
}
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#ifndef OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP
|
#ifndef OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP
|
||||||
#define OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP
|
#define OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
|
@ -40,6 +41,27 @@ public:
|
||||||
* the presence of a `SETUP_MODE` sentinel file.
|
* the presence of a `SETUP_MODE` sentinel file.
|
||||||
*/
|
*/
|
||||||
virtual bool setupModeActive() { return false; }
|
virtual bool setupModeActive() { return false; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Where to send a browser navigation that hits a 401/403, instead
|
||||||
|
* of the default JSON error body.
|
||||||
|
*
|
||||||
|
* Returning `std::nullopt` (the default) keeps the legacy behaviour:
|
||||||
|
* `AuthInterceptor` always responds with `application/json`. Returning a
|
||||||
|
* URL makes the interceptor emit a `302` with `Location:` set whenever
|
||||||
|
* the request looks like a browser navigation (HTML `Accept`, no
|
||||||
|
* `X-Requested-With`, path outside `/api/`). API callers (`fetch`,
|
||||||
|
* `axios`, anything sending `X-Requested-With: XMLHttpRequest` or
|
||||||
|
* targeting `/api/*`) still receive the JSON 401/403 so client-side
|
||||||
|
* error handling keeps working.
|
||||||
|
*
|
||||||
|
* The same hook covers both 401 (no/invalid auth) and 403 (logged in
|
||||||
|
* but not allowed) — typical wiring is to bounce both to `/` or
|
||||||
|
* `/login?next=...`. Consumers that need to differentiate can branch
|
||||||
|
* inside the override.
|
||||||
|
*/
|
||||||
|
virtual std::optional<std::string>
|
||||||
|
unauthenticatedRedirect(const std::string& /*path*/) { return std::nullopt; }
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace oatpp_authkit
|
} // namespace oatpp_authkit
|
||||||
|
|
|
||||||
11
test/CMakeLists.txt
Normal file
11
test/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Minimal test harness for oatpp-authkit.
|
||||||
|
#
|
||||||
|
# Adds plain executable tests linked against the INTERFACE library and oatpp.
|
||||||
|
# No third-party test framework — assertions use <cassert> and a tiny REQUIRE
|
||||||
|
# macro so the suite stays portable and dependency-free.
|
||||||
|
|
||||||
|
find_package(oatpp REQUIRED)
|
||||||
|
|
||||||
|
add_executable(test_negotiation test_negotiation.cpp)
|
||||||
|
target_link_libraries(test_negotiation PRIVATE oatpp::authkit oatpp::oatpp)
|
||||||
|
add_test(NAME negotiation COMMAND test_negotiation)
|
||||||
77
test/test_negotiation.cpp
Normal file
77
test/test_negotiation.cpp
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// Tests for AuthInterceptor::wantsJson + urlEncode (the negotiation primitives
|
||||||
|
// that decide whether a 401/403 returns JSON vs HTML/redirect).
|
||||||
|
//
|
||||||
|
// Kept dependency-free on purpose — the harness exists so future tests have
|
||||||
|
// somewhere to land, not to pull in doctest/Catch2.
|
||||||
|
|
||||||
|
#include "oatpp-authkit/auth/AuthInterceptor.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int g_failures = 0;
|
||||||
|
|
||||||
|
#define REQUIRE(expr) do { \
|
||||||
|
if (!(expr)) { \
|
||||||
|
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
|
||||||
|
++g_failures; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
using oatpp_authkit::AuthInterceptor;
|
||||||
|
|
||||||
|
void test_wantsJson_api_path() {
|
||||||
|
// /api/* always wants JSON, no matter what Accept says.
|
||||||
|
REQUIRE( AuthInterceptor::wantsJson("/api/users", "", "text/html"));
|
||||||
|
REQUIRE( AuthInterceptor::wantsJson("/api/", "", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_wantsJson_xrequested_with() {
|
||||||
|
// Explicit AJAX wins regardless of path/Accept.
|
||||||
|
REQUIRE( AuthInterceptor::wantsJson("/admin", "XMLHttpRequest", "text/html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_wantsJson_accept_header() {
|
||||||
|
// application/json without text/html → JSON.
|
||||||
|
REQUIRE( AuthInterceptor::wantsJson("/admin", "", "application/json"));
|
||||||
|
// text/html present → browser navigation.
|
||||||
|
REQUIRE(!AuthInterceptor::wantsJson("/admin", "", "text/html,application/xhtml+xml"));
|
||||||
|
REQUIRE(!AuthInterceptor::wantsJson("/admin", "", "text/html,application/json"));
|
||||||
|
// No Accept → assume browser (HTML/redirect).
|
||||||
|
REQUIRE(!AuthInterceptor::wantsJson("/set-password", "", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_wantsJson_set_password_browser() {
|
||||||
|
// The motivating regression: a browser following the password-reset link
|
||||||
|
// must NOT be served JSON. (Path is public so it shouldn't reach this in
|
||||||
|
// normal flow, but if auth ever rejects it the user sees HTML/redirect.)
|
||||||
|
REQUIRE(!AuthInterceptor::wantsJson("/set-password",
|
||||||
|
"",
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_urlEncode() {
|
||||||
|
REQUIRE(AuthInterceptor::urlEncode("/admin") == "%2Fadmin");
|
||||||
|
REQUIRE(AuthInterceptor::urlEncode("/set-password?t=1")== "%2Fset-password%3Ft%3D1");
|
||||||
|
REQUIRE(AuthInterceptor::urlEncode("abc-_.~123") == "abc-_.~123");
|
||||||
|
REQUIRE(AuthInterceptor::urlEncode(" ") == "%20");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
test_wantsJson_api_path();
|
||||||
|
test_wantsJson_xrequested_with();
|
||||||
|
test_wantsJson_accept_header();
|
||||||
|
test_wantsJson_set_password_browser();
|
||||||
|
test_urlEncode();
|
||||||
|
if (g_failures) {
|
||||||
|
std::fprintf(stderr, "%d test(s) failed\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::puts("ok");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue