Skip to content

Commit 3ecc95c

Browse files
committed
feat(dev,html-app): make JWT debugger HTML app more resilient to different token formats
1 parent f064449 commit 3ecc95c

1 file changed

Lines changed: 169 additions & 5 deletions

File tree

dev/tools/jwt-debugger.html

Lines changed: 169 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,56 @@
232232
::-webkit-scrollbar-track { background: var(--bg); }
233233
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
234234
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
235+
/* Transform info banner */
236+
.transform-info {
237+
margin-bottom: 8px;
238+
padding: 8px 12px;
239+
border-radius: 8px;
240+
background: var(--badge-bg);
241+
color: var(--badge-text);
242+
font-size: 0.8rem;
243+
line-height: 1.4;
244+
}
245+
.transform-info code {
246+
font-family: 'Roboto Mono', monospace;
247+
font-size: 0.75rem;
248+
background: var(--input-bg);
249+
padding: 1px 5px;
250+
border-radius: 3px;
251+
}
252+
/* Parse error overlay on decoded panel */
253+
.parse-error-overlay {
254+
position: absolute;
255+
top: 40px;
256+
left: 0;
257+
right: 0;
258+
bottom: 0;
259+
z-index: 10;
260+
background: var(--surface);
261+
border: 1px solid var(--border);
262+
border-radius: 12px;
263+
padding: 32px 24px;
264+
font-size: 0.9rem;
265+
line-height: 1.6;
266+
color: var(--error-text);
267+
text-align: center;
268+
}
269+
.parse-error-overlay::before {
270+
content: '';
271+
display: block;
272+
width: 40px;
273+
height: 40px;
274+
margin: 0 auto 16px;
275+
border-radius: 50%;
276+
background: var(--error-bg);
277+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a7 7 0 1 1 0 14A7 7 0 0 1 8 1Zm0 1a6 6 0 1 0 0 12A6 6 0 0 0 8 2Zm0 3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3A.5.5 0 0 1 8 5Zm0 5.5a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Z' fill='%23fca5a5'/%3E%3C/svg%3E");
278+
background-repeat: no-repeat;
279+
background-position: center;
280+
background-size: 24px;
281+
}
282+
[data-theme="light"] .parse-error-overlay::before {
283+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a7 7 0 1 1 0 14A7 7 0 0 1 8 1Zm0 1a6 6 0 1 0 0 12A6 6 0 0 0 8 2Zm0 3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3A.5.5 0 0 1 8 5Zm0 5.5a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Z' fill='%23dc2626'/%3E%3C/svg%3E");
284+
}
235285
/* Splitter */
236286
.splitter { cursor: col-resize; background: transparent; position: relative; z-index: 1; }
237287
.splitter::after { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; background: var(--border); transition: width .15s, background .15s; transform: translateX(-50%); }
@@ -283,6 +333,7 @@
283333
<!-- Encoded Panel -->
284334
<div style="display: flex; flex-direction: column;">
285335
<h2 style="font-size: 1rem; font-weight: 600; margin-bottom: 8px;">Encoded</h2>
336+
<div id="transform-info" class="transform-info" style="display: none;"></div>
286337
<div class="card" style="flex: 1; position: relative;">
287338
<div class="card-body" style="height: 100%; min-height: 400px;">
288339
<div id="encoded-output" contenteditable="true" spellcheck="false" class="token-input" style="width: 100%; height: 100%; min-height: 370px; white-space: pre-wrap; word-break: break-all; outline: none; font-size: 0.875rem; line-height: 1.6;"></div>
@@ -297,8 +348,9 @@ <h2 style="font-size: 1rem; font-weight: 600; margin-bottom: 8px;">Encoded</h2>
297348
<div class="splitter" id="splitter"></div>
298349

299350
<!-- Decoded Panel -->
300-
<div style="display: flex; flex-direction: column; gap: 16px;">
351+
<div style="display: flex; flex-direction: column; gap: 16px; position: relative;">
301352
<h2 style="font-size: 1rem; font-weight: 600;">Decoded</h2>
353+
<div id="parse-error" class="parse-error-overlay" style="display: none;"></div>
302354

303355
<!-- Header -->
304356
<div class="card">
@@ -379,19 +431,129 @@ <h2 style="font-size: 1rem; font-weight: 600;">Decoded</h2>
379431
try { return decodeURIComponent(escape(atob(str))); } catch (e) { return null; }
380432
};
381433

