PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
jwt_validator.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
14
15#include <algorithm>
16#include <array>
17#include <set>
18#include <chrono>
19#include <cstddef>
20#include <cstdint>
21#include <cstring>
22#include <string>
23
24// Crow JSON for parsing JWT header/payload
25#include <crow/json.h>
26
27// OpenSSL for signature verification (conditional)
28#ifdef PACS_WITH_DIGITAL_SIGNATURES
29#include <openssl/bio.h>
30#include <openssl/err.h>
31#include <openssl/evp.h>
32#include <openssl/pem.h>
33#endif
34
36
37// =============================================================================
38// Base64url Decode
39// =============================================================================
40
41namespace {
42
43// Base64url alphabet (RFC 4648 Section 5)
44constexpr std::array<int, 256> make_decode_table() {
45 std::array<int, 256> table{};
46 for (auto& v : table) v = -1;
47
48 for (int i = 0; i < 26; ++i) table[static_cast<unsigned char>('A' + i)] = i;
49 for (int i = 0; i < 26; ++i) table[static_cast<unsigned char>('a' + i)] = 26 + i;
50 for (int i = 0; i < 10; ++i) table[static_cast<unsigned char>('0' + i)] = 52 + i;
51 table[static_cast<unsigned char>('-')] = 62; // Base64url uses - instead of +
52 table[static_cast<unsigned char>('_')] = 63; // Base64url uses _ instead of /
53
54 return table;
55}
56
57constexpr auto kDecodeTable = make_decode_table();
58
60std::string json_string(const crow::json::rvalue& json, const char* key) {
61 if (!json.has(key)) return {};
62 auto& v = json[key];
63 if (v.t() == crow::json::type::String) {
64 return std::string(v.s());
65 }
66 return {};
67}
68
70std::int64_t json_int64(const crow::json::rvalue& json, const char* key) {
71 if (!json.has(key)) return 0;
72 auto& v = json[key];
73 if (v.t() == crow::json::type::Number) {
74 return v.i();
75 }
76 return 0;
77}
78
80std::int64_t current_timestamp() {
81 return std::chrono::duration_cast<std::chrono::seconds>(
82 std::chrono::system_clock::now().time_since_epoch())
83 .count();
84}
85
87std::vector<std::string> parse_scopes(const std::string& scope_string) {
88 std::vector<std::string> scopes;
89 std::string current;
90 for (char c : scope_string) {
91 if (c == ' ') {
92 if (!current.empty()) {
93 scopes.push_back(std::move(current));
94 current.clear();
95 }
96 } else {
97 current += c;
98 }
99 }
100 if (!current.empty()) {
101 scopes.push_back(std::move(current));
102 }
103 return scopes;
104}
105
106} // namespace
107
108std::optional<std::string> base64url_decode(std::string_view input) {
109 if (input.empty()) return std::string{};
110
111 // Calculate output size (3 bytes per 4 chars)
112 size_t padding = (4 - (input.size() % 4)) % 4;
113 size_t total_len = input.size() + padding;
114 size_t output_len = (total_len / 4) * 3;
115
116 std::string result;
117 result.reserve(output_len);
118
119 uint32_t buffer = 0;
120 int bits_collected = 0;
121
122 for (char c : input) {
123 if (c == '=') continue; // Skip padding if present
124
125 int val = kDecodeTable[static_cast<unsigned char>(c)];
126 if (val < 0) return std::nullopt; // Invalid character
127
128 buffer = (buffer << 6) | static_cast<uint32_t>(val);
129 bits_collected += 6;
130
131 if (bits_collected >= 8) {
132 bits_collected -= 8;
133 result += static_cast<char>((buffer >> bits_collected) & 0xFF);
134 }
135 }
136
137 return result;
138}
139
140// =============================================================================
141// JWT Error Messages
142// =============================================================================
143
144std::string_view jwt_error_message(jwt_error error) noexcept {
145 switch (error) {
146 case jwt_error::none:
147 return "no error";
149 return "malformed JWT token: expected 3 dot-separated segments";
151 return "invalid Base64url encoding in JWT segment";
153 return "invalid JSON in JWT segment";
155 return "unsupported JWT signing algorithm";
157 return "JWT signature verification failed";
159 return "JWT token has expired";
161 return "JWT token is not yet valid";
163 return "JWT issuer does not match expected value";
165 return "JWT audience does not match expected value";
167 return "required JWT claim is missing";
169 return "signature verification not available (OpenSSL required)";
170 }
171 return "unknown JWT error";
172}
173
174// =============================================================================
175// jwt_validator Implementation
176// =============================================================================
177
179 : config_(config) {}
180
181std::pair<jwt_token, jwt_error> jwt_validator::decode(
182 std::string_view token_string) const {
183
184 jwt_token token;
185
186 // Split into 3 parts: header.payload.signature
187 auto dot1 = token_string.find('.');
188 if (dot1 == std::string_view::npos) {
189 return {token, jwt_error::malformed_token};
190 }
191
192 auto dot2 = token_string.find('.', dot1 + 1);
193 if (dot2 == std::string_view::npos) {
194 return {token, jwt_error::malformed_token};
195 }
196
197 // Check no additional dots
198 if (token_string.find('.', dot2 + 1) != std::string_view::npos) {
199 return {token, jwt_error::malformed_token};
200 }
201
202 auto header_b64 = token_string.substr(0, dot1);
203 auto payload_b64 = token_string.substr(dot1 + 1, dot2 - dot1 - 1);
204 auto signature_b64 = token_string.substr(dot2 + 1);
205
206 // Store header.payload for signature verification
207 token.header_payload = std::string(token_string.substr(0, dot2));
208
209 // Decode header
210 auto header_json = base64url_decode(header_b64);
211 if (!header_json) {
212 return {token, jwt_error::invalid_base64};
213 }
214
215 // Decode payload
216 auto payload_json = base64url_decode(payload_b64);
217 if (!payload_json) {
218 return {token, jwt_error::invalid_base64};
219 }
220
221 // Decode signature
222 auto sig_bytes = base64url_decode(signature_b64);
223 if (!sig_bytes) {
224 return {token, jwt_error::invalid_base64};
225 }
226 token.signature_bytes = std::move(*sig_bytes);
227
228 // Parse header JSON
229 auto header_rv = crow::json::load(*header_json);
230 if (!header_rv) {
231 return {token, jwt_error::invalid_json};
232 }
233
234 token.header.alg = json_string(header_rv, "alg");
235 token.header.typ = json_string(header_rv, "typ");
236 token.header.kid = json_string(header_rv, "kid");
237
238 if (token.header.alg.empty()) {
239 return {token, jwt_error::missing_required_claim};
240 }
241
242 // Block dangerous algorithms unconditionally (CVE: JWT alg:none attack)
243 static const std::set<std::string> kDangerousAlgorithms = {
244 "none", "None", "NONE", "HS256", "HS384", "HS512"};
245 if (kDangerousAlgorithms.count(token.header.alg)) {
246 return {token, jwt_error::unsupported_algorithm};
247 }
248
249 // Check algorithm is allowed
250 auto& allowed = config_.allowed_algorithms;
251 if (!allowed.empty()) {
252 bool alg_allowed = std::any_of(
253 allowed.begin(), allowed.end(),
254 [&](const std::string& a) { return a == token.header.alg; });
255 if (!alg_allowed) {
256 return {token, jwt_error::unsupported_algorithm};
257 }
258 }
259
260 // Parse payload JSON
261 auto payload_rv = crow::json::load(*payload_json);
262 if (!payload_rv) {
263 return {token, jwt_error::invalid_json};
264 }
265
266 token.claims.iss = json_string(payload_rv, "iss");
267 token.claims.sub = json_string(payload_rv, "sub");
268 token.claims.aud = json_string(payload_rv, "aud");
269 token.claims.exp = json_int64(payload_rv, "exp");
270 token.claims.iat = json_int64(payload_rv, "iat");
271 token.claims.nbf = json_int64(payload_rv, "nbf");
272 token.claims.jti = json_string(payload_rv, "jti");
273
274 // Parse scopes from "scope" claim (space-delimited string per RFC 6749)
275 auto scope_str = json_string(payload_rv, "scope");
276 if (!scope_str.empty()) {
277 token.claims.scopes = parse_scopes(scope_str);
278 }
279
280 return {token, jwt_error::none};
281}
282
284 auto now = current_timestamp();
285 auto skew = static_cast<std::int64_t>(config_.clock_skew_seconds);
286
287 // Check expiration
288 if (claims.exp > 0 && now > claims.exp + skew) {
290 }
291
292 // Check not-before
293 if (claims.nbf > 0 && now < claims.nbf - skew) {
295 }
296
297 // Check issuer
298 if (!config_.issuer.empty() && claims.iss != config_.issuer) {
300 }
301
302 // Check audience
303 if (!config_.audience.empty() && claims.aud != config_.audience) {
305 }
306
307 // Sub claim is typically required
308 if (claims.sub.empty()) {
310 }
311
312 return jwt_error::none;
313}
314
315// =============================================================================
316// Signature Verification (OpenSSL)
317// =============================================================================
318
319#ifdef PACS_WITH_DIGITAL_SIGNATURES
320
321namespace {
322
324struct bio_deleter {
325 void operator()(BIO* bio) const { BIO_free_all(bio); }
326};
327using bio_ptr = std::unique_ptr<BIO, bio_deleter>;
328
330struct pkey_deleter {
331 void operator()(EVP_PKEY* key) const { EVP_PKEY_free(key); }
332};
333using pkey_ptr = std::unique_ptr<EVP_PKEY, pkey_deleter>;
334
336struct md_ctx_deleter {
337 void operator()(EVP_MD_CTX* ctx) const { EVP_MD_CTX_free(ctx); }
338};
339using md_ctx_ptr = std::unique_ptr<EVP_MD_CTX, md_ctx_deleter>;
340
342pkey_ptr load_public_key(std::string_view pem) {
343 bio_ptr bio(BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())));
344 if (!bio) return nullptr;
345
346 EVP_PKEY* key = PEM_read_bio_PUBKEY(bio.get(), nullptr, nullptr, nullptr);
347 return pkey_ptr(key);
348}
349
351bool verify_evp_signature(
352 EVP_PKEY* key,
353 const EVP_MD* md,
354 const std::string& signed_data,
355 const std::string& signature) {
356
357 md_ctx_ptr ctx(EVP_MD_CTX_new());
358 if (!ctx) return false;
359
360 if (EVP_DigestVerifyInit(ctx.get(), nullptr, md, nullptr, key) != 1) {
361 return false;
362 }
363
364 if (EVP_DigestVerifyUpdate(ctx.get(), signed_data.data(),
365 signed_data.size()) != 1) {
366 return false;
367 }
368
369 int rc = EVP_DigestVerifyFinal(
370 ctx.get(),
371 reinterpret_cast<const unsigned char*>(signature.data()),
372 signature.size());
373
374 return rc == 1;
375}
376
380std::string es256_raw_to_der(const std::string& raw_sig) {
381 if (raw_sig.size() != 64) return {};
382
383 auto encode_integer = [](const unsigned char* data, size_t len,
384 std::string& out) {
385 // Skip leading zeros but keep at least one byte
386 size_t start = 0;
387 while (start < len - 1 && data[start] == 0) ++start;
388
389 // Add leading zero if high bit set (positive integer)
390 bool need_pad = (data[start] & 0x80) != 0;
391
392 out += static_cast<char>(0x02); // INTEGER tag
393 out += static_cast<char>(len - start + (need_pad ? 1 : 0));
394 if (need_pad) out += '\0';
395 out.append(reinterpret_cast<const char*>(data + start), len - start);
396 };
397
398 const auto* sig_data = reinterpret_cast<const unsigned char*>(raw_sig.data());
399
400 std::string r_encoded, s_encoded;
401 encode_integer(sig_data, 32, r_encoded);
402 encode_integer(sig_data + 32, 32, s_encoded);
403
404 std::string der;
405 der += static_cast<char>(0x30); // SEQUENCE tag
406 der += static_cast<char>(r_encoded.size() + s_encoded.size());
407 der += r_encoded;
408 der += s_encoded;
409
410 return der;
411}
412
413} // namespace
414
416 const jwt_token& token,
417 std::string_view public_key_pem) const {
418
419 auto key = load_public_key(public_key_pem);
420 if (!key) return false;
421
422 return verify_evp_signature(
423 key.get(), EVP_sha256(),
424 token.header_payload, token.signature_bytes);
425}
426
428 const jwt_token& token,
429 std::string_view public_key_pem) const {
430
431 auto key = load_public_key(public_key_pem);
432 if (!key) return false;
433
434 // Convert raw R||S to DER format for OpenSSL
435 auto der_sig = es256_raw_to_der(token.signature_bytes);
436 if (der_sig.empty()) return false;
437
438 return verify_evp_signature(
439 key.get(), EVP_sha256(),
440 token.header_payload, der_sig);
441}
442
443#else // !PACS_WITH_DIGITAL_SIGNATURES
444
446 const jwt_token& /*token*/,
447 std::string_view /*public_key_pem*/) const {
448 return false;
449}
450
452 const jwt_token& /*token*/,
453 std::string_view /*public_key_pem*/) const {
454 return false;
455}
456
457#endif // PACS_WITH_DIGITAL_SIGNATURES
458
459// =============================================================================
460// Scope Checking
461// =============================================================================
462
464 const jwt_claims& claims,
465 std::string_view scope) noexcept {
466
467 return std::any_of(claims.scopes.begin(), claims.scopes.end(),
468 [scope](const std::string& s) { return s == scope; });
469}
470
472 const jwt_claims& claims,
473 const std::vector<std::string>& scopes) noexcept {
474
475 return std::any_of(scopes.begin(), scopes.end(),
476 [&claims](const std::string& scope) {
477 return has_scope(claims, scope);
478 });
479}
480
481const oauth2_config& jwt_validator::config() const noexcept {
482 return config_;
483}
484
485} // namespace kcenon::pacs::web::auth
jwt_validator(const oauth2_config &config)
Construct validator with OAuth 2.0 configuration.
bool verify_es256(const jwt_token &token, std::string_view public_key_pem) const
Verify ES256 (ECDSA-SHA256) signature.
static bool has_any_scope(const jwt_claims &claims, const std::vector< std::string > &scopes) noexcept
Check if token has any of the specified scopes.
jwt_error validate_claims(const jwt_claims &claims) const
Validate JWT claims against configuration.
static bool has_scope(const jwt_claims &claims, std::string_view scope) noexcept
Check if token has a specific scope.
const oauth2_config & config() const noexcept
Get the OAuth 2.0 configuration.
bool verify_rs256(const jwt_token &token, std::string_view public_key_pem) const
Verify RS256 (RSA-SHA256) signature.
std::pair< jwt_token, jwt_error > decode(std::string_view token_string) const
Decode a JWT token string into its components.
JWT (JSON Web Token) validation for OAuth 2.0.
jwt_error
JWT validation error codes.
@ invalid_base64
Base64url decoding failed.
@ invalid_signature
Signature verification failed.
@ invalid_audience
Audience doesn't match expected value.
@ token_expired
Token has expired (exp claim)
@ invalid_issuer
Issuer doesn't match expected value.
@ malformed_token
Token doesn't have 3 dot-separated parts.
@ token_not_yet_valid
Token not yet valid (nbf claim)
@ signature_not_available
OpenSSL not available for verification.
@ unsupported_algorithm
Algorithm not in allowed list.
@ missing_required_claim
Required claim is missing.
@ invalid_json
JSON parsing failed.
std::string_view jwt_error_message(jwt_error error) noexcept
Get human-readable description for a JWT error.
std::optional< std::string > base64url_decode(std::string_view input)
Decode a Base64url-encoded string (RFC 4648 Section 5)
Decoded JWT claims (payload)
std::string sub
Subject (user identifier)
std::int64_t exp
Expiration time (Unix timestamp)
std::int64_t nbf
Not before (Unix timestamp)
std::vector< std::string > scopes
OAuth 2.0 scopes (from "scope" claim)
std::int64_t iat
Issued at (Unix timestamp)
std::string typ
Token type (JWT)
std::string kid
Key ID for key selection.
std::string alg
Algorithm (RS256, ES256)
Decoded JWT token with raw segments for signature verification.
std::string signature_bytes
Decoded signature bytes.
std::string header_payload
"header.payload" for signature input
OAuth 2.0 configuration for DICOMweb authorization.
std::string audience
Expected audience (aud claim). Empty = skip audience validation.
std::uint32_t clock_skew_seconds
Allowed clock skew in seconds for exp/nbf validation.
std::string issuer
Expected token issuer (iss claim). Empty = skip issuer validation.
std::vector< std::string > allowed_algorithms
Allowed signing algorithms (default: RS256, ES256)