Skip to content

Commit 11244d7

Browse files
committed
feat(dev,html-app): add support for Elastic Cloud tokens to JWT HTML app
1 parent 9098c16 commit 11244d7

3 files changed

Lines changed: 172 additions & 14 deletions

File tree

dev/tools/jwt-debugger.html

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -564,10 +564,23 @@ <h2 id="creditsDialogTitle">Credits</h2>
564564
} catch (e) { return false; }
565565
};
566566

567-
const tryBase64Decode = (str) => {
567+
const tryBase64DecodeBytes = (str) => {
568568
let padded = str.replace(/-/g, '+').replace(/_/g, '/');
569569
while (padded.length % 4) padded += '=';
570-
try { return atob(padded); } catch (e) { return null; }
570+
try {
571+
const decoded = atob(padded);
572+
const bytes = new Uint8Array(decoded.length);
573+
for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i);
574+
return bytes;
575+
} catch (e) { return null; }
576+
};
577+
578+
const tryBase64Decode = (str) => {
579+
const bytes = tryBase64DecodeBytes(str);
580+
if (!bytes) return null;
581+
let decoded = '';
582+
for (let i = 0; i < bytes.length; i++) decoded += String.fromCharCode(bytes[i]);
583+
return decoded;
571584
};
572585

573586
const findJWTInString = (str) => {
@@ -579,28 +592,157 @@ <h2 id="creditsDialogTitle">Credits</h2>
579592
return null;
580593
};
581594

