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% ); }
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 - Z a - z 0 - 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 - Z a - z 0 - 9 _ - ] + \. [ A - Z a - z 0 - 9 _ - ] + \. [ A - Z a - z 0 - 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