Skip to content

Commit 1839953

Browse files
authored
Merge pull request #27 from DuendeSoftware/wca/jwt-decoder/bugfixes
Some bugfixes and improvements to the JWT decoder
2 parents c95f5c8 + 4735308 commit 1839953

File tree

4 files changed

+152
-53
lines changed

4 files changed

+152
-53
lines changed

src/Pages/Home/JwtDecoder/JwtDecoder.cshtml

Lines changed: 142 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -332,38 +332,10 @@
332332
});
333333
334334
const jwtInput = $('#jwt-input');
335-
jwtInput.on('input', async function() {
336-
decodedJwt = {
337-
header: null,
338-
payload: null,
339-
signature: null
340-
};
341-
342-
const jwt = $(this).text();
343-
if (jwt) {
344-
try {
345-
const parts = jwt.split('.');
346-
if (parts.length === 3) {
347-
const header = JSON.parse(atob(parts[0]));
348-
const payload = JSON.parse(atob(parts[1]));
349-
const signature = parts[2];
350-
351-
decodedJwt = {
352-
header: header,
353-
payload: payload,
354-
signature: signature
355-
};
356-
await showDecodedJwt(parts, header, payload, signature);
357-
colorJwtInput($(this), parts);
358-
} else {
359-
showError('Invalid JWT format. A JWT should have three parts separated by dots.');
360-
}
361-
} catch (e) {
362-
showError('Error decoding JWT: ' + e.message);
363-
}
364-
} else {
365-
await showDecodedJwt(null, null, null, ' ');
366-
}
335+
let debounceTimeout;
336+
jwtInput.on('input', function() {
337+
clearTimeout(debounceTimeout);
338+
debounceTimeout = setTimeout(async () => await parseJwt.call(this), 300); // 300ms debounce
367339
});
368340
369341
@if (Model.View?.Token != null)
@@ -372,25 +344,135 @@
372344
}
373345
else
374346
{
375-
@:await showDecodedJwt(null, null, null, ' ');
347+
@:await showDecodedJwt(null, null, '', '', ' ');
376348
}
377349
}
378350
379-
function colorJwtInput(target, parts) {
351+
async function parseJwt() {
352+
decodedJwt = {
353+
header: null,
354+
payload: null,
355+
signature: null
356+
};
357+
358+
// Clear previous state before attempting to parse a new JWT
359+
await showDecodedJwt(null, null, '', '', ' ');
360+
361+
const jwt = $(this).text();
362+
if (jwt) {
363+
try {
364+
const parts = jwt.indexOf('.') === -1 ? [jwt] : jwt.split('.');
365+
const headerInfo = parseJwtPart(parts, 0);
366+
const payloadInfo = parseJwtPart(parts, 1);
367+
const signature = parts.length > 2 ? parts[2] : '';
368+
369+
decodedJwt = {
370+
header: headerInfo.length > 0 ? headerInfo[0] : null,
371+
encodedHeader: headerInfo.length > 1 ? headerInfo[1] : '',
372+
payload: payloadInfo.length > 0 ? payloadInfo[0] : null,
373+
encodedPayload: payloadInfo.length > 1 ? payloadInfo[1] : '',
374+
signature: signature
375+
};
376+
377+
await showDecodedJwt(decodedJwt.header, decodedJwt.payload, decodedJwt.encodedHeader, decodedJwt.encodedPayload, signature);
378+
colorJwtInput($(this), parts, decodedJwt.encodedHeader, decodedJwt.encodedPayload, decodedJwt.signature);
379+
380+
if (parts.length !== 3) {
381+
showError('Invalid JWT format. A JWT should have three parts separated by dots.');
382+
}
383+
} catch (e) {
384+
showError('Error decoding JWT: ' + e.message);
385+
}
386+
} else {
387+
await showDecodedJwt(null, null, '', '', ' ');
388+
}
389+
}
390+
391+
function parseJwtPart(parts, index) {
392+
if (index < 0 || index >= parts.length) {
393+
return [];
394+
}
395+
396+
const part = parts[index];
397+
if (!part) {
398+
return [];
399+
}
400+
401+
// Find the base64 marker for a JSON object to start parsing
402+
let jsonStartPos = 0;
403+
while (jsonStartPos < part.length && part.substring(jsonStartPos, jsonStartPos + 3) !== 'eyJ') {
404+
jsonStartPos++;
405+
}
406+
if (jsonStartPos >= part.length) {
407+
return []; // No JSON object found
408+
}
409+
410+
// Find the base64 marker for a JSON object to end parsing ('In0' for '"}' or 'fQ' for '}')
411+
let jsonEndPos = part.length;
412+
while (jsonEndPos > jsonStartPos && (part.substring(jsonEndPos - 3, jsonEndPos) !== 'In0') && (part.substring(jsonEndPos - 2, jsonEndPos) !== 'fQ')) {
413+
jsonEndPos--;
414+
}
415+
if (jsonEndPos <= jsonStartPos) {
416+
return []; // No valid JSON object found
417+
}
418+
419+
try {
420+
const encodedPart = part.substring(jsonStartPos, jsonEndPos);
421+
const decodedPart = decodeBase64UrlSafe(encodedPart);
422+
return [JSON.parse(decodedPart), encodedPart];
423+
}
424+
catch {
425+
426+
}
427+
428+
return [];
429+
}
430+
431+
function colorJwtInput(target, originalParts, encodedHeader, encodedPayload, signature) {
380432
let html = '';
381-
if (parts.length > 0) {
382-
html += `<span class="text-danger">${parts[0] || ''}</span>`;
433+
if (encodedHeader) {
434+
const originalHeader = originalParts[0];
435+
if (originalHeader === encodedHeader) {
436+
html += `<span class="text-danger">${encodedHeader}</span>`;
437+
}
438+
else {
439+
// Show the additional characters before and after the "encodedHeader" part that are found in originalHeader
440+
const start = originalHeader.indexOf(encodedHeader);
441+
const end = start + encodedHeader.length;
442+
if (start > 0) {
443+
html += `<span class="skipped">${originalHeader.substring(0, start)}</span>`;
444+
}
445+
html += `<span class="text-danger">${encodedHeader}</span>`;
446+
if (end < originalHeader.length) {
447+
html += `<span class="skipped">${originalHeader.substring(end)}</span>`;
448+
}
449+
}
383450
}
384-
if (parts.length > 1) {
385-
html += `<span class="text-success">.${parts[1] || ''}</span>`;
451+
if (encodedPayload) {
452+
const originalPayload = originalParts[1];
453+
if (originalPayload === encodedPayload) {
454+
html += '<span class="jwt-divider">.</span>' + `<span class="text-success">${encodedPayload}</span>`;
455+
}
456+
else {
457+
// Show the additional characters before and after the "encodedPayload" part that are found in originalPayload
458+
const start = originalPayload.indexOf(encodedPayload);
459+
const end = start + encodedPayload.length;
460+
if (start > 0) {
461+
html += `<span class="skipped">${originalPayload.substring(0, start)}</span>`;
462+
}
463+
html += '<span class="jwt-divider">.</span>' + `<span class="text-success">${encodedPayload}</span>`;
464+
if (end < originalPayload.length) {
465+
html += `<span class="skipped">${originalPayload.substring(end)}</span>`;
466+
}
467+
}
386468
}
387-
if (parts.length > 2) {
388-
html += `<span class="text-warning">.${parts.slice(2).join('.') || ''}</span>`;
469+
if (signature) {
470+
html += '<span class="jwt-divider">.</span>' + `<span class="text-warning">${signature}</span>`;
389471
}
390472
target.html(html);
391473
}
392474
393-
async function showDecodedJwt(jwtParts, header, payload, signature) {
475+
async function showDecodedJwt(header, payload, encodedHeader, encodedPayload, signature) {
394476
clearError();
395477
hideSignatureValidationResults();
396478
@@ -399,8 +481,8 @@
399481
400482
$('#jwt-signature').text(signature || ' ');
401483
402-
if (jwtParts && Array.isArray(jwtParts) && jwtParts.length === 3) {
403-
await attemptSignatureValidation(header, payload, jwtParts);
484+
if (encodedHeader && encodedPayload && signature) {
485+
await attemptSignatureValidation(header, payload, encodedHeader, encodedPayload, signature);
404486
}
405487
}
406488
@@ -517,7 +599,7 @@
517599
return contents;
518600
}
519601
520-
async function attemptSignatureValidation(header, payload, jwtParts) {
602+
async function attemptSignatureValidation(header, payload, encodedHeader, encodedPayload, signature) {
521603
const jwksUrlField = $('#jwks-url');
522604
523605
const isPristine = jwksUrlField.data('pristine') !== false;
@@ -530,14 +612,18 @@
530612
531613
if (jwksUrl) {
532614
await loadJwks(jwksUrl);
533-
615+
616+
const headerAndPayload = encodedHeader + '.' + encodedPayload;
534617
if (jwks.keys.length === 0) {
535-
showSignatureValidationResult('warning', 'No JWKs loaded. Cannot validate signature.');
618+
if (signature) {
619+
showSignatureValidationResult('warning', 'The JWT has a signature, but no JWKs could be loaded to verify whether the signature is valid.');
620+
}
621+
else {
622+
showSignatureValidationResult('warning', 'No JWKs loaded and no signature to validate.');
623+
}
536624
return;
537625
}
538-
539-
const headerAndPayload = jwtParts[0] + '.' + jwtParts[1];
540-
const signature = jwtParts[2];
626+
541627
const result = await validateSignature(header, headerAndPayload, signature, jwks.keys);
542628
543629
if (result.signatureValidated) {
@@ -620,8 +706,7 @@
620706
['verify']
621707
);
622708
623-
signature = signature.replace(/-/g, '+').replace(/_/g, '/'); // Replace URL-safe characters with standard Base64 characters
624-
const binarySignature = atob(signature);
709+
const binarySignature = decodeBase64UrlSafe(signature);
625710
const signatureBuffer = Uint8Array.from(binarySignature, c => c.charCodeAt(0));
626711
const isValid = await subtle.verify(algorithm, publicKey, signatureBuffer, new TextEncoder().encode(headerAndPayload));
627712
return { signatureValidated: true, isValid: isValid };
@@ -658,7 +743,8 @@
658743
return null;
659744
}
660745
661-
if (!response.headers.get('Content-Type').startsWith('application/json')) {
746+
const contentType = response.headers.get('Content-Type');
747+
if (!contentType || !contentType.startsWith('application/json')) {
662748
if (url.toLowerCase().indexOf('.well-known') !== -1 || url.toLowerCase().endsWith('.json')) {
663749
showError('The provided JWKs URL does not return a valid JSON response.');
664750
return null;
@@ -707,5 +793,9 @@
707793
function hideSignatureValidationResults() {
708794
$('.jwt-signature-validation-result').removeClass('d-flex').addClass('d-none');
709795
}
796+
797+
function decodeBase64UrlSafe(base64Data) {
798+
return atob(base64Data.replace(/-/g, '+').replace(/_/g, '/'));
799+
}
710800
</script>
711801
}

src/wwwroot/css/site.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ a.navbar-brand .icon-banner {
127127
}
128128
.jwt-decoder-container .jwt-input-editable {
129129
word-wrap: anywhere;
130+
max-width: 100vw;
130131
}
131132
.jwt-decoder-container .jwt-input-editable .text-danger {
132133
color: #c586c0 !important;
@@ -137,6 +138,9 @@ a.navbar-brand .icon-banner {
137138
.jwt-decoder-container .jwt-input-editable .text-warning {
138139
color: #86c0c5 !important;
139140
}
141+
.jwt-decoder-container .jwt-input-editable .skipped {
142+
text-decoration: line-through;
143+
}
140144
.jwt-decoder-container .json-content, .jwt-decoder-container .jwt-input-editable {
141145
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
142146
font-size: 1em;

src/wwwroot/css/site.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/wwwroot/css/site.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ a.navbar-brand {
145145

146146
.jwt-input-editable {
147147
word-wrap: anywhere;
148+
max-width: 100vw;
148149

149150
.text-danger {
150151
color: #c586c0 !important;
@@ -157,6 +158,10 @@ a.navbar-brand {
157158
.text-warning {
158159
color: #86c0c5 !important;
159160
}
161+
162+
.skipped {
163+
text-decoration: line-through;
164+
}
160165
}
161166

162167
.json-content, .jwt-input-editable {

0 commit comments

Comments
 (0)