morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
session_auth.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <array>
5#include <chrono>
6#include <cstdint>
7#include <expected>
8#include <functional>
9#include <glaze/glaze.hpp>
10#include <optional>
11#include <string>
12#include <string_view>
13#include <utility>
14#include <vector>
15
16#include "../attributes.hpp"
17#include "../core/profiler.hpp"
18#include "session.hpp"
19
20/// @file session/session_auth.hpp
21/// @brief Opt-in authenticated sessions: signed bearer tokens and a verifying
22/// `IAuthorizer` so a `RemoteServer` can trust the caller's identity.
23///
24/// Include this only if you want authentication. The token is a signed,
25/// stateless bearer credential — `base64url(claimsJson) "." base64url(mac)` —
26/// minted app-side by a login action via `TokenIssuer` and verified per request
27/// by `SigningAuthorizer`, which also feeds the verified principal back to
28/// `RemoteServer` so `session::current()->principal` is authoritative inside a
29/// model. The MAC primitive is pluggable (`MacFunction`); a self-contained,
30/// test-vector-verified HMAC-SHA256 (`hmacSha256`) is the default. See
31/// `docs/spec/security.md` for the full trust model and its limits.
32
33namespace morph::session {
34
35// ── Cryptographic primitives (reference implementations) ─────────────────────
36//
37// These are self-contained so morph has no crypto dependency. They are correct
38// (verified against the standard test vectors in test_session_auth.cpp) but are
39// *reference* code: security-sensitive deployments should inject a vetted
40// library's HMAC via `MacFunction` rather than rely on these.
41namespace detail {
42
43// NOLINTBEGIN(readability-identifier-length,readability-function-cognitive-complexity,cppcoreguidelines-pro-bounds-constant-array-index,bugprone-easily-swappable-parameters)
44
45/// @brief SHA-256 (FIPS 180-4). Returns the raw 32-byte digest.
46/// @param data Message bytes to hash.
47/// @return 32-byte digest as a byte string.
48inline std::string sha256(std::string_view data) {
49 static constexpr std::array<uint32_t, 64> kRound = {
50 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
51 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
52 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
53 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
54 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
55 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
56 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
57 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
58
59 std::array<uint32_t, 8> hash = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
60 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
61
62 std::vector<uint8_t> msg(data.begin(), data.end());
63 const uint64_t bitLen = msg.size() * 8;
64 msg.push_back(0x80);
65 while (msg.size() % 64 != 56) {
66 msg.push_back(0x00);
67 }
68 for (int shift = 56; shift >= 0; shift -= 8) {
69 msg.push_back(static_cast<uint8_t>((bitLen >> shift) & 0xff));
70 }
71
72 auto rotr = [](uint32_t val, uint32_t bits) { return (val >> bits) | (val << (32 - bits)); };
73
74 for (std::size_t chunk = 0; chunk < msg.size(); chunk += 64) {
75 std::array<uint32_t, 64> w{};
76 for (std::size_t i = 0; i < 16; ++i) {
77 w[i] = (static_cast<uint32_t>(msg[chunk + (i * 4)]) << 24) |
78 (static_cast<uint32_t>(msg[chunk + (i * 4) + 1]) << 16) |
79 (static_cast<uint32_t>(msg[chunk + (i * 4) + 2]) << 8) |
80 static_cast<uint32_t>(msg[chunk + (i * 4) + 3]);
81 }
82 for (std::size_t i = 16; i < 64; ++i) {
83 const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3);
84 const uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10);
85 w[i] = w[i - 16] + s0 + w[i - 7] + s1;
86 }
87
88 uint32_t a = hash[0];
89 uint32_t b = hash[1];
90 uint32_t c = hash[2];
91 uint32_t d = hash[3];
92 uint32_t e = hash[4];
93 uint32_t f = hash[5];
94 uint32_t g = hash[6];
95 uint32_t h = hash[7];
96 for (std::size_t i = 0; i < 64; ++i) {
97 const uint32_t bigS1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
98 const uint32_t choose = (e & f) ^ (~e & g);
99 const uint32_t t1 = h + bigS1 + choose + kRound[i] + w[i];
100 const uint32_t bigS0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
101 const uint32_t maj = (a & b) ^ (a & c) ^ (b & c);
102 const uint32_t t2 = bigS0 + maj;
103 h = g;
104 g = f;
105 f = e;
106 e = d + t1;
107 d = c;
108 c = b;
109 b = a;
110 a = t1 + t2;
111 }
112 hash[0] += a;
113 hash[1] += b;
114 hash[2] += c;
115 hash[3] += d;
116 hash[4] += e;
117 hash[5] += f;
118 hash[6] += g;
119 hash[7] += h;
120 }
121
122 std::string out(32, '\0');
123 for (std::size_t i = 0; i < 8; ++i) {
124 out[(i * 4)] = static_cast<char>((hash[i] >> 24) & 0xff);
125 out[(i * 4) + 1] = static_cast<char>((hash[i] >> 16) & 0xff);
126 out[(i * 4) + 2] = static_cast<char>((hash[i] >> 8) & 0xff);
127 out[(i * 4) + 3] = static_cast<char>(hash[i] & 0xff);
128 }
129 return out;
130}
131
132/// @brief HMAC-SHA256 (RFC 2104). Returns the raw 32-byte MAC.
133/// @param key Secret key bytes.
134/// @param msg Message bytes to authenticate.
135/// @return 32-byte MAC as a byte string.
136inline std::string hmacSha256Raw(std::string_view key, std::string_view msg) {
137 constexpr std::size_t blockSize = 64;
138 std::string block{key};
139 if (block.size() > blockSize) {
140 block = sha256(block);
141 }
142 block.resize(blockSize, '\0');
143
144 std::string inner(blockSize, '\0');
145 std::string outer(blockSize, '\0');
146 for (std::size_t i = 0; i < blockSize; ++i) {
147 inner[i] = static_cast<char>(static_cast<uint8_t>(block[i]) ^ 0x36);
148 outer[i] = static_cast<char>(static_cast<uint8_t>(block[i]) ^ 0x5c);
149 }
150 const std::string innerHash = sha256(inner + std::string{msg});
151 return sha256(outer + innerHash);
152}
153
154/// @brief Constant-time byte-string equality (guards against MAC timing leaks).
155/// @param lhs First string.
156/// @param rhs Second string.
157/// @return `true` iff the strings are byte-for-byte equal.
158inline bool constantTimeEquals(std::string_view lhs, std::string_view rhs) {
159 if (lhs.size() != rhs.size()) {
160 return false; // length is not secret
161 }
162 uint8_t diff = 0;
163 for (std::size_t i = 0; i < lhs.size(); ++i) {
164 diff = uint8_t(diff | (uint8_t(lhs[i]) ^ uint8_t(rhs[i])));
165 }
166 return diff == 0;
167}
168
169/// @brief The url-safe base64 alphabet (RFC 4648 §5): `-` and `_` replace `+`/`/`.
170inline constexpr std::string_view kB64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
171
172/// @brief base64url-encodes @p in without padding (RFC 4648 §5).
173/// @param in Bytes to encode.
174/// @return The base64url text.
175inline std::string base64UrlEncode(std::string_view in) {
176 std::string out;
177 std::size_t i = 0;
178 for (; i + 3 <= in.size(); i += 3) {
179 const uint32_t triple = (static_cast<uint32_t>(static_cast<uint8_t>(in[i])) << 16) |
180 (static_cast<uint32_t>(static_cast<uint8_t>(in[i + 1])) << 8) |
181 static_cast<uint32_t>(static_cast<uint8_t>(in[i + 2]));
182 out.push_back(kB64Alphabet[(triple >> 18) & 0x3f]);
183 out.push_back(kB64Alphabet[(triple >> 12) & 0x3f]);
184 out.push_back(kB64Alphabet[(triple >> 6) & 0x3f]);
185 out.push_back(kB64Alphabet[triple & 0x3f]);
186 }
187 const std::size_t rem = in.size() - i;
188 if (rem == 1) {
189 const uint32_t triple = static_cast<uint32_t>(static_cast<uint8_t>(in[i])) << 16;
190 out.push_back(kB64Alphabet[(triple >> 18) & 0x3f]);
191 out.push_back(kB64Alphabet[(triple >> 12) & 0x3f]);
192 } else if (rem == 2) {
193 const uint32_t triple = (static_cast<uint32_t>(static_cast<uint8_t>(in[i])) << 16) |
194 (static_cast<uint32_t>(static_cast<uint8_t>(in[i + 1])) << 8);
195 out.push_back(kB64Alphabet[(triple >> 18) & 0x3f]);
196 out.push_back(kB64Alphabet[(triple >> 12) & 0x3f]);
197 out.push_back(kB64Alphabet[(triple >> 6) & 0x3f]);
198 }
199 return out;
200}
201
202/// @brief Decodes canonical base64url text (no padding). Returns nullopt on invalid input.
203///
204/// Canonical decoding is enforced to remove token-string malleability: a token
205/// is a signed byte string, but base64url is a *bit*-oriented encoding, so a
206/// naive decoder that silently discards the leftover bits of the final symbol
207/// would map several distinct encodings onto the same bytes (e.g. the trailing
208/// character could be perturbed within the discarded low bits without changing
209/// the decoded MAC). This function rejects any such non-canonical input:
210///
211/// - A length `% 4 == 1` is impossible for real base64url output and is rejected.
212/// - The leftover bits that do not form a whole output byte (2 bits for a
213/// 1-remainder group, 4 bits for a 2-remainder group) **must be zero**; a
214/// nonzero remainder means the encoding is not the canonical one this decoder
215/// would itself produce, so it is rejected rather than truncated.
216///
217/// The result is thus a bijection over valid tokens: exactly one string decodes
218/// to any given byte sequence.
219/// @param in base64url text.
220/// @return Decoded bytes, or nullopt if @p in has an invalid character, an
221/// impossible length, or nonzero leftover (non-canonical) bits.
222inline std::optional<std::string> base64UrlDecode(std::string_view in) {
223 auto valueOf = [](char chr) -> int {
224 const auto pos = kB64Alphabet.find(chr);
225 return pos == std::string_view::npos ? -1 : static_cast<int>(pos);
226 };
227 // A base64url group of 4 symbols encodes 3 bytes; the only valid tail
228 // remainders are 0 (whole groups), 2 (→1 byte), or 3 (→2 bytes). A
229 // remainder of 1 symbol cannot be produced by the encoder and is rejected.
230 if (in.size() % 4 == 1) {
231 return std::nullopt;
232 }
233 std::string out;
234 uint32_t buffer = 0;
235 int bits = 0;
236 for (const char chr : in) {
237 const int val = valueOf(chr);
238 if (val < 0) {
239 return std::nullopt;
240 }
241 buffer = (buffer << 6) | static_cast<uint32_t>(val);
242 bits += 6;
243 if (bits >= 8) {
244 bits -= 8;
245 out.push_back(static_cast<char>((buffer >> bits) & 0xff));
246 }
247 }
248 // Reject non-canonical encodings: any bit left in `buffer` below the byte
249 // boundary must be zero, otherwise this input is a distinct encoding of the
250 // same bytes (malleability) rather than the canonical one.
251 if (bits > 0 && (buffer & ((1U << bits) - 1)) != 0) {
252 return std::nullopt;
253 }
254 return out;
255}
256
257// NOLINTEND(readability-identifier-length,readability-function-cognitive-complexity,cppcoreguidelines-pro-bounds-constant-array-index,bugprone-easily-swappable-parameters)
258
259} // namespace detail
260
261/// @brief Pluggable message-authentication primitive: `mac(secret, message)`.
262///
263/// Returns the raw MAC bytes. Defaults to `hmacSha256`; inject a vetted
264/// library's HMAC for production.
265using MacFunction = std::function<std::string(std::string_view key, std::string_view message)>;
266
267/// @brief Reference HMAC-SHA256 `MacFunction` (the default). Returns raw MAC bytes.
268/// @param key Secret key bytes.
269/// @param message Message bytes to authenticate.
270/// @return 32-byte HMAC as a byte string.
271inline std::string hmacSha256(std::string_view key, std::string_view message) {
272 return detail::hmacSha256Raw(key, message);
273}
274
275/// @brief Wall-clock source (milliseconds since the Unix epoch), injectable for tests.
276using Clock = std::function<int64_t()>;
277
278/// @brief Default clock: `std::chrono::system_clock` in milliseconds since epoch.
279/// @return Current time in ms since the Unix epoch.
280inline int64_t systemClockMs() {
281 return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch())
282 .count();
283}
284
285/// @brief Verified claims carried by a session token.
286///
287/// Serialised to JSON (Glaze) as the signed payload. Extend with app claims as
288/// needed — unknown fields are ignored on read, so adding claims is compatible.
290 /// @brief Authenticated user/principal id.
291 std::string principal;
292 /// @brief Issue time, ms since epoch. If positive, `verify` rejects a token
293 /// whose `issuedAtMs` is more than `kClockSkewMs` in the future
294 /// (not-yet-valid). `0` (unset) disables the not-before check.
295 int64_t issuedAtMs = 0;
296 /// @brief Expiry, ms since epoch. **Must be strictly positive** — a token
297 /// must carry a real expiry. `verify` treats `expiresAtMs <= 0`
298 /// (including the default `0`) as invalid/expired, so a zero token is
299 /// never an eternal credential.
300 int64_t expiresAtMs = 0;
301 /// @brief Coarse-grained roles the app can key authorization policy on.
302 std::vector<std::string> roles;
303};
304
305/// @brief Allowed clock skew (ms) between issuer and verifier for the
306/// not-before (`issuedAtMs`) check. Keeps a token minted a moment ahead
307/// of the verifier's clock from being spuriously rejected.
308inline constexpr int64_t kClockSkewMs = 60'000; // 60s
309
310/// @brief Why token verification failed.
311enum class AuthError : std::uint8_t {
312 Malformed, ///< Not `payload.sig`, bad base64url, or unparseable claims.
313 BadSignature, ///< MAC did not match — forged or tampered.
314 Expired, ///< Missing/non-positive `expiresAtMs`, or it is in the past.
315 NotYetValid, ///< `issuedAtMs` is set and more than `kClockSkewMs` in the future.
316};
317
318/// @brief Thrown by `TokenIssuer::issue` if serialising the claims fails.
319///
320/// Not realistically reachable for `SessionToken` (a flat aggregate of
321/// strings/integers, same as `journal::LogEntry`/`FileQueueRecord`), but
322/// routing through a throwing helper rather than discarding the error keeps
323/// this writer consistent with its two sibling writers
324/// (`journal::toJson`/`offline::detail::toJson`), instead of silently
325/// serialising a claims blob that later fails to decode.
326struct TokenIssuanceError : std::runtime_error {
327 using std::runtime_error::runtime_error;
328};
329
330namespace detail {
331
332/// @brief Converts a Glaze error into a `TokenIssuanceError`, or does nothing
333/// if @p errCode reports success. See `journal::detail::throwOnGlazeError`
334/// for the identical pattern in the sibling writer.
335/// @param errCode Result of a `glz::write` call.
336/// @param context Buffer passed to `glz::format_error` for the message.
337inline void throwOnGlazeError(const glz::error_ctx& errCode, std::string_view context) {
338 // Not covered by this file's own test suite: the only call site
339 // (`TokenIssuer::issue`) always writes a `SessionToken`, a flat aggregate
340 // of strings/integers with no tagged-variant member, so Glaze's writer has
341 // no reachable failure mode for it (unlike, e.g., a null smart pointer
342 // behind a tagged variant alternative -- see glaze's
343 // docs/variant-handling.md, "Variants with Smart Pointers"). Forcing this
344 // branch would mean giving `SessionToken` a variant member solely to break
345 // it, which is not a change this type should make. `journal::toJson`'s and
346 // `offline::detail::toJson`'s identical `throwOnGlazeError` call sites
347 // carry the same accepted, documented gap for the same reason (see
348 // `TokenIssuanceError`'s doc comment above).
349 if (errCode) {
350 throw TokenIssuanceError{glz::format_error(errCode, context)};
351 }
352}
353
354/// @brief Write options that escape ASCII control bytes as `\\uXXXX` sequences.
355///
356/// glaze 7.4 leaves control bytes (0x00-0x1F) unescaped by default, which
357/// breaks a `SessionToken` carrying one in `principal`/`roles` two ways: RFC
358/// 8259 requires those bytes escaped, so the raw byte alone yields JSON this
359/// morph's own `TokenVerifier` cannot decode — a signed token that fails to
360/// verify, a silent issue-succeeds/verify-fails asymmetry; worse, once the
361/// same string also contains an escaped `\` or `"`, glaze's chunked writer
362/// path silently rewrites the control byte as two 0x00 bytes, corrupting the
363/// claims before they are ever signed. Mirrors
364/// `morph::wire::detail::EscapingWriteOpts` (`core/wire.hpp`) exactly;
365/// duplicated here (rather than shared) so this header stays free of a
366/// `core/` dependency. Escaping is lossless, so any such byte still
367/// round-trips through `TokenVerifier::verify` unchanged.
368struct EscapingWriteOpts : glz::opts {
369 /// @brief Emit control bytes as `\\uXXXX` rather than raw.
370 // NOLINTNEXTLINE(readability-identifier-naming) — glaze's option name, matched by name.
372};
373
374} // namespace detail
375
376/// @brief Mints signed bearer tokens from claims using a shared secret.
377///
378/// Wire format: `base64url(claimsJson) "." base64url(mac(secret, payload))`.
380public:
381#ifdef MORPH_REQUIRE_VETTED_HMAC
382 /// @brief Constructs an issuer over @p secret using @p mac.
383 ///
384 /// Built with `MORPH_REQUIRE_VETTED_HMAC` defined: @p mac has **no
385 /// default** here, so a call site that relied on the reference
386 /// `hmacSha256` default fails to compile, forcing an explicit vetted
387 /// `MacFunction` injection. See `docs/spec/security.md` ("MAC-primitive
388 /// recommended wiring").
389 /// @param secret Shared secret (same value the verifier uses).
390 /// @param mac MAC primitive (required — no default under this build option).
391 /// Stored and invoked for this issuer's whole lifetime, so
392 /// anything the callable refers to must outlive the issuer.
393 explicit TokenIssuer(std::string secret, MacFunction mac MORPH_LIFETIMEBOUND)
394 : _secret{std::move(secret)}, _mac{std::move(mac)} {}
395#else
396 /// @brief Constructs an issuer over @p secret using @p mac.
397 /// @param secret Shared secret (same value the verifier uses).
398 /// @param mac MAC primitive; defaults to `hmacSha256`. Stored and
399 /// invoked for this issuer's whole lifetime, so anything the
400 /// callable refers to must outlive the issuer.
401 explicit TokenIssuer(std::string secret, MacFunction mac MORPH_LIFETIMEBOUND = hmacSha256)
402 : _secret{std::move(secret)}, _mac{std::move(mac)} {}
403#endif
404
405 /// @brief Serialises @p claims and returns a signed token string.
406 ///
407 /// Writes with `detail::EscapingWriteOpts` so a raw ASCII control byte in
408 /// `principal`/`roles` round-trips through `TokenVerifier::verify` instead
409 /// of producing invalid JSON (or, alongside an escaped `\`/`"`, silently
410 /// corrupted JSON) — see that struct's doc comment.
411 /// @param claims Claims to embed and sign.
412 /// @return The signed `payload.sig` token.
413 /// @throws TokenIssuanceError on encode failure (see `detail::throwOnGlazeError`
414 /// for why this is not realistically reachable for `SessionToken`).
415 [[nodiscard]] std::string issue(const SessionToken& claims) const {
416 MORPH_ZONE("TokenIssuer::issue");
417 std::string json;
418 detail::throwOnGlazeError(glz::write<detail::EscapingWriteOpts{}>(claims, json), json);
419 const std::string payload = detail::base64UrlEncode(json);
420 const std::string sig = detail::base64UrlEncode(_mac(_secret, payload));
421 return payload + "." + sig;
422 }
423
424private:
425 std::string _secret;
426 MacFunction _mac;
427};
428
429/// @brief Verifies signed bearer tokens against a shared secret.
431public:
432#ifdef MORPH_REQUIRE_VETTED_HMAC
433 /// @brief Constructs a verifier over @p secret using @p mac.
434 ///
435 /// Built with `MORPH_REQUIRE_VETTED_HMAC` defined: @p mac has **no
436 /// default** here, so a call site that relied on the reference
437 /// `hmacSha256` default fails to compile, forcing an explicit vetted
438 /// `MacFunction` injection. See `docs/spec/security.md` ("MAC-primitive
439 /// recommended wiring").
440 /// @param secret Shared secret (same value the issuer used).
441 /// @param mac MAC primitive (required — no default under this build option).
442 /// Stored and invoked for this verifier's whole lifetime, so
443 /// anything the callable refers to must outlive the verifier.
444 explicit TokenVerifier(std::string secret, MacFunction mac MORPH_LIFETIMEBOUND)
445 : _secret{std::move(secret)}, _mac{std::move(mac)} {}
446#else
447 /// @brief Constructs a verifier over @p secret using @p mac.
448 /// @param secret Shared secret (same value the issuer used).
449 /// @param mac MAC primitive; defaults to `hmacSha256`. Stored and
450 /// invoked for this verifier's whole lifetime, so anything the
451 /// callable refers to must outlive the verifier.
452 explicit TokenVerifier(std::string secret, MacFunction mac MORPH_LIFETIMEBOUND = hmacSha256)
453 : _secret{std::move(secret)}, _mac{std::move(mac)} {}
454#endif
455
456 /// @brief Verifies @p token's signature and expiry and returns its claims.
457 ///
458 /// The MAC is checked *before* the payload is parsed, so untrusted JSON is
459 /// never handed to the parser until authenticity is established.
460 /// @param token Token string (`payload.sig`).
461 /// @param nowMs Current time (ms since epoch) to check expiry against.
462 /// @return The verified claims, or an `AuthError`.
463 [[nodiscard]] std::expected<SessionToken, AuthError> verify(std::string_view token, int64_t nowMs) const {
464 MORPH_ZONE("TokenVerifier::verify");
465 const auto dot = token.find('.');
466 if (dot == std::string_view::npos || token.find('.', dot + 1) != std::string_view::npos) {
467 return std::unexpected(AuthError::Malformed);
468 }
469 const std::string_view payload = token.substr(0, dot);
470 const auto sig = detail::base64UrlDecode(token.substr(dot + 1));
471 if (!sig) {
472 return std::unexpected(AuthError::Malformed);
473 }
474 const std::string expectedMac = _mac(_secret, std::string{payload});
475 if (!detail::constantTimeEquals(*sig, expectedMac)) {
476 return std::unexpected(AuthError::BadSignature);
477 }
478 const auto json = detail::base64UrlDecode(payload);
479 if (!json) {
480 return std::unexpected(AuthError::Malformed);
481 }
482 SessionToken claims;
483 // Ignore unknown claims so a token minted by a newer issuer (with extra
484 // application claims) still verifies against an older verifier — the
485 // forward-compatibility the SessionToken doc promises. See security.md.
486 static constexpr glz::opts kLenient{.error_on_unknown_keys = false};
487 if (glz::read<kLenient>(claims, *json)) {
488 return std::unexpected(AuthError::Malformed);
489 }
490 // A token MUST carry a strictly-positive expiry. A missing/zero (or
491 // negative) `expiresAtMs` is treated as already-expired rather than
492 // "eternal", so a default-constructed or zeroed token is never an
493 // unbounded bearer credential. See docs/spec/security.md.
494 if (claims.expiresAtMs <= 0 || nowMs > claims.expiresAtMs) {
495 return std::unexpected(AuthError::Expired);
496 }
497 // Not-before / issued-at check. When `issuedAtMs` is set (positive), a
498 // token whose issue time is more than `kClockSkewMs` in the future is
499 // not yet valid — it was minted against a clock ahead of ours beyond the
500 // tolerated skew. An unset (`0`) or non-positive `issuedAtMs` skips this
501 // check (issue time is optional/informational).
502 if (claims.issuedAtMs > 0 && claims.issuedAtMs - nowMs > kClockSkewMs) {
503 return std::unexpected(AuthError::NotYetValid);
504 }
505 return claims;
506 }
507
508private:
509 std::string _secret;
510 MacFunction _mac;
511};
512
513/// @brief `IAuthorizer` that authenticates via a signed `Context::token`.
514///
515/// `authorize` returns `true` only for a token with a valid signature and
516/// unexpired claims (and, if a policy is supplied, one the policy admits).
517/// `authenticate` returns the verified principal so `RemoteServer` makes it
518/// authoritative. Install it on the server:
519/// @code
520/// auto authz = std::make_shared<morph::session::SigningAuthorizer>(sharedSecret);
521/// auto server = std::make_shared<morph::backend::RemoteServer>(pool, authz);
522/// @endcode
524public:
525 /// @brief Optional per-request policy over verified claims.
526 ///
527 /// Receives the verified token plus the target ids; return `false` to deny.
528 /// The action id is **empty** for the `instances`, `schemas` and
529 /// `deregister` envelopes, and the model type id is empty for `deregister`
530 /// -- see `IAuthorizer::authorize`'s own doc comment for why, and handle
531 /// those cases explicitly rather than letting them reach a default arm.
532 /// The default (empty) admits any validly-signed, unexpired token.
533 using Policy =
534 std::function<bool(const SessionToken& claims, std::string_view modelType, std::string_view actionType)>;
535
536#ifdef MORPH_REQUIRE_VETTED_HMAC
537 /// @brief Constructs the authorizer.
538 ///
539 /// Built with `MORPH_REQUIRE_VETTED_HMAC` defined: @p mac has **no
540 /// default**, so a call site that relied on the reference `hmacSha256`
541 /// default fails to compile. See `docs/spec/security.md` ("MAC-primitive
542 /// recommended wiring").
543 /// @param secret Shared secret used to verify tokens.
544 /// @param mac MAC primitive (required — no default under this build
545 /// option). Forwarded into this authorizer's `TokenVerifier`
546 /// and invoked for its whole lifetime, so anything the
547 /// callable refers to must outlive it.
548 /// @param clock Time source (ms since epoch) for expiry; defaults to system
549 /// time. Stored and invoked for this authorizer's whole
550 /// lifetime, so anything the callable refers to must outlive it.
551 /// @param policy Optional per-request policy over verified claims. Stored and
552 /// invoked on the same terms as @p clock.
553 explicit SigningAuthorizer(std::string secret, MacFunction mac MORPH_LIFETIMEBOUND,
554 Clock clock MORPH_LIFETIMEBOUND = systemClockMs, Policy policy MORPH_LIFETIMEBOUND = {})
555 : _verifier{std::move(secret), std::move(mac)}, _clock{std::move(clock)}, _policy{std::move(policy)} {}
556#else
557 /// @brief Constructs the authorizer.
558 /// @param secret Shared secret used to verify tokens.
559 /// @param mac MAC primitive; defaults to `hmacSha256`. Forwarded into
560 /// this authorizer's `TokenVerifier` and invoked for its whole
561 /// lifetime, so anything the callable refers to must outlive it.
562 /// @param clock Time source (ms since epoch) for expiry; defaults to system
563 /// time. Stored and invoked for this authorizer's whole
564 /// lifetime, so anything the callable refers to must outlive it.
565 /// @param policy Optional per-request policy over verified claims. Stored and
566 /// invoked on the same terms as @p clock.
567 explicit SigningAuthorizer(std::string secret, MacFunction mac MORPH_LIFETIMEBOUND = hmacSha256,
568 Clock clock MORPH_LIFETIMEBOUND = systemClockMs, Policy policy MORPH_LIFETIMEBOUND = {})
569 : _verifier{std::move(secret), std::move(mac)}, _clock{std::move(clock)}, _policy{std::move(policy)} {}
570#endif
571
572 /// @brief Allows the call iff @p ctx carries a valid token the policy admits.
573 /// @param ctx Per-call session (its `token` is verified).
574 /// @param modelType Target model type id (passed to the policy).
575 /// @param actionType Target action type id (passed to the policy).
576 /// @return `true` to allow dispatch, `false` to reject.
577 [[nodiscard]] bool authorize(const Context& ctx,
578 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
579 std::string_view modelType, std::string_view actionType) const override {
580 const auto claims = verified(ctx);
581 if (!claims) {
582 return false;
583 }
584 return !_policy || _policy(*claims, modelType, actionType);
585 }
586
587 /// @brief Returns the verified principal so the server can make it authoritative.
588 /// @param ctx Per-call session (its `token` is verified).
589 /// @return The token's principal if valid, else `nullopt`.
590 [[nodiscard]] std::optional<std::string> authenticate(const Context& ctx) const override {
591 const auto claims = verified(ctx);
592 if (!claims) {
593 return std::nullopt;
594 }
595 return claims->principal;
596 }
597
598 /// @brief Verifies @p ctx's token once, then asks `authenticate` and
599 /// `authorize` as the default `admit` does.
600 ///
601 /// Both are still called through the vtable, so a subclass that overrides
602 /// either keeps its own decision: a carve-out that admits a call without a
603 /// token, or a rule that refuses more than the token does. While this call
604 /// runs, `SigningAuthorizer`'s own `authenticate` and `authorize` answer
605 /// from the one verification instead of verifying again, so a token is
606 /// judged against one clock reading. Nothing outlives the call.
607 /// @param ctx Per-call session (its `token` is verified).
608 /// @param modelType Target model type id (passed to `authorize`).
609 /// @param actionType Target action type id (passed to `authorize`).
610 /// @return `authorize`'s answer and `authenticate`'s, from one
611 /// verification.
612 [[nodiscard]] Admission admit(const Context& ctx,
613 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
614 std::string_view modelType, std::string_view actionType) const override {
615 Verification const once{.owner = this, .ctx = &ctx, .claims = _verifier.verify(ctx.token, _clock())};
616 ActiveVerification const scope{once};
617 auto principal = authenticate(ctx);
618 return Admission{.allowed = authorize(ctx, modelType, actionType), .principal = std::move(principal)};
619 }
620
621private:
622 /// @brief One verification of one session's token, by one authorizer.
623 struct Verification {
624 const SigningAuthorizer* owner;
625 const Context* ctx;
626 std::expected<SessionToken, AuthError> claims;
627 };
628
629 /// @brief The verification the running `admit` on this thread made, if any.
630 static const Verification*& active() noexcept {
631 thread_local const Verification* current = nullptr;
632 return current;
633 }
634
635 /// @brief Installs a verification for the length of one `admit`, and
636 /// restores the one it replaced, on return or on a throw.
637 class ActiveVerification {
638 public:
639 explicit ActiveVerification(const Verification& verification) noexcept
640 : _previous{std::exchange(active(), &verification)} {}
641 ~ActiveVerification() { active() = _previous; }
642 ActiveVerification(const ActiveVerification&) = delete;
643 ActiveVerification& operator=(const ActiveVerification&) = delete;
644 ActiveVerification(ActiveVerification&&) = delete;
645 ActiveVerification& operator=(ActiveVerification&&) = delete;
646
647 private:
648 const Verification* _previous;
649 };
650
651 /// @brief @p ctx's claims: the running `admit`'s verification when it is
652 /// this authorizer's of this very session, else a fresh one.
653 [[nodiscard]] std::expected<SessionToken, AuthError> verified(const Context& ctx) const {
654 if (const auto* running = active(); running != nullptr && running->owner == this && running->ctx == &ctx) {
655 return running->claims;
656 }
657 return _verifier.verify(ctx.token, _clock());
658 }
659
660 TokenVerifier _verifier;
661 Clock _clock;
662 Policy _policy;
663};
664
665} // namespace morph::session
IAuthorizer that authenticates via a signed Context::token.
bool authorize(const Context &ctx, std::string_view modelType, std::string_view actionType) const override
Allows the call iff ctx carries a valid token the policy admits.
std::optional< std::string > authenticate(const Context &ctx) const override
Returns the verified principal so the server can make it authoritative.
std::function< bool(const SessionToken &claims, std::string_view modelType, std::string_view actionType)> Policy
Optional per-request policy over verified claims.
SigningAuthorizer(std::string secret, MacFunction mac=hmacSha256, Clock clock=systemClockMs, Policy policy={})
Constructs the authorizer.
Admission admit(const Context &ctx, std::string_view modelType, std::string_view actionType) const override
Verifies ctx's token once, then asks authenticate and authorize as the default admit does.
TokenIssuer(std::string secret, MacFunction mac=hmacSha256)
Constructs an issuer over secret using mac.
std::string issue(const SessionToken &claims) const
Serialises claims and returns a signed token string.
std::expected< SessionToken, AuthError > verify(std::string_view token, int64_t nowMs) const
Verifies token's signature and expiry and returns its claims.
TokenVerifier(std::string secret, MacFunction mac=hmacSha256)
Constructs a verifier over secret using mac.
Compile-time profiler instrumentation: Tracy zones, plots, messages and thread names,...
#define MORPH_ZONE(name)
Opens a profiler zone named name that ends with the enclosing scope. At most one per scope.
Definition profiler.hpp:118
constexpr int64_t kClockSkewMs
Allowed clock skew (ms) between issuer and verifier for the not-before (issuedAtMs) check....
std::string hmacSha256(std::string_view key, std::string_view message)
Reference HMAC-SHA256 MacFunction (the default). Returns raw MAC bytes.
int64_t systemClockMs()
Default clock: std::chrono::system_clock in milliseconds since epoch.
std::function< int64_t()> Clock
Wall-clock source (milliseconds since the Unix epoch), injectable for tests.
constexpr std::string_view kB64Alphabet
The url-safe base64 alphabet (RFC 4648 §5): - and _ replace +//.
bool constantTimeEquals(std::string_view lhs, std::string_view rhs)
Constant-time byte-string equality (guards against MAC timing leaks).
std::string hmacSha256Raw(std::string_view key, std::string_view msg)
HMAC-SHA256 (RFC 2104). Returns the raw 32-byte MAC.
std::optional< std::string > base64UrlDecode(std::string_view in)
Decodes canonical base64url text (no padding). Returns nullopt on invalid input.
void throwOnGlazeError(const glz::error_ctx &errCode, std::string_view context)
Converts a Glaze error into a TokenIssuanceError, or does nothing if errCode reports success....
std::string sha256(std::string_view data)
SHA-256 (FIPS 180-4). Returns the raw 32-byte digest.
AuthError
Why token verification failed.
@ NotYetValid
issuedAtMs is set and more than kClockSkewMs in the future.
@ Malformed
Not payload.sig, bad base64url, or unparseable claims.
@ Expired
Missing/non-positive expiresAtMs, or it is in the past.
@ BadSignature
MAC did not match — forged or tampered.
std::string base64UrlEncode(std::string_view in)
base64url-encodes in without padding (RFC 4648 §5).
std::function< std::string(std::string_view key, std::string_view message)> MacFunction
Pluggable message-authentication primitive: mac(secret, message).
Per-call session context carried through the bridge to the model.
Definition session.hpp:26
std::string token
Bearer credential verified server-side (empty if unauthenticated).
Definition session.hpp:42
What admit decides about one call: whether it may run, and under which verified principal.
Definition session.hpp:172
Authorizes incoming actions on a RemoteServer.
Definition session.hpp:137
Verified claims carried by a session token.
std::string principal
Authenticated user/principal id.
std::vector< std::string > roles
Coarse-grained roles the app can key authorization policy on.
int64_t expiresAtMs
Expiry, ms since epoch. Must be strictly positive — a token must carry a real expiry....
int64_t issuedAtMs
Issue time, ms since epoch. If positive, verify rejects a token whose issuedAtMs is more than kClockS...
Thrown by TokenIssuer::issue if serialising the claims fails.
Write options that escape ASCII control bytes as \\uXXXX sequences.
bool escape_control_characters
Emit control bytes as \\uXXXX rather than raw.