cxxmcp 1.1.6
C++ MCP SDK
Loading...
Searching...
No Matches
dpop.hpp
Go to the documentation of this file.
1// Copyright (c) 2025 [caomengxuan666]
2
3#pragma once
4
5#include <openssl/bio.h>
6#include <openssl/evp.h>
7#include <openssl/pem.h>
8#include <openssl/rand.h>
9
10#include <chrono>
11#include <cstdint>
12#include <functional>
13#include <limits>
14#include <memory>
15#include <nlohmann/json.hpp>
16#include <optional>
17#include <string>
18#include <utility>
19#include <vector>
20
21#include "cxxmcp/auth/dpop.hpp"
26
29
30namespace mcp::auth::openssl {
31
32namespace detail {
33
34struct BioDeleter {
35 void operator()(BIO* value) const noexcept { BIO_free(value); }
36};
37
38using BioPtr = std::unique_ptr<BIO, BioDeleter>;
39
40inline core::Result<EvpPkeyPtr> private_key_from_pem(std::string_view pem) {
41 if (pem.empty()) {
42 return core::unexpected(make_jose_error(
43 JoseErrorCode::kInvalidJwk, "DPoP private key PEM is required"));
44 }
45 if (pem.size() >
46 static_cast<std::size_t>((std::numeric_limits<int>::max)())) {
47 return core::unexpected(make_jose_error(
48 JoseErrorCode::kInvalidJwk, "DPoP private key PEM is too large"));
49 }
50
51 BioPtr bio(BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())));
52 if (!bio) {
53 return core::unexpected(
54 make_jose_error(JoseErrorCode::kInvalidJwk,
55 "failed to allocate OpenSSL private-key BIO"));
56 }
57
58 EVP_PKEY* raw = PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr);
59 if (raw == nullptr) {
60 return core::unexpected(make_jose_error(
61 JoseErrorCode::kInvalidJwk, "failed to parse DPoP private key PEM"));
62 }
63 return EvpPkeyPtr(raw);
64}
65
66inline core::Result<std::string> random_jwt_id() {
67 std::vector<unsigned char> bytes(16);
68 if (RAND_bytes(bytes.data(), static_cast<int>(bytes.size())) != 1) {
69 return core::unexpected(
70 make_jose_error(JoseErrorCode::kSignatureVerificationFailed,
71 "failed to generate DPoP jti"));
72 }
73 return base64url_encode(bytes);
74}
75
76inline std::int64_t unix_seconds_from_time(TimePoint value) {
77 return std::chrono::duration_cast<std::chrono::seconds>(
78 value.time_since_epoch())
79 .count();
80}
81
82inline TimePoint time_from_unix_seconds(std::int64_t seconds) {
83 return TimePoint(std::chrono::seconds(seconds));
84}
85
86inline nlohmann::json public_jwk_to_json(const JsonWebKey& jwk) {
87 nlohmann::json value;
88 value["kty"] = jwk.key_type;
89 if (jwk.algorithm.has_value()) {
90 value["alg"] = *jwk.algorithm;
91 }
92 if (jwk.key_id.has_value()) {
93 value["kid"] = *jwk.key_id;
94 }
95 if (jwk.curve.has_value()) {
96 value["crv"] = *jwk.curve;
97 }
98 if (jwk.x.has_value()) {
99 value["x"] = *jwk.x;
100 }
101 if (jwk.y.has_value()) {
102 value["y"] = *jwk.y;
103 }
104 if (jwk.modulus.has_value()) {
105 value["n"] = *jwk.modulus;
106 }
107 if (jwk.exponent.has_value()) {
108 value["e"] = *jwk.exponent;
109 }
110 return value;
111}
112
113inline core::Result<std::string> sign_compact_jws(
114 EVP_PKEY* key, std::string_view algorithm, const nlohmann::json& header,
115 const nlohmann::json& payload) {
116 const EVP_MD* digest = digest_for_jose_algorithm(algorithm);
117 if (digest == nullptr) {
118 return core::unexpected(
119 make_jose_error(JoseErrorCode::kUnsupportedJoseAlgorithm,
120 "unsupported DPoP signing algorithm"));
121 }
122
123 const std::string encoded_header = base64url_encode(header.dump());
124 const std::string encoded_payload = base64url_encode(payload.dump());
125 const std::string signing_input = encoded_header + "." + encoded_payload;
126
127 EvpMdCtxPtr context(EVP_MD_CTX_new());
128 if (!context ||
129 EVP_DigestSignInit(context.get(), nullptr, digest, nullptr, key) <= 0 ||
130 EVP_DigestSignUpdate(context.get(), signing_input.data(),
131 signing_input.size()) <= 0) {
132 return core::unexpected(
133 make_jose_error(JoseErrorCode::kSignatureVerificationFailed,
134 "failed to initialize DPoP proof signing"));
135 }
136
137 std::size_t signature_size = 0;
138 if (EVP_DigestSignFinal(context.get(), nullptr, &signature_size) <= 0) {
139 return core::unexpected(
140 make_jose_error(JoseErrorCode::kSignatureVerificationFailed,
141 "failed to size DPoP proof signature"));
142 }
143 std::vector<unsigned char> signature(signature_size);
144 if (EVP_DigestSignFinal(context.get(), signature.data(), &signature_size) <=
145 0) {
146 return core::unexpected(
147 make_jose_error(JoseErrorCode::kSignatureVerificationFailed,
148 "failed to sign DPoP proof"));
149 }
150 signature.resize(signature_size);
151
152 if (is_ecdsa_jose_algorithm(algorithm)) {
153 auto raw = ecdsa_der_signature_to_raw(signature, algorithm);
154 if (!raw.has_value()) {
155 return core::unexpected(raw.error());
156 }
157 signature = std::move(*raw);
158 }
159
160 return signing_input + "." + base64url_encode(signature);
161}
162
163inline core::Result<nlohmann::json> parse_json_object_bytes(
164 const std::vector<unsigned char>& bytes, std::string message) {
165 try {
166 auto parsed = nlohmann::json::parse(bytes.begin(), bytes.end());
167 if (!parsed.is_object()) {
168 return core::unexpected(make_jose_error(
169 JoseErrorCode::kJwtClaimValidationFailed, std::move(message)));
170 }
171 return parsed;
172 } catch (const nlohmann::json::parse_error& error) {
173 return core::unexpected(
174 make_jose_error(JoseErrorCode::kJwtClaimValidationFailed,
175 std::move(message), error.what()));
176 }
177}
178
179inline std::optional<std::string> string_claim(const nlohmann::json& object,
180 const char* key) {
181 const auto iter = object.find(key);
182 if (iter == object.end() || iter->is_null() || !iter->is_string()) {
183 return std::nullopt;
184 }
185 return iter->get<std::string>();
186}
187
188inline std::optional<std::int64_t> numeric_date_claim(
189 const nlohmann::json& object, const char* key) {
190 const auto iter = object.find(key);
191 if (iter == object.end() || iter->is_null()) {
192 return std::nullopt;
193 }
194 if (iter->is_number_integer()) {
195 return iter->get<std::int64_t>();
196 }
197 if (iter->is_number_unsigned()) {
198 const auto value = iter->get<std::uint64_t>();
199 if (value <= static_cast<std::uint64_t>(
200 (std::numeric_limits<std::int64_t>::max)())) {
201 return static_cast<std::int64_t>(value);
202 }
203 }
204 return std::nullopt;
205}
206
207inline MetadataMap dpop_claim_metadata(const nlohmann::json& payload) {
208 MetadataMap result;
209 for (auto iter = payload.begin(); iter != payload.end(); ++iter) {
210 const std::string key = iter.key();
211 if (key == "jti" || key == "htm" || key == "htu" || key == "iat" ||
212 key == "ath" || key == "nonce") {
213 continue;
214 }
215 if (iter->is_string()) {
216 result.emplace(key, iter->get<std::string>());
217 } else if (iter->is_boolean()) {
218 result.emplace(key, iter->get<bool>() ? "true" : "false");
219 } else if (iter->is_number() || iter->is_array() || iter->is_object()) {
220 result.emplace(key, iter->dump());
221 }
222 }
223 return result;
224}
225
226} // namespace detail
227
229 std::string private_key_pem;
230 std::string algorithm = "ES256";
231 std::string client_id;
232 std::string audience;
233 std::optional<std::string> key_id;
234 std::optional<std::string> jwt_id;
235 TimePoint issued_at = SystemClock::now();
236 std::chrono::seconds lifetime = std::chrono::minutes(5);
237};
238
239inline core::Result<std::string> sign_private_key_jwt_assertion(
241 if (request.client_id.empty()) {
242 return core::unexpected(make_jose_error(
243 JoseErrorCode::kJwtClaimValidationFailed, "client_id is required"));
244 }
245 if (request.audience.empty()) {
246 return core::unexpected(make_jose_error(
247 JoseErrorCode::kJwtClaimValidationFailed, "audience is required"));
248 }
249
250 auto private_key = detail::private_key_from_pem(request.private_key_pem);
251 if (!private_key.has_value()) {
252 return core::unexpected(private_key.error());
253 }
254
255 nlohmann::json header;
256 header["alg"] = request.algorithm;
257 if (request.key_id.has_value()) {
258 header["kid"] = *request.key_id;
259 }
260
261 core::Result<std::string> jwt_id =
262 request.jwt_id.has_value() ? core::Result<std::string>(*request.jwt_id)
263 : detail::random_jwt_id();
264 if (!jwt_id.has_value()) {
265 return core::unexpected(jwt_id.error());
266 }
267
268 const auto issued_at = detail::unix_seconds_from_time(request.issued_at);
269 const auto expires_at =
270 detail::unix_seconds_from_time(request.issued_at + request.lifetime);
271
272 nlohmann::json payload;
273 payload["iss"] = request.client_id;
274 payload["sub"] = request.client_id;
275 payload["aud"] = request.audience;
276 payload["jti"] = *jwt_id;
277 payload["iat"] = issued_at;
278 payload["exp"] = expires_at;
279
280 return detail::sign_compact_jws(private_key->get(), request.algorithm, header,
281 payload);
282}
283
285 std::function<TimePoint()> now;
286 std::function<core::Result<std::string>()> jwt_id_generator;
287};
288
289class OpenSslDpopSigner final : public DpopSigner {
290 public:
291 explicit OpenSslDpopSigner(OpenSslDpopSignerOptions options = {})
292 : options_(std::move(options)) {}
293
294 core::Result<std::string> sign(const DpopProofRequest& request) override {
295 if (request.target.method.empty()) {
296 return core::unexpected(
297 mcp::auth::detail::dpop_error("HTTP request method is required"));
298 }
299 if (request.target.url.empty()) {
300 return core::unexpected(
301 mcp::auth::detail::dpop_error("HTTP request URL is required"));
302 }
303 if (request.key.algorithm.empty()) {
304 return core::unexpected(
305 mcp::auth::detail::dpop_error("DPoP signing algorithm is required"));
306 }
307
308 auto private_key =
309 detail::private_key_from_pem(request.key.private_key_pem.view());
310 if (!private_key.has_value()) {
311 return core::unexpected(private_key.error());
312 }
313
314 auto public_jwk = public_jwk_from_evp_pkey(
315 private_key->get(), request.key.algorithm,
316 request.key.key_id.empty()
317 ? std::optional<std::string>{}
318 : std::optional<std::string>{request.key.key_id});
319 if (!public_jwk.has_value()) {
320 return core::unexpected(public_jwk.error());
321 }
322
323 auto jwt_id = options_.jwt_id_generator ? options_.jwt_id_generator()
324 : detail::random_jwt_id();
325 if (!jwt_id.has_value()) {
326 return core::unexpected(jwt_id.error());
327 }
328
329 const TimePoint now = options_.now ? options_.now() : SystemClock::now();
330 nlohmann::json header;
331 header["typ"] = "dpop+jwt";
332 header["alg"] = request.key.algorithm;
333 if (!request.key.key_id.empty()) {
334 header["kid"] = request.key.key_id;
335 }
336 header["jwk"] = detail::public_jwk_to_json(*public_jwk);
337
338 nlohmann::json payload;
339 payload["jti"] = *jwt_id;
340 payload["htm"] = request.target.method;
341 payload["htu"] = request.target.url;
342 payload["iat"] = detail::unix_seconds_from_time(now);
343 if (request.access_token.has_value()) {
344 auto access_token_hash = dpop_access_token_hash(*request.access_token);
345 if (!access_token_hash.has_value()) {
346 return core::unexpected(access_token_hash.error());
347 }
348 payload["ath"] = *access_token_hash;
349 }
350 if (request.nonce.has_value()) {
351 payload["nonce"] = *request.nonce;
352 }
353
354 return detail::sign_compact_jws(private_key->get(), request.key.algorithm,
355 header, payload);
356 }
357
358 private:
360};
361
363 DpopClaimValidationOptions claim_validation;
364 bool validate_claims = true;
365};
366
367class OpenSslDpopVerifier final : public DpopVerifier {
368 public:
370 : options_(std::move(options)) {}
371
373 const std::string& proof_jwt, const HttpRequestTarget& target,
374 const std::optional<std::string>& access_token) override {
375 auto decoded = decode_compact_jws(proof_jwt);
376 if (!decoded.has_value()) {
377 return core::unexpected(decoded.error());
378 }
379
380 const auto& header = decoded->protected_header;
381 if (!header.type.has_value() || *header.type != "dpop+jwt") {
382 return core::unexpected(
383 make_jose_error(JoseErrorCode::kJwtClaimValidationFailed,
384 "DPoP proof typ must be dpop+jwt"));
385 }
386 const auto jwk_value = header.raw.find("jwk");
387 if (jwk_value == header.raw.end() || !jwk_value->is_object()) {
388 return core::unexpected(make_jose_error(
389 JoseErrorCode::kInvalidJwk, "DPoP proof header jwk is required"));
390 }
391
392 auto jwk = parse_json_web_key(*jwk_value);
393 if (!jwk.has_value()) {
394 return core::unexpected(jwk.error());
395 }
396 if (!jwk->algorithm.has_value()) {
397 jwk->algorithm = header.algorithm;
398 }
399 if (!jwk->key_id.has_value()) {
400 jwk->key_id = header.key_id;
401 }
402
403 JwsVerificationOptions verification_options;
404 verification_options.required_algorithm = header.algorithm;
405 verification_options.required_key_id = header.key_id;
406 auto verified =
407 verify_compact_jws_signature(proof_jwt, *jwk, verification_options);
408 if (!verified.has_value()) {
409 return core::unexpected(verified.error());
410 }
411
412 auto payload = detail::parse_json_object_bytes(
413 decoded->payload, "DPoP proof payload must be a JSON object");
414 if (!payload.has_value()) {
415 return core::unexpected(payload.error());
416 }
417
418 auto claims = parse_claims(*payload);
419 if (!claims.has_value()) {
420 return core::unexpected(claims.error());
421 }
422
423 if (options_.validate_claims) {
424 auto claim_options = options_.claim_validation;
425 if (access_token.has_value()) {
426 auto expected_hash = dpop_access_token_hash(*access_token);
427 if (!expected_hash.has_value()) {
428 return core::unexpected(expected_hash.error());
429 }
430 claim_options.expected_access_token_hash = *expected_hash;
431 }
432 auto validated = validate_dpop_proof_claims(*claims, target, access_token,
433 claim_options, nullptr);
434 if (!validated.has_value()) {
435 return core::unexpected(validated.error());
436 }
437 }
438
439 return *claims;
440 }
441
442 private:
443 static core::Result<DpopProofClaims> parse_claims(
444 const nlohmann::json& payload) {
445 auto jwt_id = detail::string_claim(payload, "jti");
446 auto method = detail::string_claim(payload, "htm");
447 auto url = detail::string_claim(payload, "htu");
448 auto issued_at = detail::numeric_date_claim(payload, "iat");
449 if (!jwt_id.has_value() || jwt_id->empty()) {
450 return core::unexpected(
451 mcp::auth::detail::dpop_error("DPoP proof jti is required"));
452 }
453 if (!method.has_value() || method->empty()) {
454 return core::unexpected(
455 mcp::auth::detail::dpop_error("DPoP proof htm is required"));
456 }
457 if (!url.has_value() || url->empty()) {
458 return core::unexpected(
459 mcp::auth::detail::dpop_error("DPoP proof htu is required"));
460 }
461 if (!issued_at.has_value()) {
462 return core::unexpected(
463 mcp::auth::detail::dpop_error("DPoP proof iat is required"));
464 }
465
466 DpopProofClaims claims;
467 claims.jwt_id = std::move(*jwt_id);
468 claims.method = std::move(*method);
469 claims.url = std::move(*url);
470 claims.issued_at = detail::time_from_unix_seconds(*issued_at);
471 claims.access_token_hash = detail::string_claim(payload, "ath");
472 claims.nonce = detail::string_claim(payload, "nonce");
473 claims.claims = detail::dpop_claim_metadata(payload);
474 return claims;
475 }
476
478};
479
480} // namespace mcp::auth::openssl
DPoP proof construction boundary.
Definition dpop.hpp:310
DPoP proof verification boundary for server-side auth providers.
Definition dpop.hpp:391
DPoP proof model and signing/verification boundaries.
core::Result< core::Unit > validate_dpop_proof_claims(const DpopProofClaims &claims, const HttpRequestTarget &target, const std::optional< std::string > &access_token, const DpopClaimValidationOptions &options={}, DpopReplayCache *replay_cache=nullptr)
Validate DPoP claims after JWT signature verification.
Definition dpop.hpp:196
OpenSSL conversion helpers for public JSON Web Keys.
core::Result< JsonWebKey > parse_json_web_key(const nlohmann::json &value)
Parse a public JWK from JSON without performing trust decisions.
Definition jwks.hpp:182
OpenSSL-backed compact JWS signature verification helpers.
Shared result and error primitives used by the public cxxmcp SDK.
tl::expected< T, Error > Result
Alias for the SDK result type.
Definition result.hpp:64
OpenSSL-backed SHA-256 helpers for optional auth crypto.
core::Result< std::string > dpop_access_token_hash(std::string_view access_token)
Compute the RFC 9449 DPoP ath value for an access token.
Definition sha256.hpp:29
Options for validating verified DPoP claims against an HTTP request.
Definition dpop.hpp:183
Parsed or verified DPoP proof claims.
Definition dpop.hpp:124
Input for constructing a DPoP proof JWT.
Definition dpop.hpp:116
Transport-neutral HTTP request descriptor used by auth helpers.
Definition types.hpp:29
Definition jws_verify.hpp:147