No description
Find a file
Uwe Schuster 9976efe1de #16 (audit L-1..L-8): fix the low-severity findings
L-1 RequireRole: guard std::stoi on the bundle id — a non-numeric/out-of-range
    value now yields a clean 401 instead of an uncaught exception → 500.
    AuthPrincipal::id documented as numeric-only (carry UUIDs in username).
L-2 SmtpTransport: require TLS (CURLUSESSL_ALL) for non-loopback relays so a
    stripped STARTTLS can't downgrade credentials/body to cleartext; localhost
    relay stays opportunistic.
L-3 AuditLog: escapeJson now escapes all control chars (RFC 8259) so a newline
    in a field can't forge/corrupt the audit JSON; SKIP_FIELDS gains credential
    names (password/passwordHash/tlsCertDn/apiKey/token/secret) so secrets never
    land in changed_fields.
L-4 ws/Hub: consume the thread_local auth handoff once, up front, and clear it
    unconditionally — a stale value can't attach to a later connection on a
    reused worker thread.
L-5 TemporalRepository: default id generator draws 128 bits from the platform
    CSPRNG (std::random_device) per call instead of a once-seeded mt19937_64,
    so entity_ids aren't predictable from observed output.
L-6 AuthInterceptor: expired-session sweep is now a lock-free atomic timer and
    exception-isolated; documented that resolveBySessionHash() must enforce
    expiry at query time (the sweep is GC only).
L-7 new util/ConstantTime.hpp (constantTimeEquals) + TokenHasher doc requiring a
    >=256-bit cryptographic hash.
L-8 IQueryable: likeEscape + Field::likeContains/likePrefix emit
    `LIKE ? ESCAPE '\'` with %/_/\ escaped for untrusted terms; documented the
    compile-time identifier-source invariant.

Tests: new test_constant_time; likeEscape/likeContains/likePrefix cases added to
test_queryable. All 20 ctest targets pass. README + header docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:03:01 +02:00
cmake v0.1.0: initial clean-lift from fewo-webapp 2026-04-21 21:42:53 +02:00
docs #14 PR 1: relocate role_templates module + Atlas migration docs 2026-05-06 12:36:18 +02:00
include/oatpp-authkit #16 (audit L-1..L-8): fix the low-severity findings 2026-05-29 14:03:01 +02:00
test #16 (audit L-1..L-8): fix the low-severity findings 2026-05-29 14:03:01 +02:00
.gitignore v0.1.0: initial clean-lift from fewo-webapp 2026-04-21 21:42:53 +02:00
CMakeLists.txt #15: RedactedFieldRepository — null credentials on historical rows 2026-05-06 20:52:02 +02:00
README.md #16 (audit L-1..L-8): fix the low-severity findings 2026-05-29 14:03:01 +02:00

oatpp-authkit

Header-only C++ library distilled from fewo-webapp's hardened auth / security stack. Header-only, oatpp 1.3+, C++17.

What's in v0.1 (the clean-lift set)