595+
const escapeHtml = (value) => value.replace(/[&<>"']/g, (ch) => ({
596+
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
597+
})[ch]);
598+
599+
const bytesToUtf8 = (bytes) => {
600+
try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch (e) { return null; }
601+
};
602+
603+
const readUint32BE = (bytes, offset) => (
604+
((bytes[offset] << 24) >>> 0) + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3]
605+
) >>> 0;
606+
607+
const CRC32_TABLE = (() => {
608+
const table = new Uint32Array(256);
609+
for (let n = 0; n < table.length; n++) {
610+
let c = n;
611+
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
612+
table[n] = c >>> 0;
613+
}
614+
return table;
615+
})();
616+
617+
const crc32 = (bytes) => {
618+
let crc = 0xffffffff;
619+
for (let i = 0; i < bytes.length; i++) crc = CRC32_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
620+
return (crc ^ 0xffffffff) >>> 0;
621+
};
622+
623+
const unwrapElasticChecksum = (bytes) => {
624+
if (bytes.length < 8) return null;
625+
const separatorOffset = bytes.length - 8;
626+
if (
627+
bytes[separatorOffset] !== 0 || bytes[separatorOffset + 1] !== 0 ||
628+
bytes[separatorOffset + 2] !== 0 || bytes[separatorOffset + 3] !== 0
629+
) {
630+
return null;
631+
}
632+
633+
const payload = bytes.subarray(0, separatorOffset);
634+
return crc32(payload) === readUint32BE(bytes, bytes.length - 4) ? payload : null;
635+
};
636+
637+
const readLz4Length = (bytes, state, initialLength) => {
638+
let length = initialLength;
639+
if (length !== 15) return length;
640+
641+
let next;
642+
do {
643+
if (state.offset >= bytes.length) throw new Error('Invalid LZ4 length');
644+
next = bytes[state.offset++];
645+
length += next;
646+
} while (next === 255);
647+
return length;
648+
};
649+
650+
const decompressLz4BlockWithLength = (bytes) => {
651+
if (bytes.length < 4) return null;
652+
653+
const outputLength = readUint32BE(bytes, 0);
654+
if (outputLength === 0 || outputLength > 1024 * 1024) return null;
655+
656+
const output = new Uint8Array(outputLength);
657+
const state = { offset: 4 };
658+
let outputOffset = 0;
659+
660+
try {
661+
while (state.offset < bytes.length) {
662+
const token = bytes[state.offset++];
663+
const literalLength = readLz4Length(bytes, state, token >> 4);
664+
if (state.offset + literalLength > bytes.length || outputOffset + literalLength > outputLength) return null;
665+
666+
output.set(bytes.subarray(state.offset, state.offset + literalLength), outputOffset);
667+
state.offset += literalLength;
668+
outputOffset += literalLength;
669+
670+
if (state.offset >= bytes.length) break;
671+
if (state.offset + 2 > bytes.length) return null;
672+
673+
const matchOffset = bytes[state.offset] | (bytes[state.offset + 1] << 8);
674+
state.offset += 2;
675+
if (matchOffset === 0 || matchOffset > outputOffset) return null;
676+
677+
const matchLength = readLz4Length(bytes, state, token & 0x0f) + 4;
678+
if (outputOffset + matchLength > outputLength) return null;
679+
680+
for (let i = 0; i < matchLength; i++) {
681+
output[outputOffset + i] = output[outputOffset - matchOffset + i];
682+
}
683+
outputOffset += matchLength;
684+
}
685+
} catch (e) {
686+
return null;
687+
}
688+
689+
return outputOffset === outputLength ? output : null;
690+
};
691+
692+
const decodeElasticWrappedJWT = (encoded) => {
693+
const bytes = tryBase64DecodeBytes(encoded);
694+
if (!bytes) return null;
695+
696+
const checksumPayload = unwrapElasticChecksum(bytes);
697+
if (!checksumPayload) return null;
698+
699+
const decompressed = decompressLz4BlockWithLength(checksumPayload);
700+
const decodedPayload = decompressed ? bytesToUtf8(decompressed) : bytesToUtf8(checksumPayload);
701+
if (!decodedPayload) return null;
702+
703+
const jwt = findJWTInString(decodedPayload);
704+
if (!jwt) return null;
705+
706+
const transforms = ['Base64-decoded', 'Verified Elastic checksum'];
707+
if (decompressed) transforms.push('LZ4-decompressed');
708+
transforms.push('Extracted JWT from Elastic token');
709+
return { jwt, transforms };
710+
};
711+
582712
// Unwraps Bearer prefixes, opaque session tokens, and base64-wrapped envelopes
583713
// around a real JWT, so users can paste whatever their HTTP tooling shows.
584714
const extractJWT = (input) => {
585715
input = input.trim();
586716
if (!input) return null;
587717

718+
const bearerMatch = input.match(/^Bearer\s+(.+)$/i);
719+
if (bearerMatch) {
720+
const result = extractJWT(bearerMatch[1]);
721+
if (result) return { jwt: result.jwt, transforms: ['Stripped <code>Bearer</code> prefix', ...result.transforms] };
722+
}
723+
588724
if (isValidJWT(input)) return { jwt: input, transforms: [] };
589725

590726
const segments = input.split('_');
591727
for (let i = 1; i < segments.length && i <= 5; i++) {
592728
const remainder = segments.slice(i).join('_');
593729
const prefix = segments.slice(0, i).join('_') + '_';
730+
const prefixLabel = `<code>${escapeHtml(prefix)}</code>`;
594731

595732
if (isValidJWT(remainder)) {
596-
return { jwt: remainder, transforms: [`Stripped prefix <code>${prefix}</code>`] };
733+
return { jwt: remainder, transforms: [`Stripped prefix ${prefixLabel}`] };
734+
}
735+
736+
const elasticWrapped = decodeElasticWrappedJWT(remainder);
737+
if (elasticWrapped) {
738+
return { jwt: elasticWrapped.jwt, transforms: [`Stripped prefix ${prefixLabel}`, ...elasticWrapped.transforms] };
597739
}
598740

599741
const decoded = tryBase64Decode(remainder);
600742
if (decoded) {
601743
const jwt = findJWTInString(decoded);
602744
if (jwt) {
603-
return { jwt, transforms: [`Stripped prefix <code>${prefix}</code>`, 'Base64-decoded', 'Extracted JWT from decoded payload'] };
745+
return { jwt, transforms: [`Stripped prefix ${prefixLabel}`, 'Base64-decoded', 'Extracted JWT from decoded payload'] };
604746
}
605747
}
606748
}

dev/tools/jwt-debugger.skill.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ library and report results in chat.
1919

2020
## Inputs
2121

22-
| Field | Type | Default | Notes |
23-
|----------|--------|----------|---------------------------------------------------------------------------------------|
24-
| `jwt` | string | required | The compact-form token: `header.payload.signature`. Strip any `Bearer ` prefix first. |
25-
| `secret` | string | `""` | Optional HMAC secret used for verification or re-signing. Empty string when unknown. |
22+
| Field | Type | Default | Notes |
23+
|----------|--------|----------|----------------------------------------------------------------------------------------------------------------------------------------|
24+
| `jwt` | string | required | The compact-form token: `header.payload.signature`. The UI also accepts `Bearer ...`, base64-wrapped JWTs, and Elastic `essu_` tokens. |
25+
| `secret` | string | `""` | Optional HMAC secret used for verification or re-signing. Empty string when unknown. |
2626

2727
State object shape (keys are exactly `j`, `s`):
2828

@@ -82,8 +82,9 @@ sentence; don't paraphrase the payload contents.
8282
Secutils server but is fully visible to anyone with the link. Never paste
8383
high-value production secrets into a share URL - generate or rotate the
8484
secret first.
85-
- Strip `Bearer ` (and any surrounding whitespace) from the JWT before
86-
encoding; the tool expects the raw three-segment token.
85+
- Prefer encoding the raw three-segment JWT. The tool can unwrap `Bearer `,
86+
base64-wrapped JWTs, and Elastic `essu_` tokens pasted directly into the UI, but
87+
share URLs are smallest when they store the compact JWT itself.
8788
- Asymmetric algorithms (RS*, ES*, PS*) are not supported by this tool. If
8889
the header `alg` is one of those, decode in chat and tell the user to use a
8990
server-side library for verification.

e2e/tools/jwt.spec.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const tool = getTool('jwt');
1010
const SAMPLE_JWT =
1111
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
1212
const SAMPLE_SECRET = 'your-256-bit-secret';
13+
const ELASTIC_REFRESH_TOKEN =
14+
'essu_AAABc/AIZXlKMGVYQWlPaUpLVjFRaUxDSmhiR2MQAPAySVV6STFOaUo5LmV5SnpkV0lpT2lJek9URXlORFExTWpJeUlpd2libUptSWpveE56YzVPREE1TkRZeUxDSnBjM01FAPBHbGJHRnpkR2xqTFdOc2IzVmtJaXdpZEhsd0lqb2ljbVZtY21WemFDMTBiMnRsYmlJc0luTmxjM05wYjI1ZlkzSmxZWFJsWkNJNk1UYzNPVGd3T1RVeU14ADAybGtEAPIGTVRNeE56STBNREUzT1NJc0ltVjRjMACBNE1EQTJPRGMwAEBjMnAwMABQZFhObGNoAEJtbGhkKAAJWABAYW5ScCgA8EtNR1F6TnpFeU5qUXdZVGRrTkRnNVlXRmhPR1pqTURreU9ETXhPVEV3TUdJaWZRLlM1SWdvZXlQSFNYOGIxU0hoZmdGT2EtRUV4ZDk0dl9YdmtIT09lZ1RkX1UAAAAAtnky3g==';
1315

1416
test.describe(`${tool.name} (${tool.path})`, () => {
1517
test('SEO head block matches the AGENTS.md SEO budget', async ({ page }) => {
@@ -27,8 +29,19 @@ test.describe(`${tool.name} (${tool.path})`, () => {
2729
const encoded = page.locator('#encoded-output');
2830
await expect(encoded).toBeVisible();
2931
await encoded.fill(SAMPLE_JWT);
30-
await expect(page.locator('#header-output')).toContainText('HS256');
31-
await expect(page.locator('#payload-output')).toContainText('John Doe');
32+
await expect(page.locator('#decoded-header')).toHaveValue(/"alg": "HS256"/);
33+
await expect(page.locator('#decoded-payload')).toHaveValue(/"name": "John Doe"/);
34+
});
35+
36+
test('unwraps an Elastic essu-prefixed LZ4 token', async ({ page }) => {
37+
await page.goto(tool.path);
38+
const encoded = page.locator('#encoded-output');
39+
await expect(encoded).toBeVisible();
40+
await encoded.fill(ELASTIC_REFRESH_TOKEN);
41+
await expect(page.locator('#transform-info')).toContainText('LZ4-decompressed');
42+
await expect(encoded).toContainText(/^eyJ/);
43+
await expect(page.locator('#decoded-header')).toHaveValue(/"alg": "HS256"/);
44+
await expect(page.locator('#decoded-payload')).toHaveValue(/"typ": "refresh-token"/);
3245
});
3346

3447
test('verifies the signature once the secret is provided', async ({ page }) => {
@@ -40,8 +53,10 @@ test.describe(`${tool.name} (${tool.path})`, () => {
4053

4154
test('Share button produces a shareable URL with state in the fragment', async ({ page }) => {
4255
await page.goto(tool.path);
43-
await page.locator('#encoded-output').fill(SAMPLE_JWT);
56+
const encoded = page.locator('#encoded-output');
57+
await encoded.fill(SAMPLE_JWT);
4458
await page.locator('#secret-input').fill(SAMPLE_SECRET);
59+
const sharedJwt = await encoded.innerText();
4560

4661
// Capture the URL the page wants to share by stubbing the clipboard.
4762
let copied = '';
@@ -61,7 +76,7 @@ test.describe(`${tool.name} (${tool.path})`, () => {
6176

6277
const fragment = new URL(copied).hash;
6378
await page.goto(`${tool.path}${fragment}`);
64-
await expect(page.locator('#encoded-output')).toHaveValue(SAMPLE_JWT);
79+
await expect(page.locator('#encoded-output')).toContainText(sharedJwt);
6580
await expect(page.locator('#secret-input')).toHaveValue(SAMPLE_SECRET);
6681
});
6782
});

0 commit comments

Comments
 (0)