feat: add JWT authentication support (#5079)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Anwarul Islam 2025-05-28 16:11:47 +06:00 committed by GitHub
parent e1f78b185a
commit 82d9367843
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1301 additions and 818 deletions

View file

@ -52,7 +52,7 @@
"qs": "6.13.0",
"verzod": "0.2.4",
"xmlbuilder2": "3.1.1",
"zod": "3.23.8"
"zod": "3.25.32"
},
"devDependencies": {
"@hoppscotch/data": "workspace:^",
@ -66,7 +66,7 @@
"qs": "6.11.2",
"semver": "7.6.3",
"tsup": "8.3.0",
"typescript": "5.6.3",
"typescript": "5.8.3",
"vitest": "2.1.2"
}
}

View file

@ -0,0 +1,41 @@
{
"v": 3,
"name": "JWT Auth (headers) - collection",
"folders": [],
"requests": [
{
"v": "13",
"id": "cm0dm70cw000687bnxi830zz3",
"auth": {
"authType": "jwt",
"authActive": true,
"addTo": "HEADERS",
"algorithm": "<<algorithm>>",
"secret": "<<secret>>",
"privateKey": "<<privateKey>>",
"payload": "<<payload>>",
"jwtHeaders": "<<jwtHeaders>>",
"headerPrefix": "<<headerPrefix>>",
"isSecretBase64Encoded": false
},
"body": {
"body": null,
"contentType": null
},
"name": "jwt-auth-headers",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "<<url>>",
"testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);});",
"preRequestScript": "",
"responses": {},
"requestVariables": []
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}

View file

@ -0,0 +1,41 @@
{
"v": 3,
"name": "JWT Auth (params) - collection",
"folders": [],
"requests": [
{
"v": "13",
"id": "cm0dm70cw000687bnxi830zz4",
"auth": {
"authType": "jwt",
"authActive": true,
"addTo": "QUERY_PARAMS",
"algorithm": "<<algorithm>>",
"secret": "<<secret>>",
"privateKey": "<<privateKey>>",
"payload": "<<payload>>",
"jwtHeaders": "<<jwtHeaders>>",
"paramName": "<<paramName>>",
"isSecretBase64Encoded": false
},
"body": {
"body": null,
"contentType": null
},
"name": "jwt-auth-params",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "<<url>>",
"testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);});",
"preRequestScript": "",
"responses": {},
"requestVariables": []
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}

View file

@ -0,0 +1,43 @@
{
"v": 1,
"id": "cm0dsn3v70004p4qk3l9b7sjn",
"name": "JWT Auth - environments",
"variables": [
{
"key": "url",
"value": "http://localhost:8888/auth/jwt"
},
{
"key": "algorithm",
"value": "HS256"
},
{
"key": "secret",
"value": "secret-1"
},
{
"key": "privateKey",
"value": ""
},
{
"key": "payload",
"value": "{\"user\":\"test\",\"role\":\"admin\"}"
},
{
"key": "jwtHeaders",
"value": "{}"
},
{
"key": "headerPrefix",
"value": "Bearer "
},
{
"key": "paramName",
"value": "token"
},
{
"key": "isSecretBase64Encoded",
"value": "false"
}
]
}

View file

@ -485,17 +485,17 @@ export const WORKSPACE_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Worksp
export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: HoppCollection[] =
[
{
v: 7,
v: 8,
id: "clx1f86hv000010f8szcfya0t",
name: "Multiple child collections with authorization & headers set at each level",
folders: [
{
v: 7,
v: 8,
id: "clx1fjgah000110f8a5bs68gd",
name: "folder-1",
folders: [
{
v: 7,
v: 8,
id: "clx1fjwmm000410f8l1gkkr1a",
name: "folder-11",
folders: [],
@ -537,7 +537,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
],
},
{
v: 7,
v: 8,
id: "clx1fjyxm000510f8pv90dt43",
name: "folder-12",
folders: [],
@ -595,7 +595,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
],
},
{
v: 7,
v: 8,
id: "clx1fk1cv000610f88kc3aupy",
name: "folder-13",
folders: [],
@ -707,12 +707,12 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
],
},
{
v: 7,
v: 8,
id: "clx1fjk9o000210f8j0573pls",
name: "folder-2",
folders: [
{
v: 7,
v: 8,
id: "clx1fk516000710f87sfpw6bo",
name: "folder-21",
folders: [],
@ -752,7 +752,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
],
},
{
v: 7,
v: 8,
id: "clx1fk72t000810f8gfwkpi5y",
name: "folder-22",
folders: [],
@ -810,7 +810,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
],
},
{
v: 7,
v: 8,
id: "clx1fk95g000910f8bunhaoo8",
name: "folder-23",
folders: [],
@ -915,12 +915,12 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
],
},
{
v: 7,
v: 8,
id: "clx1fjmlq000310f86o4d3w2o",
name: "folder-3",
folders: [
{
v: 7,
v: 8,
id: "clx1iwq0p003e10f8u8zg0p85",
name: "folder-31",
folders: [],
@ -960,7 +960,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
],
},
{
v: 7,
v: 8,
id: "clx1izut7003m10f894ip59zg",
name: "folder-32",
folders: [],
@ -1018,7 +1018,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
],
},
{
v: 7,
v: 8,
id: "clx1j2ka9003q10f8cdbzpgpg",
name: "folder-33",
folders: [],
@ -1372,42 +1372,50 @@ export const WORKSPACE_ENVIRONMENT_MOCK: WorkspaceEnvironment = {
variables: [
{
key: "firstName",
value: "John",
initialValue: "John",
currentValue: "John",
secret: false,
},
{
key: "lastName",
value: "Doe",
initialValue: "Doe",
currentValue: "Doe",
secret: false,
},
{
key: "id",
value: "7",
initialValue: "7",
currentValue: "7",
secret: false,
},
{
key: "fullName",
value: "<<firstName>> <<lastName>>",
initialValue: "<<firstName>> <<lastName>>",
currentValue: "<<firstName>> <<lastName>>",
secret: false,
},
{
key: "recursiveVarX",
value: "<<recursiveVarY>>",
initialValue: "<<recursiveVarY>>",
currentValue: "<<recursiveVarY>>",
secret: false,
},
{
key: "recursiveVarY",
value: "<<salutation>>",
initialValue: "<<salutation>>",
currentValue: "<<salutation>>",
secret: false,
},
{
key: "salutation",
value: "Hello",
initialValue: "Hello",
currentValue: "Hello",
secret: false,
},
{
key: "greetText",
value: "<<salutation>> <<fullName>>",
initialValue: "<<salutation>> <<fullName>>",
currentValue: "<<salutation>> <<fullName>>",
secret: false,
},
],
@ -1420,42 +1428,50 @@ export const TRANSFORMED_ENVIRONMENT_MOCK: Environment = {
variables: [
{
key: "firstName",
value: "John",
initialValue: "John",
currentValue: "John",
secret: false,
},
{
key: "lastName",
value: "Doe",
initialValue: "Doe",
currentValue: "Doe",
secret: false,
},
{
key: "id",
value: "7",
initialValue: "7",
currentValue: "7",
secret: false,
},
{
key: "fullName",
value: "<<firstName>> <<lastName>>",
initialValue: "<<firstName>> <<lastName>>",
currentValue: "<<firstName>> <<lastName>>",
secret: false,
},
{
key: "recursiveVarX",
value: "<<recursiveVarY>>",
initialValue: "<<recursiveVarY>>",
currentValue: "<<recursiveVarY>>",
secret: false,
},
{
key: "recursiveVarY",
value: "<<salutation>>",
initialValue: "<<salutation>>",
currentValue: "<<salutation>>",
secret: false,
},
{
key: "salutation",
value: "Hello",
initialValue: "Hello",
currentValue: "Hello",
secret: false,
},
{
key: "greetText",
value: "<<salutation>> <<fullName>>",
initialValue: "<<salutation>> <<fullName>>",
currentValue: "<<salutation>> <<fullName>>",
secret: false,
},
],

View file

@ -6,6 +6,7 @@ import {
parseRawKeyValueEntriesE,
parseTemplateString,
parseTemplateStringE,
generateJWTToken,
} from "@hoppscotch/data";
import { runPreRequestScript } from "@hoppscotch/js-sandbox/node";
import * as A from "fp-ts/Array";
@ -334,6 +335,50 @@ export async function getEffectiveRESTRequest(
value: hawkHeader,
description: "",
});
} else if (request.auth.authType === "jwt") {
const { addTo } = request.auth;
// Generate JWT token
const token = await generateJWTToken({
algorithm: request.auth.algorithm || "HS256",
secret: parseTemplateString(request.auth.secret, resolvedVariables),
privateKey: parseTemplateString(
request.auth.privateKey,
resolvedVariables
),
payload: parseTemplateString(request.auth.payload, resolvedVariables),
jwtHeaders: parseTemplateString(
request.auth.jwtHeaders,
resolvedVariables
),
isSecretBase64Encoded: request.auth.isSecretBase64Encoded,
});
if (token) {
if (addTo === "HEADERS") {
const headerPrefix =
parseTemplateString(request.auth.headerPrefix, resolvedVariables) ||
"Bearer ";
effectiveFinalHeaders.push({
active: true,
key: "Authorization",
value: `${headerPrefix}${token}`,
description: "",
});
} else if (addTo === "QUERY_PARAMS") {
const paramName =
parseTemplateString(request.auth.paramName, resolvedVariables) ||
"token";
effectiveFinalParams.push({
active: true,
key: paramName,
value: token,
description: "",
});
}
}
}
}

View file

@ -252,6 +252,9 @@
"username": "Username",
"advance_config": "Advanced Configuration",
"advance_config_description": "Hoppscotch automatically assigns default values to certain fields if no explicit value is provided",
"algorithm": "Algorithm",
"payload": "Payload",
"secret": "Secret",
"aws_signature": {
"access_key": "Access Key",
"secret_key": "Secret Key",
@ -268,6 +271,16 @@
"client_nonce": "Client Nonce",
"opaque": "Opaque",
"disable_retry": "Disable Retrying Request"
},
"jwt": {
"params_name": "Params Name",
"header_prefix": "Header Prefix",
"placeholder_request_header": "Request header prefix",
"placeholder_request_param": "Request params name",
"secret_base64_encoded": "Secret Base64 Encoded",
"headers": "JWT Headers",
"private_key": "Private Key",
"placeholder_headers": "JWT Headers"
}
},
"collection": {

View file

@ -114,7 +114,7 @@
"workbox-window": "7.1.0",
"xml-formatter": "3.6.3",
"yargs-parser": "21.1.1",
"zod": "3.23.8"
"zod": "3.25.32"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
@ -165,7 +165,7 @@
"rollup-plugin-polyfill-node": "0.13.0",
"sass": "1.79.5",
"tailwindcss": "3.4.14",
"typescript": "5.3.3",
"typescript": "5.8.3",
"unplugin-fonts": "1.1.1",
"unplugin-icons": "0.19.3",
"unplugin-vue-components": "0.27.4",

View file

@ -124,6 +124,7 @@ declare module 'vue' {
HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default']
HistoryPersonal: typeof import('./components/history/Personal.vue')['default']
HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default']
HoppAccordion: typeof import('@hoppscotch/ui')['HoppAccordion']
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
@ -145,6 +146,7 @@ declare module 'vue' {
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartTextarea: typeof import('@hoppscotch/ui')['HoppSmartTextarea']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
HoppSmartTree: typeof import('@hoppscotch/ui')['HoppSmartTree']
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
@ -157,6 +159,7 @@ declare module 'vue' {
HttpAuthorizationBasic: typeof import('./components/http/authorization/Basic.vue')['default']
HttpAuthorizationDigest: typeof import('./components/http/authorization/Digest.vue')['default']
HttpAuthorizationHAWK: typeof import('./components/http/authorization/HAWK.vue')['default']
HttpAuthorizationJWT: typeof import('./components/http/authorization/JWT.vue')['default']
HttpAuthorizationNTLM: typeof import('./components/http/authorization/NTLM.vue')['default']
HttpAuthorizationOAuth2: typeof import('./components/http/authorization/OAuth2.vue')['default']
HttpBody: typeof import('./components/http/Body.vue')['default']

View file

@ -61,8 +61,9 @@
:on="authActive"
class="px-2"
@change="authActive = !authActive"
>{{ t("state.enabled") }}</HoppSmartCheckbox
>
{{ t("state.enabled") }}
</HoppSmartCheckbox>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/authorization"
@ -157,6 +158,9 @@
<div v-if="auth.authType === 'digest'">
<HttpAuthorizationDigest v-model="auth" :envs="envs" />
</div>
<div v-if="auth.authType === 'jwt'">
<HttpAuthorizationJWT v-model="auth" :envs="envs" />
</div>
</div>
<div
class="z-[9] sticky top-upperTertiaryStickyFold h-full min-w-[12rem] max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4"
@ -198,6 +202,7 @@ import {
HoppRESTAuthDigest,
HoppRESTAuthHAWK,
HoppRESTAuthOAuth2,
HoppRESTAuthJWT,
} from "@hoppscotch/data"
const t = useI18n()
@ -294,6 +299,21 @@ const selectDigestAuthType = () => {
} as HoppRESTAuth
}
const selectJWTAuthType = () => {
auth.value = {
...auth.value,
authType: "jwt",
secret: "",
algorithm: "HS256",
payload: "{}",
addTo: "HEADERS",
isSecretBase64Encoded: false,
headerPrefix: "Bearer ",
paramName: "token",
jwtHeaders: "{}",
} as HoppRESTAuthJWT
}
const authTypes: AuthType[] = [
{
key: "inherit",
@ -336,6 +356,11 @@ const authTypes: AuthType[] = [
label: "HAWK",
handler: selectHAWKAuthType,
},
{
key: "jwt",
label: "JWT",
handler: selectJWTAuthType,
},
]
const authType = pluckRef(auth, "authType")

View file

@ -0,0 +1,310 @@
<template>
<div class="flex items-center border-b border-dividerLight">
<span class="flex items-center">
<label class="ml-4 text-secondaryLight">
{{ t("authorization.digest.algorithm") }}
</label>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => authTippyActions.focus()"
>
<HoppSmartSelectWrapper>
<HoppButtonSecondary
:label="auth.algorithm"
class="ml-2 rounded-none pr-8"
/>
</HoppSmartSelectWrapper>
<template #content="{ hide }">
<div
ref="authTippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
v-for="alg in algorithms"
:key="alg"
:icon="auth.algorithm === alg ? IconCircleDot : IconCircle"
:active="auth.algorithm === alg"
:label="alg"
@click="
() => {
auth.algorithm = alg
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
<!-- Private Key field for RSA/ECDSA algorithms -->
<div
v-if="isAsymmetricAlgorithm"
class="ml-4 py-2 border-b border-dividerLight"
>
<label class="text-secondaryLight">
{{ t("authorization.jwt.private_key") }}
</label>
<div ref="privateKeyEditor" class="mt-2 h-32"></div>
</div>
<!-- Secret field for HMAC algorithms -->
<div v-else>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="auth.secret"
:auto-complete-env="true"
:placeholder="t('authorization.secret')"
:envs="envs"
/>
</div>
<div class="px-4 py-2 flex items-center">
<HoppSmartCheckbox
:on="auth.isSecretBase64Encoded"
@change="auth.isSecretBase64Encoded = !auth.isSecretBase64Encoded"
>
{{ t("authorization.jwt.secret_base64_encoded") }}
</HoppSmartCheckbox>
</div>
</div>
<div class="ml-4 py-2 border-b border-dividerLight">
<label class="text-secondaryLight">
{{ t("authorization.payload") }}
</label>
<div ref="payloadEditor" class="mt-2 h-32"></div>
</div>
<div class="flex flex-col">
<!-- label as advanced config here -->
<div class="p-4 flex flex-col space-y-1">
<label>
{{ t("authorization.advance_config") }}
</label>
<p class="text-secondaryLight">
{{ t("authorization.advance_config_description") }}
</p>
</div>
<div class="flex items-center border-b border-dividerLight">
<span class="flex items-center">
<label class="ml-4 text-secondaryLight">
{{ t("authorization.pass_key_by") }}
</label>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => authTippyActions?.focus()"
>
<HoppSmartSelectWrapper>
<HoppButtonSecondary
:label="passBy"
class="ml-2 rounded-none pr-8"
/>
</HoppSmartSelectWrapper>
<template #content="{ hide }">
<div
ref="authTippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
v-for="addToTarget in addToTargets"
:key="addToTarget.id"
:label="addToTarget.label"
:icon="
auth.addTo === addToTarget.id ? IconCircleDot : IconCircle
"
:active="auth.addTo === addToTarget.id"
@click="
() => {
auth.addTo = addToTarget.id
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
<!-- passby conditional prefix or name -->
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-if="auth.addTo === 'HEADERS'"
v-model="auth.headerPrefix"
:auto-complete-env="true"
:placeholder="t('authorization.jwt.placeholder_request_header')"
:envs="envs"
/>
<SmartEnvInput
v-else
v-model="auth.paramName"
:auto-complete-env="true"
:placeholder="t('authorization.jwt.placeholder_request_param')"
:envs="envs"
/>
</div>
</div>
<div class="ml-4 py-2 border-b border-dividerLight">
<label class="text-secondaryLight">
{{ t("authorization.jwt.headers") }}
</label>
<div ref="headersEditor" class="mt-2 h-32"></div>
</div>
</template>
<script setup lang="ts">
import { useCodemirror } from "@composables/codemirror"
import { useI18n } from "@composables/i18n"
import { HoppRESTAuthJWT } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core"
import { computed, reactive, ref } from "vue"
import { AggregateEnvironment } from "~/newstore/environments"
import IconCircle from "~icons/lucide/circle"
import IconCircleDot from "~icons/lucide/circle-dot"
const t = useI18n()
const authTippyActions = ref<any | null>(null)
const props = withDefaults(
defineProps<{
modelValue: HoppRESTAuthJWT
envs?: AggregateEnvironment[]
}>(),
{
envs: undefined,
}
)
const emit = defineEmits<{
(e: "update:modelValue", value: HoppRESTAuthJWT): void
}>()
const auth = useVModel(props, "modelValue", emit)
// Template refs for CodeMirror editors
const payloadEditor = ref<any | null>(null)
const headersEditor = ref<any | null>(null)
const privateKeyEditor = ref<any | null>(null)
const payload = computed({
get: () => auth.value.payload,
set: (value) => {
auth.value = {
...auth.value,
payload: value,
}
},
})
const jwtHeaders = computed({
get: () => auth.value.jwtHeaders,
set: (value) => {
auth.value = {
...auth.value,
jwtHeaders: value,
}
},
})
const privateKey = computed({
get: () => auth.value.privateKey,
set: (value) => {
auth.value = {
...auth.value,
privateKey: value,
}
},
})
// Initialize CodeMirror for payload editor
useCodemirror(
payloadEditor,
payload,
reactive({
extendedEditorConfig: {
mode: "application/json",
readOnly: false,
lineWrapping: true,
},
linter: null,
completer: null,
environmentHighlights: true,
})
)
// Initialize CodeMirror for headers editor
useCodemirror(
headersEditor,
jwtHeaders,
reactive({
extendedEditorConfig: {
mode: "application/json",
readOnly: false,
lineWrapping: true,
},
linter: null,
completer: null,
environmentHighlights: true,
})
)
useCodemirror(
privateKeyEditor,
privateKey,
reactive({
extendedEditorConfig: {
mode: "text/plain",
readOnly: false,
lineWrapping: true,
placeholder: `-----BEGIN PRIVATE KEY-----
Your private key here
-----END PRIVATE KEY-----`,
},
linter: null,
completer: null,
environmentHighlights: true,
})
)
const algorithms: HoppRESTAuthJWT["algorithm"][] = ["HS256", "HS384", "HS512"]
const addToTargets = [
{
id: "HEADERS" as const,
label: "Headers",
},
{
id: "QUERY_PARAMS" as const,
label: "Query Params",
},
]
const passBy = computed(() => {
return (
addToTargets.find((target) => target.id === auth.value.addTo)?.label ||
t("state.none")
)
})
const isAsymmetricAlgorithm = computed(() => {
return (
auth.value.algorithm.startsWith("RS") ||
auth.value.algorithm.startsWith("ES") ||
auth.value.algorithm.startsWith("PS")
)
})
</script>

View file

@ -29,13 +29,12 @@ import { toFormData } from "../functional/formData"
import { tupleWithSameKeysToRecord } from "../functional/record"
import { isJSONContentType } from "./contenttypes"
import { stripComments } from "../editor/linting/jsonc"
import {
DigestAuthParams,
fetchInitialDigestAuthInfo,
generateDigestAuthHeader,
} from "../auth/digest"
import { calculateHawkHeader } from "@hoppscotch/data"
import { calculateHawkHeader, generateJWTToken } from "@hoppscotch/data"
export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
/**
@ -297,6 +296,35 @@ export const getComputedAuthHeaders = async (
value: hawkHeader,
description: "",
})
} else if (
request.auth.authType === "jwt" &&
request.auth.addTo === "HEADERS"
) {
const token = await generateJWTToken({
algorithm: request.auth.algorithm || "HS256",
secret: parseTemplateString(request.auth.secret, envVars, false),
privateKey: parseTemplateString(request.auth.privateKey, envVars, false),
payload: parseTemplateString(request.auth.payload, envVars, false),
jwtHeaders: parseTemplateString(request.auth.jwtHeaders, envVars, false),
isSecretBase64Encoded: request.auth.isSecretBase64Encoded,
})
if (token) {
// Get prefix (defaults to "Bearer " if not specified)
const headerPrefix = parseTemplateString(
request.auth.headerPrefix,
envVars,
false,
showKeyIfSecret
)
headers.push({
active: true,
key: "Authorization",
value: `${headerPrefix}${token}`,
description: "",
})
}
}
return headers
@ -432,7 +460,8 @@ export const getComputedParams = async (
if (
req.auth.authType !== "api-key" &&
req.auth.authType !== "oauth-2" &&
req.auth.authType !== "aws-signature"
req.auth.authType !== "aws-signature" &&
req.auth.authType !== "jwt"
)
return []
@ -503,6 +532,36 @@ export const getComputedParams = async (
},
]
}
if (req.auth.authType === "jwt") {
const token = await generateJWTToken({
algorithm: req.auth.algorithm || "HS256",
secret: parseTemplateString(req.auth.secret, envVars, false),
privateKey: parseTemplateString(req.auth.privateKey, envVars, false),
payload: parseTemplateString(req.auth.payload, envVars, false),
jwtHeaders: parseTemplateString(req.auth.jwtHeaders, envVars, false),
isSecretBase64Encoded: req.auth.isSecretBase64Encoded,
})
if (token) {
// Get param name (defaults to "token" if not specified)
const paramName = parseTemplateString(req.auth.paramName, envVars)
return [
{
source: "auth",
param: {
active: true,
key: paramName,
value: token,
description: "",
},
},
]
}
return []
}
return []
}

View file

@ -25,7 +25,7 @@ const DEFAULT_SETTINGS = getDefaultSettings()
export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
{
v: 7,
v: 8,
name: "Echo",
requests: [
{
@ -57,7 +57,7 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
{
v: 7,
v: 8,
name: "Echo",
requests: [
{

View file

@ -462,6 +462,7 @@ export class PersistenceService extends Service {
const translatedData = result.data.map(translateToNewRESTCollection)
setRESTCollections(translatedData)
} else {
console.error(`Failed with `, result.error, data)
this.showErrorToast(STORE_KEYS.REST_COLLECTIONS)
await Store.set(
STORE_NAMESPACE,

View file

@ -37,7 +37,7 @@
"devDependencies": {
"@types/lodash": "4.17.10",
"@types/uuid": "10.0.0",
"typescript": "5.6.3",
"typescript": "5.8.3",
"vite": "5.4.9"
},
"dependencies": {
@ -45,8 +45,9 @@
"io-ts": "2.2.21",
"lodash": "4.17.21",
"parser-ts": "0.7.0",
"jose": "6.0.11",
"uuid": "10.0.0",
"verzod": "0.2.4",
"zod": "3.23.8"
"zod": "3.25.32"
}
}

View file

@ -7,6 +7,7 @@ import V4_VERSION from "./v/4"
import V5_VERSION from "./v/5"
import V6_VERSION from "./v/6"
import V7_VERSION from "./v/7"
import V8_VERSION from "./v/8"
import { z } from "zod"
import { translateToNewRequest } from "../rest"
@ -18,7 +19,7 @@ const versionedObject = z.object({
})
export const HoppCollection = createVersionedEntity({
latestVersion: 7,
latestVersion: 8,
versionMap: {
1: V1_VERSION,
2: V2_VERSION,
@ -27,6 +28,7 @@ export const HoppCollection = createVersionedEntity({
5: V5_VERSION,
6: V6_VERSION,
7: V7_VERSION,
8: V8_VERSION,
},
getVersion(data) {
const versionCheck = versionedObject.safeParse(data)
@ -42,7 +44,7 @@ export const HoppCollection = createVersionedEntity({
export type HoppCollection = InferredEntity<typeof HoppCollection>
export const CollectionSchemaVersion = 7
export const CollectionSchemaVersion = 8
/**
* Generates a Collection object. This ignores the version number object

View file

@ -0,0 +1,37 @@
import { defineVersion } from "verzod"
import { z } from "zod"
import { HoppGQLAuth } from "../../graphql/v/8"
import { HoppRESTAuth } from "../../rest/v/13"
import { V7_SCHEMA, v7_baseCollectionSchema } from "./7"
export const v8_baseCollectionSchema = v7_baseCollectionSchema.extend({
v: z.literal(8),
auth: z.union([HoppRESTAuth, HoppGQLAuth]),
})
type Input = z.input<typeof v8_baseCollectionSchema> & {
folders: Input[]
}
type Output = z.output<typeof v8_baseCollectionSchema> & {
folders: Output[]
}
export const V8_SCHEMA: z.ZodType<Output, z.ZodTypeDef, Input> =
v8_baseCollectionSchema.extend({
folders: z.lazy(() => z.array(V8_SCHEMA)),
})
export default defineVersion({
initial: false,
schema: V8_SCHEMA,
// @ts-expect-error
up(old: z.infer<typeof V7_SCHEMA>) {
return {
...old,
v: 8 as const,
}
},
})

View file

@ -8,3 +8,4 @@ export * from "./predefinedVariables"
export * from "./utils/collection"
export * from "./utils/hawk"
export * from "./utils/akamai-eg"
export * from "./utils/jwt"

View file

@ -18,7 +18,8 @@ import V8_VERSION from "./v/8"
import V9_VERSION from "./v/9"
import V10_VERSION, { HoppRESTReqBody } from "./v/10"
import V11_VERSION, { HoppRESTRequestResponses } from "./v/11"
import V12_VERSION, { HoppRESTAuth } from "./v/12"
import V12_VERSION from "./v/12"
import V13_VERSION, { HoppRESTAuth } from "./v/13"
export * from "./content-types"
@ -51,17 +52,15 @@ export {
HoppRESTResponseOriginalRequest,
HoppRESTRequestResponse,
HoppRESTRequestResponses,
HoppRESTAuthOAuth2,
ClientCredentialsGrantTypeParams,
} from "./v/11"
export { HoppRESTReqBody } from "./v/10"
export { HoppRESTAuthOAuth2, ClientCredentialsGrantTypeParams } from "./v/11"
export { HoppRESTAuthHAWK, HoppRESTAuthAkamaiEdgeGrid } from "./v/12"
export {
HoppRESTAuthHAWK,
HoppRESTAuthAkamaiEdgeGrid,
HoppRESTAuth,
} from "./v/12"
export { HoppRESTAuth, HoppRESTAuthJWT } from "./v/13"
const versionedObject = z.object({
// v is a stringified number
@ -69,7 +68,7 @@ const versionedObject = z.object({
})
export const HoppRESTRequest = createVersionedEntity({
latestVersion: 12,
latestVersion: 13,
versionMap: {
0: V0_VERSION,
1: V1_VERSION,
@ -84,6 +83,7 @@ export const HoppRESTRequest = createVersionedEntity({
10: V10_VERSION,
11: V11_VERSION,
12: V12_VERSION,
13: V13_VERSION,
},
getVersion(data) {
// For V1 onwards we have the v string storing the number
@ -126,7 +126,7 @@ const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
responses: lodashIsEqualEq,
})
export const RESTReqSchemaVersion = "12"
export const RESTReqSchemaVersion = "13"
export type HoppRESTParam = HoppRESTRequest["params"][number]
export type HoppRESTHeader = HoppRESTRequest["headers"][number]

View file

@ -0,0 +1,83 @@
import {
HoppRESTAuthBasic,
HoppRESTAuthBearer,
HoppRESTAuthInherit,
HoppRESTAuthNone,
} from "./1"
import { HoppRESTAuthAPIKey } from "./4"
import { HoppRESTAuthAWSSignature } from "./7"
import { HoppRESTAuthDigest } from "./8"
import { HoppRESTAuthHAWK, HoppRESTAuthAkamaiEdgeGrid, V12_SCHEMA } from "./12"
import { z } from "zod"
import { defineVersion } from "verzod"
import { HoppRESTAuthOAuth2 } from "./11"
export const HoppRESTAuthJWT = z.object({
authType: z.literal("jwt"),
secret: z.string().catch(""),
privateKey: z.string().catch(""), // For RSA/ECDSA algorithms
algorithm: z
.enum([
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
])
.catch("HS256"),
payload: z.string().catch("{}"),
addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"),
isSecretBase64Encoded: z.boolean().catch(false),
headerPrefix: z.string().catch("Bearer "),
paramName: z.string().catch("token"),
jwtHeaders: z.string().catch("{}"),
})
export type HoppRESTAuthJWT = z.infer<typeof HoppRESTAuthJWT>
export const HoppRESTAuth = z
.discriminatedUnion("authType", [
HoppRESTAuthNone,
HoppRESTAuthInherit,
HoppRESTAuthBasic,
HoppRESTAuthBearer,
HoppRESTAuthOAuth2,
HoppRESTAuthAPIKey,
HoppRESTAuthAWSSignature,
HoppRESTAuthDigest,
HoppRESTAuthHAWK,
HoppRESTAuthAkamaiEdgeGrid,
HoppRESTAuthJWT,
])
.and(
z.object({
authActive: z.boolean(),
})
)
export type HoppRESTAuth = z.infer<typeof HoppRESTAuth>
export const V13_SCHEMA = V12_SCHEMA.extend({
v: z.literal("13"),
auth: HoppRESTAuth,
})
export default defineVersion({
schema: V13_SCHEMA,
initial: false,
up(old: any) {
return {
...old,
v: "13" as const,
}
},
})

View file

@ -0,0 +1,99 @@
import * as jose from "jose"
export interface JWTTokenParams {
algorithm: string
secret: string
privateKey: string
payload: string
jwtHeaders: string
isSecretBase64Encoded: boolean
}
/**
* Generates a JWT token using the provided parameters
* @param params JWT token generation parameters with pre-parsed values
* @returns Promise<string | null> - The generated JWT token or null if generation fails
*/
export async function generateJWTToken(
params: JWTTokenParams
): Promise<string | null> {
const {
algorithm,
secret,
privateKey,
payload,
jwtHeaders,
isSecretBase64Encoded,
} = params
// Parse the payload and headers from JSON strings
let parsedPayload = {}
let parsedHeaders = {}
// Safely parse payload JSON
try {
const payloadString = payload?.trim() || "{}"
if (payloadString === "") {
parsedPayload = {}
} else {
parsedPayload = JSON.parse(payloadString)
}
} catch (e) {
console.error("Failed to parse JWT payload JSON:", e)
console.error("Payload value:", payload)
return null
}
// Safely parse headers JSON
try {
const headersString = jwtHeaders?.trim() || "{}"
if (headersString === "") {
parsedHeaders = {}
} else {
parsedHeaders = JSON.parse(headersString)
}
} catch (e) {
console.error("Failed to parse JWT headers JSON:", e)
console.error("Headers value:", jwtHeaders)
return null
}
try {
let cryptoKey: Uint8Array
// Use private key for RSA/ECDSA algorithms, secret for HMAC algorithms
if (
algorithm.startsWith("RS") ||
algorithm.startsWith("ES") ||
algorithm.startsWith("PS")
) {
// RSA or ECDSA algorithms - use private key
if (!privateKey) {
console.error("Private key is required for RSA/ECDSA algorithms")
return null
}
cryptoKey = new TextEncoder().encode(privateKey)
} else {
// HMAC algorithms - use secret
if (!secret) {
console.error("Secret is required for HMAC algorithms")
return null
}
cryptoKey = isSecretBase64Encoded
? Uint8Array.from(Buffer.from(secret, "base64"))
: new TextEncoder().encode(secret)
}
const token = await new jose.SignJWT(parsedPayload)
.setProtectedHeader({
alg: algorithm,
...parsedHeaders,
})
.sign(cryptoKey)
return token
} catch (e) {
console.error("Error generating JWT token:", e)
return null
}
}

View file

@ -134,7 +134,7 @@ function exportedCollectionToHoppCollection(
return {
id: restCollection.id,
v: 7,
v: 8,
name: restCollection.name,
folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
@ -196,7 +196,7 @@ function exportedCollectionToHoppCollection(
return {
id: gqlCollection.id,
v: 7,
v: 8,
name: gqlCollection.name,
folders: gqlCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
@ -374,7 +374,7 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title,
folders: [],
requests: [],
v: 7,
v: 8,
auth: data.auth,
headers: addDescriptionField(data.headers),
})
@ -382,7 +382,7 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title,
folders: [],
requests: [],
v: 7,
v: 8,
auth: data.auth,
headers: addDescriptionField(data.headers),
})
@ -607,7 +607,7 @@ function setupUserCollectionDuplicatedSubscription() {
name,
folders,
requests,
v: 7,
v: 8,
auth,
headers: addDescriptionField(headers),
}
@ -1037,7 +1037,7 @@ function transformDuplicatedCollections(
name,
folders,
requests,
v: 7,
v: 8,
auth,
headers: addDescriptionField(headers),
}

View file

@ -47,7 +47,7 @@
"verzod": "0.2.4",
"vue": "3.5.12",
"workbox-window": "7.1.0",
"zod": "3.23.8"
"zod": "3.25.32"
},
"devDependencies": {
"@graphql-codegen/add": "5.0.3",
@ -76,7 +76,7 @@
"postcss": "8.4.47",
"prettier-plugin-tailwindcss": "0.6.8",
"tailwindcss": "3.4.13",
"typescript": "5.3.3",
"typescript": "5.8.3",
"unplugin-fonts": "1.1.1",
"unplugin-icons": "0.19.3",
"unplugin-vue-components": "0.27.4",

View file

@ -140,7 +140,7 @@ function exportedCollectionToHoppCollection(
return {
id: restCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 7,
v: 8,
name: restCollection.name,
folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
@ -204,7 +204,7 @@ function exportedCollectionToHoppCollection(
return {
id: gqlCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 7,
v: 8,
name: gqlCollection.name,
folders: gqlCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
@ -383,7 +383,7 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title,
folders: [],
requests: [],
v: 7,
v: 8,
_ref_id: data._ref_id,
auth: data.auth,
headers: addDescriptionField(data.headers),
@ -392,7 +392,7 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title,
folders: [],
requests: [],
v: 7,
v: 8,
_ref_id: data._ref_id,
auth: data.auth,
headers: addDescriptionField(data.headers),
@ -620,7 +620,7 @@ function setupUserCollectionDuplicatedSubscription() {
name,
folders,
requests,
v: 7,
v: 8,
_ref_id,
auth,
headers: addDescriptionField(headers),
@ -1054,7 +1054,7 @@ function transformDuplicatedCollections(
folders,
requests,
_ref_id,
v: 7,
v: 8,
auth,
headers: addDescriptionField(headers),
}

View file

@ -140,7 +140,7 @@ function exportedCollectionToHoppCollection(
return {
id: restCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 7,
v: 8,
name: restCollection.name,
folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
@ -204,7 +204,7 @@ function exportedCollectionToHoppCollection(
return {
id: gqlCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 7,
v: 8,
name: gqlCollection.name,
folders: gqlCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
@ -383,7 +383,7 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title,
folders: [],
requests: [],
v: 7,
v: 8,
_ref_id: data._ref_id,
auth: data.auth,
headers: addDescriptionField(data.headers),
@ -392,7 +392,7 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title,
folders: [],
requests: [],
v: 7,
v: 8,
_ref_id: data._ref_id,
auth: data.auth,
headers: addDescriptionField(data.headers),
@ -620,7 +620,7 @@ function setupUserCollectionDuplicatedSubscription() {
name,
folders,
requests,
v: 7,
v: 8,
_ref_id,
auth,
headers: addDescriptionField(headers),
@ -1054,7 +1054,7 @@ function transformDuplicatedCollections(
folders,
requests,
_ref_id,
v: 7,
v: 8,
auth,
headers: addDescriptionField(headers),
}

View file

@ -65,7 +65,7 @@
"npm-run-all": "4.1.5",
"sass": "1.80.3",
"ts-node": "10.9.2",
"typescript": "5.6.3",
"typescript": "5.8.3",
"unplugin-fonts": "1.1.1",
"vite": "5.4.9",
"vite-plugin-pages": "0.32.3",

File diff suppressed because it is too large Load diff