434+
const isValidJWT = (str) => {
435+
const parts = str.split('.');
436+
if (parts.length !== 3) return false;
437+
if (!parts.every(p => p.length > 0 && /^[A-Za-z0-9_-]+$/.test(p))) return false;
438+
try {
439+
const header = JSON.parse(base64UrlDecode(parts[0]));
440+
return header && typeof header === 'object' && ('alg' in header || 'typ' in header);
441+
} catch (e) { return false; }
442+
};
443+
444+
const tryBase64Decode = (str) => {
445+
let padded = str.replace(/-/g, '+').replace(/_/g, '/');
446+
while (padded.length % 4) padded += '=';
447+
try { return atob(padded); } catch (e) { return null; }
448+
};
449+
450+
const findJWTInString = (str) => {
451+
const re = /[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
452+
let match;
453+
while ((match = re.exec(str)) !== null) {
454+
if (isValidJWT(match[0])) return match[0];
455+
}
456+
return null;
457+
};
458+
459+
const extractJWT = (input) => {
460+
input = input.trim();
461+
if (!input) return null;
462+
463+
if (isValidJWT(input)) return { jwt: input, transforms: [] };
464+
465+
const segments = input.split('_');
466+
for (let i = 1; i < segments.length && i <= 5; i++) {
467+
const remainder = segments.slice(i).join('_');
468+
const prefix = segments.slice(0, i).join('_') + '_';
469+
470+
if (isValidJWT(remainder)) {
471+
return { jwt: remainder, transforms: [`Stripped prefix <code>${prefix}</code>`] };
472+
}
473+
474+
const decoded = tryBase64Decode(remainder);
475+
if (decoded) {
476+
const jwt = findJWTInString(decoded);
477+
if (jwt) {
478+
return { jwt, transforms: [`Stripped prefix <code>${prefix}</code>`, 'Base64-decoded', 'Extracted JWT from decoded payload'] };
479+
}
480+
}
481+
}
482+
483+
const decoded = tryBase64Decode(input);
484+
if (decoded) {
485+
const jwt = findJWTInString(decoded);
486+
if (jwt) {
487+
return { jwt, transforms: ['Base64-decoded', 'Extracted JWT from decoded payload'] };
488+
}
489+
}
490+
491+
return null;
492+
};
493+
494+
const transformInfo = document.getElementById('transform-info');
495+
const parseError = document.getElementById('parse-error');
496+
497+
const showTransformInfo = (transforms) => {
498+
if (transforms && transforms.length > 0) {
499+
transformInfo.innerHTML = '<strong>Unwrapped token:</strong> ' + transforms.join(' \u2192 ');
500+
transformInfo.style.display = '';
501+
} else {
502+
transformInfo.style.display = 'none';
503+
transformInfo.innerHTML = '';
504+
}
505+
};
506+
507+
const showParseError = (message) => {
508+
if (message) {
509+
parseError.textContent = message;
510+
parseError.style.display = '';
511+
} else {
512+
parseError.style.display = 'none';
513+
parseError.textContent = '';
514+
}
515+
};
516+
517+
const clearDecodedPanels = () => {
518+
const headerEl = document.getElementById('decoded-header');
519+
const payloadEl = document.getElementById('decoded-payload');
520+
if (headerEl) headerEl.value = '';
521+
if (payloadEl) payloadEl.value = '';
522+
populateTable('header-claims-table', null);
523+
populateTable('payload-claims-table', null);
524+
signatureStatus.textContent = '';
525+
signatureStatus.style.cssText = 'margin-top: 12px; text-align: center; font-weight: 600; padding: 8px 16px; border-radius: 8px; font-size: 0.875rem;';
526+
};
527+
382528
const updateSignatureStatusUI = (isValid) => {
383529
signatureStatus.textContent = isValid ? 'Signature Verified' : 'Invalid Signature';
384530
signatureStatus.className = isValid ? 'sig-valid' : 'sig-invalid';
385531
signatureStatus.style.cssText = 'margin-top: 12px; text-align: center; font-weight: 600; padding: 8px 16px; border-radius: 8px; font-size: 0.875rem; background:' + (isValid ? 'var(--success-bg)' : 'var(--error-bg)') + '; color:' + (isValid ? 'var(--success-text)' : 'var(--error-text)') + ';';
386532
};
387533

388534
const updateDecodedFromToken = () => {
389-
const token = encodedOutput.innerText;
390-
const parts = token.split('.');
391-
if (parts.length !== 3) {
392-
updateSignatureStatusUI(false);
535+
const rawInput = encodedOutput.innerText;
536+
showParseError(null);
537+
538+
const result = extractJWT(rawInput);
539+
if (!result) {
540+
showTransformInfo(null);
541+
if (rawInput.trim()) {
542+
clearDecodedPanels();
543+
showParseError('Could not parse as JWT. Expected format: header.payload.signature (base64url-encoded, dot-separated).');
544+
}
393545
return;
394546
}
547+
548+
const { jwt, transforms } = result;
549+
showTransformInfo(transforms);
550+
551+
if (transforms.length > 0) {
552+
const parts = jwt.split('.');
553+
encodedOutput.innerHTML = `<span class="token-part-header">${parts[0]}</span><span class="token-dot">.</span><span class="token-part-payload">${parts[1]}</span><span class="token-dot">.</span><span class="token-part-signature">${parts[2]}</span>`;
554+
}
555+
556+
const parts = jwt.split('.');
395557
try {
396558
const headerJson = JSON.parse(base64UrlDecode(parts[0]));
397559
document.getElementById('decoded-header').value = JSON.stringify(headerJson, null, 2);
@@ -412,6 +574,8 @@ <h2 style="font-size: 1rem; font-weight: 600;">Decoded</h2>
412574
};
413575

414576
const updateEncoded = async () => {
577+
showTransformInfo(null);
578+
showParseError(null);
415579
const header = JSON.parse(document.getElementById('decoded-header').value);
416580
const payload = JSON.parse(document.getElementById('decoded-payload').value);
417581
if (!header || !payload) {

0 commit comments

Comments
 (0)