Header Purpose
interceptor/SecurityHeadersInterceptor.hpp CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy. Strict defaults.
interceptor/BodySizeLimitInterceptor.hpp Reject request bodies above a configurable limit with 413 before they hit your handlers.
handler/JsonErrorHandler.hpp Normalises thrown exceptions into {status, message} JSON so controllers never leak raw HTML error pages.
util/RateLimiter.hpp In-memory token-bucket keyed on an arbitrary string (typically the client IP from clientIpTrusted). The constructor validates its args (capacity finite ≥1, refillRate finite >0) and throws std::invalid_argument otherwise — a zero/negative/NaN rate previously disabled the limiter silently (authkit#16 M-7).
util/TokenExtract.hpp extractToken (Cookie/Bearer) + cookieValue(header,name) exact-name cookie parse (authkit#16 M-1 — no substring matching, so a sibling xsession= can't shadow session=), isValidIp (IPv4/IPv6 via inet_pton), clientIpTrusted (loopback-gated XFF; returns the "unknown"/"invalid" sentinels off-proxy — treat as one shared rate-limit bucket, M-8).
util/OriginCheck.hpp originHostname, sameOrigin(originOrReferer, host), originAllowed(origin, allowlist) — pure CSRF/CSWSH origin helpers (authkit#16 M-4/M-10). Used by AuthInterceptor for session mutations; call sameOrigin/originAllowed in your WSController to block Cross-Site WebSocket Hijacking at the handshake.
util/SessionCookie.hpp buildSetSessionCookie(token, opts) / buildClearSessionCookie(opts) — safe-by-default Set-Cookie builder (HttpOnly + Secure + SameSite=Strict + Path=/ by default; opt out explicitly). Rejects control chars / ; in fields (authkit#16 M-9). Returns the header value only; framework-agnostic.
util/ConstantTime.hpp constantTimeEquals(a, b) — branch-free secret comparison for consumers that compare a token/HMAC/hash in memory rather than via an indexed store lookup (authkit#16 L-7).
mail/SmtpTransport.hpp libcurl SMTP+MIME sender. Requires TLS (CURLUSESSL_ALL) for non-loopback relays so credentials/body can't be sent cleartext if STARTTLS is stripped (authkit#16 L-2); a localhost/127.0.0.1 relay stays opportunistic. Rejects CR/LF/NUL in to/fromAddress (header-injection guard, authkit#16 H-5).
startup/RequireEncryptionKey.hpp requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext) — refuse startup without a symmetric key unless a dev flag overrides.
repo/Repository.hpp + IHistoryRepository.hpp + TemporalFieldTraits.hpp + TemporalAt.hpp + ActorContext.hpp Pure-abstract Repository<TDto> interface set distilled from fewo-webapp's per-entity *Db clients. Mixed UUID allocation on save, separate IHistoryRepository<T> for temporal versions, TemporalFieldTraits<T> to map canonical (entity_id, valid_from, valid_until) onto whatever a DTO actually calls them, ActorContext placeholder for the scope-guard decorator.
repo/TemporalRepository.hpp Decorator that wraps any Repository<TDto> and turns it into a temporally-versioned one. Stable-live + historical-copy semantics (authkit#13): the live row's id PK is preserved across updates; each prior version is captured as a fresh row with a new id. softDelete closes the live row in place; with ON UPDATE CASCADE on consumer-side composite child FKs, child rows follow automatically. findByEntityIdAt(id, at) returns the version live at a point in time; implements IHistoryRepository<T>. Inner adapter is expected to expose all rows (live + historical) and treat save as upsert keyed by id (per-row PK). DTOs register their four temporal columns via OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, id, entity_id, valid_from, valid_until).
repo/ScopeGuardRepository.hpp Generic resource-scope decorator. Takes a bool(ActorContext, TDto) predicate, an actor accessor, and an entity_id accessor at construction; gates every method on the predicate. On save the predicate must pass on the incoming DTO and, for an update, on the row as it currently stands — so an actor can't reparent an out-of-scope row into its own scope by relabelling it in the request body. Throws ScopeDeniedException on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. ScopeGuardQueryable<T> (same header) is the variant for IQueryable inners: it filters query() results through the predicate too, so the queryable surface can't bypass the guard.
repo/IQueryable.hpp Optional capability for repos that resolve a typed query AST. field<&Dto::col>().eq(...) style DSL composes via && / `
repo/IAuditSink.hpp + repo/AuditLogRepository.hpp Cross-cutting audit-trail decorator. Emits an AuditEvent (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied IAuditSink. Ops are Create / Update / Delete / Read; pre-write findByEntityId lookup distinguishes Create from Update. Configurable enabled-op set (default {Create,Update,Delete}Read is opt-in, list() never audited). Sink failures are caught and swallowed unless a bool(const std::exception&) handler asks to rethrow. Stacks with TemporalRepository and ScopeGuardRepository.
repo/SchemaContract.hpp Declarative schema model for the decorator stack (authkit#14). Each decorator exposes a static constexpr DecoratorSchema kSchema listing the columns/indexes it contributes to the entity table plus any sidecar tables it owns. SchemaBuilder<Decorators…>::create(table, exec) composes contributions into a single CREATE TABLE per entity table; sidecars emit separately. SchemaContract<Decorators…>::verify(table, probe) is a runtime introspect-and-assert that throws SchemaContractViolation if any required column or sidecar is missing. Decorator code never runs ALTER at runtime — Atlas (atlasgo.io) owns evolution between deploys; the C++ side only declares desired state and checks it.
repo/RedactedFieldRepository.hpp Decorator that nulls out named fields on historical rows only (authkit#15). Sits below TemporalRepository and inspects each save: if valid_until != SENTINEL, the row is being closed as a historical version, so the configured fields (e.g. passwordHash, tlsCertDn) are set to null before persisting. The live row keeps its values intact. Built for the case where a credential rides a temporal row — every change creates a historical version with the prior secret preserved, and the redaction prevents a DB breach from yielding every credential a user has ever had. The constructor throws std::invalid_argument if a configured field name isn't a DTO member (authkit#16 M-6) — a typo would otherwise silently redact nothing.

Decorator schema contributions

Decorator Entity columns Entity indexes Sidecar tables
TemporalRepository<T> valid_from TEXT NOT NULL DEFAULT '', valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z' UNIQUE INDEX ux_{table}_entity_valid_until ON {table}(entity_id, valid_until) (none)
AuditLogRepository<T> (none) (none) audit_log (id, actor_user_id, entity_type, entity_id, op, timestamp_ms)
ScopeGuardRepository<T> (none) (none) (none)

The concrete repo at the bottom of the stack contributes the entity_id + business columns. Stacking is declarative; column dedup keeps duplicate contributions safe.

Wiring it up:

#include "oatpp-authkit/repo/SchemaContract.hpp"

auto exec  = [&](const std::string& sql) { /* run DDL */ };
auto probe = [&](const std::string& sql) { /* run SELECT, return bool */ };

// On a fresh DB (e.g. CI dev DB that Atlas inspects):
oatpp_authkit::repo::SchemaBuilder<
    ConcretePersonRepository,
    oatpp_authkit::repo::TemporalRepository<PersonDto>,
    oatpp_authkit::repo::AuditLogRepository<PersonDto>>::create("persons", exec);

// At every app startup, against a populated DB:
oatpp_authkit::repo::SchemaContract<
    ConcretePersonRepository,
    oatpp_authkit::repo::TemporalRepository<PersonDto>,
    oatpp_authkit::repo::AuditLogRepository<PersonDto>>::verify("persons", probe);

Atlas wiring (out of scope for this header): point atlas migrate diff's --dev-url at a SQLite that SchemaBuilder has populated, and --url at the live prod DB. Atlas emits versioned migration SQL; the deploy pipeline applies it. The decorator code stays unchanged across schema evolutions.

Consume via CMake

# FetchContent (pin to a tag):
include(FetchContent)
FetchContent_Declare(oatpp-authkit
    GIT_REPOSITORY https://git.uwe-schuster.info/uwe.admin/oatpp-authkit.git
    GIT_TAG v0.1.0)
FetchContent_MakeAvailable(oatpp-authkit)

target_link_libraries(app PRIVATE oatpp::authkit)

Or after cmake --install:

find_package(oatpp-authkit 0.1 REQUIRED)
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):

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

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

  • v0.2AuthInterceptor + requireAdmin ported onto three seams (IAuthBackend, IAuthPolicy, IRuntimeConfig) so consumers plug in their own user store, public-path list, and admin role set without forking the interceptor.
  • Later — session cookie helpers, API-key rotation, re-encryption migration.

See docs/security-baseline.md for language-neutral CSP / rate-limit / body-size constants that non-C++ consumers can re-implement directly.