Skip to content

Commit 9108ef3

Browse files
committed
[1.0.8] JWT Forger
1 parent 460c3e4 commit 9108ef3

18 files changed

Lines changed: 3503 additions & 923 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.8] - 2026-03-24
9+
10+
* [Feature] JWT Forger
11+
812
## [1.0.7] - 2026-03-09
913

1014
* [Technical] Replace the use of Parquet files with smaller JSON files. Otherwise, a resource limitation error occurs on Cloudflare Workers (free tier) when decoding Parquet files (even with read stream)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "toolbox",
33
"private": true,
4-
"version": "1.0.7",
4+
"version": "1.0.8",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

pnpm-lock.yaml

Lines changed: 1214 additions & 921 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/modules/jwt/JwtDecoder.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
<script setup lang="ts">
22
import { TbButton, TbFieldInput } from '@components'
33
import { computed, ref } from 'vue'
4+
import { useRoute } from 'vue-router'
45
import JwtClaimSection from './JwtClaimSection.vue'
56
import JwtExpirationBanner from './JwtExpirationBanner.vue'
67
import JwtSignatureSection from './JwtSignatureSection.vue'
8+
import JwtSignatureVerifier from './JwtSignatureVerifier.vue'
79
import JwtTokenDisplay from './JwtTokenDisplay.vue'
810
import { CLAIM_LABELS, decodeJwt, formatClaimValue, formatHeaderValue, HEADER_LABELS, SAMPLE_JWT } from './logic'
911
10-
const token = ref('')
12+
const route = useRoute()
13+
const initialToken = typeof route.query.token === 'string' ? route.query.token : ''
14+
const token = ref(initialToken)
1115
1216
const decoded = computed(() => decodeJwt(token.value))
1317
@@ -61,6 +65,11 @@ function loadSample() {
6165
/>
6266

6367
<JwtSignatureSection v-if="decoded.signature" :signature="decoded.signature" />
68+
69+
<JwtSignatureVerifier
70+
:token="token.trim()"
71+
:algorithm="String(decoded.header.alg || 'none')"
72+
/>
6473
</template>
6574
</template>
6675
</div>

src/modules/jwt/JwtForger.vue

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<script setup lang="ts">
2+
import { computed, ref, watch } from 'vue'
3+
import {
4+
algorithmFamily,
5+
applyPreset,
6+
buildHeaderJson,
7+
buildPayloadJson,
8+
generateDefaultClaims,
9+
OPTIONAL_HEADER_CLAIMS,
10+
signToken,
11+
} from './forger-logic'
12+
import type { ClaimEntry, SigningAlgorithm, TokenPreset } from './forger-types'
13+
import JwtForgerAlgorithmSection from './JwtForgerAlgorithmSection.vue'
14+
import JwtForgerHeaderEditor from './JwtForgerHeaderEditor.vue'
15+
import JwtForgerKeyInput from './JwtForgerKeyInput.vue'
16+
import JwtForgerOutput from './JwtForgerOutput.vue'
17+
import JwtForgerPayloadEditor from './JwtForgerPayloadEditor.vue'
18+
import JwtForgerPresetBar from './JwtForgerPresetBar.vue'
19+
20+
const algorithm = ref<SigningAlgorithm>('HS256')
21+
const standardClaims = ref<ClaimEntry[]>(generateDefaultClaims())
22+
const customClaims = ref<ClaimEntry[]>([])
23+
const headerExtraClaims = ref<ClaimEntry[]>(
24+
OPTIONAL_HEADER_CLAIMS.map(claim => ({ key: claim.key, value: '', enabled: false })),
25+
)
26+
const secret = ref('your-256-bit-secret')
27+
const privateKey = ref('')
28+
29+
const kidValue = computed(() => {
30+
const kidClaim = headerExtraClaims.value.find(claim => claim.key === 'kid')
31+
return kidClaim?.enabled && kidClaim.value.trim() ? kidClaim.value.trim() : null
32+
})
33+
34+
function loadPreset(preset: TokenPreset) {
35+
const result = applyPreset(preset)
36+
algorithm.value = result.algorithm
37+
standardClaims.value = result.standardClaims
38+
customClaims.value = result.customClaims
39+
}
40+
41+
const generatedToken = ref('')
42+
const signingError = ref<string | null>(null)
43+
44+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
45+
46+
watch(
47+
[algorithm, standardClaims, customClaims, headerExtraClaims, secret, privateKey],
48+
() => {
49+
if (debounceTimer) {
50+
clearTimeout(debounceTimer)
51+
}
52+
debounceTimer = setTimeout(() => {
53+
regenerateToken()
54+
}, 150)
55+
},
56+
{ deep: true },
57+
)
58+
59+
async function regenerateToken() {
60+
try {
61+
const headerJson = buildHeaderJson(algorithm.value, headerExtraClaims.value)
62+
const payloadJson = buildPayloadJson(standardClaims.value, customClaims.value)
63+
const family = algorithmFamily(algorithm.value)
64+
65+
const key = family === 'hmac' ? secret.value : privateKey.value
66+
67+
if (family !== 'none' && family !== 'hmac' && !key.trim()) {
68+
generatedToken.value = ''
69+
signingError.value = null
70+
return
71+
}
72+
73+
const token = await signToken(headerJson, payloadJson, algorithm.value, key)
74+
generatedToken.value = token
75+
signingError.value = null
76+
} catch (error: unknown) {
77+
const message = error instanceof Error ? error.message : 'Unknown signing error'
78+
signingError.value = message
79+
generatedToken.value = ''
80+
}
81+
}
82+
83+
regenerateToken()
84+
</script>
85+
86+
<template>
87+
<div class="tb-stack-6">
88+
<JwtForgerPresetBar @select="loadPreset" />
89+
90+
<JwtForgerAlgorithmSection v-model="algorithm" />
91+
92+
<JwtForgerHeaderEditor
93+
:algorithm="algorithm"
94+
:extra-claims="headerExtraClaims"
95+
@update:extra-claims="headerExtraClaims = $event"
96+
/>
97+
98+
<JwtForgerPayloadEditor
99+
:standard-claims="standardClaims"
100+
:custom-claims="customClaims"
101+
@update:standard-claims="standardClaims = $event"
102+
@update:custom-claims="customClaims = $event"
103+
/>
104+
105+
<JwtForgerKeyInput
106+
:algorithm="algorithm"
107+
:secret="secret"
108+
:private-key="privateKey"
109+
:kid="kidValue"
110+
@update:secret="secret = $event"
111+
@update:private-key="privateKey = $event"
112+
/>
113+
114+
<JwtForgerOutput :token="generatedToken" :error="signingError" />
115+
</div>
116+
</template>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<script setup lang="ts">
2+
import { TbCard, TbOptionGroup } from '@components'
3+
import { computed } from 'vue'
4+
import { ALGORITHM_CATALOG, ALGORITHM_FAMILIES, algorithmFamily, familyVariants } from './forger-logic'
5+
import type { AlgorithmFamily, SigningAlgorithm } from './forger-types'
6+
7+
const algorithm = defineModel<SigningAlgorithm>({ required: true })
8+
9+
const selectedFamily = computed(() => algorithmFamily(algorithm.value))
10+
11+
const familyOptions = ALGORITHM_FAMILIES.map(family => ({
12+
value: family.value,
13+
label: family.label,
14+
}))
15+
16+
const variantOptions = computed(() =>
17+
familyVariants(selectedFamily.value).map(info => ({
18+
value: info.value,
19+
label: info.label,
20+
})),
21+
)
22+
23+
const selectedInfo = computed(() => ALGORITHM_CATALOG.find(entry => entry.value === algorithm.value))
24+
25+
function onFamilyChange(value: string) {
26+
const family = value as AlgorithmFamily
27+
const variants = familyVariants(family)
28+
if (variants.length > 0) {
29+
algorithm.value = variants[0].value
30+
}
31+
}
32+
</script>
33+
34+
<template>
35+
<TbCard sectioned title="Algorithm">
36+
<div class="tb-stack-4">
37+
<TbOptionGroup
38+
:model-value="selectedFamily"
39+
:options="familyOptions"
40+
variant="pill"
41+
label="Family"
42+
@update:model-value="onFamilyChange"
43+
/>
44+
45+
<TbOptionGroup
46+
v-if="variantOptions.length > 1"
47+
v-model="algorithm"
48+
:options="variantOptions"
49+
variant="segmented"
50+
label="Variant"
51+
/>
52+
53+
<p v-if="selectedInfo" class="tb-text-description tb-leading-relaxed">
54+
{{ selectedInfo.description }}
55+
</p>
56+
</div>
57+
</TbCard>
58+
</template>
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<script setup lang="ts">
2+
import { TbCard, TbCodeEditor, TbExpandable, TbInput } from '@components'
3+
import { computed } from 'vue'
4+
import { buildHeaderJson, OPTIONAL_HEADER_CLAIMS } from './forger-logic'
5+
import type { ClaimEntry, SigningAlgorithm } from './forger-types'
6+
7+
const props = defineProps<{
8+
algorithm: SigningAlgorithm
9+
extraClaims: ClaimEntry[]
10+
}>()
11+
12+
const emit = defineEmits<{
13+
'update:extraClaims': [value: ClaimEntry[]]
14+
}>()
15+
16+
const headerJson = computed(() => buildHeaderJson(props.algorithm, props.extraClaims))
17+
18+
function toggleClaim(key: string, checked: boolean) {
19+
const updated = props.extraClaims.map(claim => {
20+
if (claim.key === key) {
21+
return { ...claim, enabled: checked }
22+
} else {
23+
return claim
24+
}
25+
})
26+
emit('update:extraClaims', updated)
27+
}
28+
29+
function updateClaimValue(key: string, value: string) {
30+
const updated = props.extraClaims.map(claim => {
31+
if (claim.key === key) {
32+
return { ...claim, value }
33+
} else {
34+
return claim
35+
}
36+
})
37+
emit('update:extraClaims', updated)
38+
}
39+
40+
function getDefinition(key: string) {
41+
return OPTIONAL_HEADER_CLAIMS.find(claim => claim.key === key)
42+
}
43+
</script>
44+
45+
<template>
46+
<TbCard sectioned title="Header" title-class="tb-text-jwt-header">
47+
<div class="tb-stack-4">
48+
<TbCodeEditor
49+
:model-value="headerJson"
50+
readonly
51+
copyable
52+
language="json"
53+
/>
54+
55+
<TbExpandable chevron="right">
56+
<template #header>
57+
<span class="tb-text-sm tb-text-secondary">Optional header claims</span>
58+
</template>
59+
<div class="tb-stack-3">
60+
<div v-for="claim in extraClaims" :key="claim.key" class="tb-stack-1">
61+
<div class="tb-row tb-row--gap-3">
62+
<label class="tb-checkbox">
63+
<input
64+
type="checkbox"
65+
class="tb-checkbox__input"
66+
:checked="claim.enabled"
67+
@change="toggleClaim(claim.key, ($event.target as HTMLInputElement).checked)"
68+
>
69+
<span class="tb-checkbox__label tb-font-semibold tb-font-mono">{{ claim.key }}</span>
70+
</label>
71+
<span class="tb-text-secondary tb-text-sm">{{ getDefinition(claim.key)?.label }}</span>
72+
</div>
73+
<template v-if="claim.enabled">
74+
<TbInput
75+
:model-value="claim.value"
76+
:placeholder="getDefinition(claim.key)?.placeholder"
77+
@update:model-value="updateClaimValue(claim.key, String($event))"
78+
/>
79+
<p class="tb-hint">{{ getDefinition(claim.key)?.description }}</p>
80+
</template>
81+
</div>
82+
</div>
83+
</TbExpandable>
84+
</div>
85+
</TbCard>
86+
</template>

0 commit comments

Comments
 (0)