diff --git a/packages/hoppscotch-cli/package.json b/packages/hoppscotch-cli/package.json index 09745a863..65ed6a94b 100644 --- a/packages/hoppscotch-cli/package.json +++ b/packages/hoppscotch-cli/package.json @@ -46,6 +46,7 @@ "chalk": "5.3.0", "commander": "12.1.0", "isolated-vm": "5.0.1", + "js-md5": "0.8.3", "lodash-es": "4.17.21", "qs": "6.13.0", "verzod": "0.2.3", diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts index 01ce0962d..e663ff739 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts @@ -370,19 +370,7 @@ describe("hopp test [options] ", () => { ); describe("Request variables", () => { - test("Picks active request variables and ignores inactive entries", async () => { - const COLL_PATH = getTestJsonFilePath( - "request-vars-coll.json", - "collection" - ); - - const args = `test ${COLL_PATH}`; - - const { error } = await runCLI(args); - expect(error).toBeNull(); - }); - - test("Supports the usage of request variables along with environment variables", async () => { + test("Picks active request variables and ignores inactive entries alongside the usage of environment variables", async () => { const env = { ...process.env, secretBasicAuthPasswordEnvVar: "password", @@ -430,6 +418,24 @@ describe("hopp test [options] ", () => { expect(error).toBeNull(); }); }); + + describe("Digest Authorization type", () => { + test("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => { + const COLL_PATH = getTestJsonFilePath( + "digest-auth-coll.json", + "collection" + ); + const ENVS_PATH = getTestJsonFilePath( + "digest-auth-envs.json", + "environment" + ); + + const args = `test ${COLL_PATH} -e ${ENVS_PATH}`; + const { error } = await runCLI(args); + + expect(error).toBeNull(); + }); + }); }); describe("Test `hopp test --delay ` command:", () => { diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-coll.json new file mode 100644 index 000000000..a3d020aa6 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-coll.json @@ -0,0 +1,43 @@ +{ + "v": 3, + "name": "Digest Auth - collection", + "folders": [], + "requests": [ + { + "v": "8", + "id": "cm0dm70cw000687bnxi830zz7", + "auth": { + "authType": "digest", + "authActive": true, + "username": "<>", + "password": "<>", + "realm": "", + "nonce": "", + "algorithm": "MD5", + "qop": "auth", + "nc": "", + "cnonce": "", + "opaque": "", + "disableRetry": false + }, + "body": { + "body": null, + "contentType": null + }, + "name": "digest-auth-headers", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);}); \n pw.test(\"Receives the www-authenticate header\", ()=> { pw.expect(pw.response.headers['www-authenticate']).toBeType('string');});", + "preRequestScript": "", + "responses": {}, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-failure-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-failure-coll.json new file mode 100644 index 000000000..d8ab5ef25 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-failure-coll.json @@ -0,0 +1,43 @@ +{ + "v": 3, + "name": "Digest Auth (failure state) - collection", + "folders": [], + "requests": [ + { + "v": "8", + "id": "cm0dm70cw000687bnxi830zz7", + "auth": { + "authType": "digest", + "authActive": true, + "username": "<>", + "password": "<>", + "realm": "", + "nonce": "", + "algorithm": "MD5", + "qop": "auth", + "nc": "", + "cnonce": "", + "opaque": "", + "disableRetry": true + }, + "body": { + "body": null, + "contentType": null + }, + "name": "digest-auth-headers", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Status code is not 200\", ()=> { pw.expect(pw.response.status).not.toBe(200);}); \n pw.test(\"Receives the www-authenticate header\", ()=> { pw.expect(pw.response.headers['www-authenticate']).not.toBeType('string');});", + "preRequestScript": "", + "responses": {}, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-success-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-success-coll.json new file mode 100644 index 000000000..16f0b6fca --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/digest-auth-success-coll.json @@ -0,0 +1,43 @@ +{ + "v": 3, + "name": "Digest Auth (success state) - collection", + "folders": [], + "requests": [ + { + "v": "8", + "id": "cm0dm70cw000687bnxi830zz7", + "auth": { + "authType": "digest", + "authActive": true, + "username": "<>", + "password": "<>", + "realm": "", + "nonce": "", + "algorithm": "MD5", + "qop": "auth", + "nc": "", + "cnonce": "", + "opaque": "", + "disableRetry": false + }, + "body": { + "body": null, + "contentType": null + }, + "name": "digest-auth-headers", + "method": "GET", + "params": [], + "headers": [], + "endpoint": "<>", + "testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);}); \n pw.test(\"Receives the www-authenticate header\", ()=> { pw.expect(pw.response.headers['www-authenticate']).toBeType('string');});", + "preRequestScript": "", + "responses": {}, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/digest-auth-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/digest-auth-envs.json new file mode 100644 index 000000000..eacbcbc07 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/digest-auth-envs.json @@ -0,0 +1,21 @@ +{ + "v": 1, + "id": "cm0dsn3v70004p4qk3l9b7sjm", + "name": "Digest Auth - environments", + "variables": [ + { + "key": "username", + "value": "admin", + "secret": true + }, + { + "key": "password", + "value": "admin", + "secret": true + }, + { + "key": "url", + "value": "https://test.insightres.org/digest/" + } + ] +} diff --git a/packages/hoppscotch-cli/src/utils/auth/digest.ts b/packages/hoppscotch-cli/src/utils/auth/digest.ts new file mode 100644 index 000000000..5120535f3 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/auth/digest.ts @@ -0,0 +1,139 @@ +import axios from "axios"; +import { md5 } from "js-md5"; + +import { exceptionColors } from "../getters"; + +export interface DigestAuthParams { + username: string; + password: string; + realm: string; + nonce: string; + endpoint: string; + method: string; + algorithm: string; + qop: string; + nc?: string; + opaque?: string; + cnonce?: string; // client nonce (optional but typically required in qop='auth') +} + +export interface DigestAuthInfo { + realm: string; + nonce: string; + qop: string; + opaque?: string; + algorithm: string; +} + +// Utility function to parse Digest auth header values +const parseDigestAuthHeader = ( + header: string +): { [key: string]: string } | null => { + const matches = header.match(/([a-z0-9]+)="([^"]+)"/gi); + if (!matches) return null; + + const authParams: { [key: string]: string } = {}; + matches.forEach((match) => { + const parts = match.split("="); + authParams[parts[0]] = parts[1].replace(/"/g, ""); + }); + + return authParams; +}; + +// Function to generate Digest Auth Header +export const generateDigestAuthHeader = async (params: DigestAuthParams) => { + const { + username, + password, + realm, + nonce, + endpoint, + method, + algorithm = "MD5", + qop, + nc = "00000001", + opaque, + cnonce, + } = params; + + const uri = endpoint.replace(/(^\w+:|^)\/\//, ""); + + // Generate client nonce if not provided + const generatedCnonce = cnonce || md5(`${Math.random()}`); + + // Step 1: Hash the username, realm, and password + const ha1 = md5(`${username}:${realm}:${password}`); + + // Step 2: Hash the method and URI + const ha2 = md5(`${method}:${uri}`); + + // Step 3: Compute the response hash + const response = md5( + `${ha1}:${nonce}:${nc}:${generatedCnonce}:${qop}:${ha2}` + ); + + // Build the Digest header + let authHeader = `Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", algorithm="${algorithm}", response="${response}", qop=${qop}, nc=${nc}, cnonce="${generatedCnonce}"`; + + if (opaque) { + authHeader += `, opaque="${opaque}"`; + } + + return authHeader; +}; + +export const fetchInitialDigestAuthInfo = async ( + url: string, + method: string, + disableRetry: boolean +): Promise => { + try { + const initialResponse = await axios.request({ + url, + method, + validateStatus: () => true, // Allow handling of all status codes + }); + + // Check if the response status is 401 (which is expected in Digest Auth flow) + if (initialResponse.status === 401 && !disableRetry) { + const authHeader = initialResponse.headers["www-authenticate"]; + + if (authHeader) { + const authParams = parseDigestAuthHeader(authHeader); + if ( + authParams && + authParams.realm && + authParams.nonce && + authParams.qop + ) { + return { + realm: authParams.realm, + nonce: authParams.nonce, + qop: authParams.qop, + opaque: authParams.opaque, + algorithm: authParams.algorithm, + }; + } + } + throw new Error( + "Failed to parse authentication parameters from WWW-Authenticate header" + ); + } else if (initialResponse.status === 401 && disableRetry) { + throw new Error( + `401 Unauthorized received. Retry is disabled as specified, so no further attempts will be made.` + ); + } else { + throw new Error(`Unexpected response: ${initialResponse.status}`); + } + } catch (error) { + const errMsg = error instanceof Error ? error.message : error; + + console.error( + exceptionColors.FAIL( + `\n Error fetching initial digest auth info: ${errMsg} \n` + ) + ); + throw error; // Re-throw the error to handle it further up the chain if needed + } +}; diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index 2908a25fb..75438b3bf 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -26,6 +26,11 @@ import { isHoppCLIError } from "./checks"; import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array"; import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters"; import { toFormData } from "./mutators"; +import { + DigestAuthParams, + fetchInitialDigestAuthInfo, + generateDigestAuthHeader, +} from "./auth/digest"; /** * Runs pre-request-script runner over given request which extracts set ENVs and @@ -232,6 +237,46 @@ export async function getEffectiveRESTRequest( }); }); } + } else if (request.auth.authType === "digest") { + const { method, endpoint } = request as HoppRESTRequest; + + // Step 1: Fetch the initial auth info (nonce, realm, etc.) + const authInfo = await fetchInitialDigestAuthInfo( + parseTemplateString(endpoint, resolvedVariables), + method, + request.auth.disableRetry + ); + + // Step 2: Set up the parameters for the digest authentication header + const digestAuthParams: DigestAuthParams = { + username: parseTemplateString(request.auth.username, resolvedVariables), + password: parseTemplateString(request.auth.password, resolvedVariables), + realm: request.auth.realm + ? parseTemplateString(request.auth.realm, resolvedVariables) + : authInfo.realm, + nonce: request.auth.nonce + ? parseTemplateString(authInfo.nonce, resolvedVariables) + : authInfo.nonce, + endpoint: parseTemplateString(endpoint, resolvedVariables), + method, + algorithm: request.auth.algorithm ?? authInfo.algorithm, + qop: request.auth.qop + ? parseTemplateString(request.auth.qop, resolvedVariables) + : authInfo.qop, + opaque: request.auth.opaque + ? parseTemplateString(request.auth.opaque, resolvedVariables) + : authInfo.opaque, + }; + + // Step 3: Generate the Authorization header + const authHeaderValue = await generateDigestAuthHeader(digestAuthParams); + + effectiveFinalHeaders.push({ + active: true, + key: "Authorization", + value: authHeaderValue, + description: "", + }); } } diff --git a/packages/hoppscotch-cli/src/utils/request.ts b/packages/hoppscotch-cli/src/utils/request.ts index 97bd02041..ab38f983d 100644 --- a/packages/hoppscotch-cli/src/utils/request.ts +++ b/packages/hoppscotch-cli/src/utils/request.ts @@ -240,6 +240,7 @@ export const processRequest = // Updating report for errors & current result report.errors.push(preRequestRes.left); + console.error(`Report result is `, report.result); report.result = report.result; } else { // Updating effective-request and consuming updated envs after pre-request script execution diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 03f8e3fd1..6c9ca16ae 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -209,14 +209,25 @@ "token": "Token", "type": "Authorization Type", "username": "Username", + "advance_config": "Advanced Configuration", + "advance_config_description": "Hoppscotch automatically assigns default values to certain fields if no explicit value is provided", "aws_signature": { "access_key": "Access Key", "secret_key": "Secret Key", "service_name": "Service Name", "aws_region": "AWS Region", - "service_token": "Service Token", - "advance_config": "Advanced Configuration", - "advance_config_description": "Hoppscotch automatically assigns default values to certain fields if no explicit value is provided" + "service_token": "Service Token" + }, + "digest": { + "realm": "Realm", + "nonce": "Nonce", + "algorithm": "Algorithm", + "qop": "qop", + "nonce_count": "Nonce Count", + "client_nonce": "Client Nonce", + "opaque": "Opaque", + "disable_retry": "Disable Retrying Request", + "inspector_warning": "Agent interceptor is recommended when using Digest Authorization." } }, "collection": { diff --git a/packages/hoppscotch-common/package.json b/packages/hoppscotch-common/package.json index b54015e5d..af7f3e08e 100644 --- a/packages/hoppscotch-common/package.json +++ b/packages/hoppscotch-common/package.json @@ -64,6 +64,7 @@ "graphql-tag": "2.12.6", "insomnia-importers": "3.6.0", "io-ts": "2.2.21", + "js-md5": "0.8.3", "js-yaml": "4.1.0", "jsonc-parser": "3.3.1", "jsonpath-plus": "10.0.0", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 70c873501..5def13335 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -135,6 +135,7 @@ declare module 'vue' { HttpAuthorizationASAP: typeof import('./components/http/authorization/ASAP.vue')['default'] HttpAuthorizationAWSSign: typeof import('./components/http/authorization/AWSSign.vue')['default'] 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'] HttpAuthorizationNTLM: typeof import('./components/http/authorization/NTLM.vue')['default'] HttpAuthorizationOAuth2: typeof import('./components/http/authorization/OAuth2.vue')['default'] diff --git a/packages/hoppscotch-common/src/components/app/spotlight/Entry.vue b/packages/hoppscotch-common/src/components/app/spotlight/Entry.vue index aecc5b1b5..a5b07eeb8 100644 --- a/packages/hoppscotch-common/src/components/app/spotlight/Entry.vue +++ b/packages/hoppscotch-common/src/components/app/spotlight/Entry.vue @@ -80,10 +80,11 @@ const props = defineProps<{ active: boolean }>() -const formattedShortcutKeys = computed(() => - props.entry.meta?.keyboardShortcut?.map( - (key) => SPECIAL_KEY_CHARS[key] ?? capitalize(key) - ) +const formattedShortcutKeys = computed( + () => + props.entry.meta?.keyboardShortcut?.map( + (key) => SPECIAL_KEY_CHARS[key] ?? capitalize(key) + ) ) const emit = defineEmits<{ diff --git a/packages/hoppscotch-common/src/components/graphql/Authorization.vue b/packages/hoppscotch-common/src/components/graphql/Authorization.vue index 560237642..a19ff787e 100644 --- a/packages/hoppscotch-common/src/components/graphql/Authorization.vue +++ b/packages/hoppscotch-common/src/components/graphql/Authorization.vue @@ -39,7 +39,9 @@ :active="item.key === authType" @click=" () => { - item.handler ? item.handler() : (auth.authType = item.key) + item.handler + ? item.handler() + : (auth = { ...auth, authType: item.key } as HoppGQLAuth) hide() } " @@ -117,7 +119,8 @@ {{ t("authorization.inherited_from", { auth: getAuthName( - inheritedProperties.auth.inheritedAuth.authType + (inheritedProperties.auth.inheritedAuth as HoppGQLAuth) + .authType ), collection: inheritedProperties?.auth.parentName, }) @@ -176,7 +179,11 @@ import { useI18n } from "@composables/i18n" import { pluckRef } from "@composables/ref" import { useColorMode } from "@composables/theming" -import { HoppGQLAuth, HoppGQLAuthOAuth2 } from "@hoppscotch/data" +import { + HoppGQLAuth, + HoppGQLAuthOAuth2, + HoppGQLAuthAWSSignature, +} from "@hoppscotch/data" import { useVModel } from "@vueuse/core" import { computed, onMounted, ref } from "vue" @@ -255,15 +262,23 @@ const selectAPIKeyAuthType = () => { } const selectAWSSignatureAuthType = () => { + const { + accessKey = "", + secretKey = "", + region = "", + serviceName = "", + addTo = "HEADERS", + } = auth.value as HoppGQLAuthAWSSignature + auth.value = { ...auth.value, authType: "aws-signature", - addTo: "HEADERS", - accessKey: "", - secretKey: "", - region: "", - serviceName: "", - } as HoppGQLAuth + addTo, + accessKey, + secretKey, + region, + serviceName, + } } const authTypes: AuthType[] = [ diff --git a/packages/hoppscotch-common/src/components/graphql/Headers.vue b/packages/hoppscotch-common/src/components/graphql/Headers.vue index 51c4bb974..9b3c1a808 100644 --- a/packages/hoppscotch-common/src/components/graphql/Headers.vue +++ b/packages/hoppscotch-common/src/components/graphql/Headers.vue @@ -231,6 +231,7 @@ import { useColorMode } from "@composables/theming" import { useToast } from "@composables/toast" import { GQLHeader, + HoppGQLAuth, HoppGQLRequest, parseRawKeyValueEntriesE, rawKeyValueEntriesToString, @@ -675,7 +676,7 @@ const inheritedProperties = computedAsync(async () => { const [computedAuthHeader] = await getComputedAuthHeaders( request.value, - props.inheritedProperties.auth.inheritedAuth + props.inheritedProperties.auth.inheritedAuth as HoppGQLAuth ) if ( diff --git a/packages/hoppscotch-common/src/components/http/Authorization.vue b/packages/hoppscotch-common/src/components/http/Authorization.vue index 05c188a54..c18f280a8 100644 --- a/packages/hoppscotch-common/src/components/http/Authorization.vue +++ b/packages/hoppscotch-common/src/components/http/Authorization.vue @@ -39,7 +39,9 @@ :active="item.key === authType" @click=" () => { - item.handler ? item.handler() : (auth.authType = item.key) + item.handler + ? item.handler() + : (auth = { ...auth, authType: item.key } as HoppRESTAuth) hide() } " @@ -149,6 +151,9 @@
+
+ +
{ } const selectAWSSignatureAuthType = () => { + const { + accessKey = "", + secretKey = "", + region = "", + serviceName = "", + addTo = "HEADERS", + } = auth.value as HoppRESTAuthAWSSignature + auth.value = { ...auth.value, authType: "aws-signature", - addTo: "HEADERS", - accessKey: "", - secretKey: "", - region: "", - serviceName: "", + addTo, + accessKey, + secretKey, + region, + serviceName, + } +} + +const selectDigestAuthType = () => { + const { + username = "", + password = "", + algorithm = "MD5", + } = auth.value as HoppRESTAuthDigest + + console.error(`Auth is `, auth.value) + + auth.value = { + ...auth.value, + authType: "digest", + username, + password, + algorithm, } as HoppRESTAuth } @@ -260,6 +296,11 @@ const authTypes: AuthType[] = [ key: "basic", label: "Basic Auth", }, + { + key: "digest", + label: "Digest Auth", + handler: selectDigestAuthType, + }, { key: "bearer", label: "Bearer", diff --git a/packages/hoppscotch-common/src/components/http/authorization/AWSSign.vue b/packages/hoppscotch-common/src/components/http/authorization/AWSSign.vue index 4df3b6592..cc1957e12 100644 --- a/packages/hoppscotch-common/src/components/http/authorization/AWSSign.vue +++ b/packages/hoppscotch-common/src/components/http/authorization/AWSSign.vue @@ -21,11 +21,11 @@
-
diff --git a/packages/hoppscotch-common/src/components/http/authorization/Digest.vue b/packages/hoppscotch-common/src/components/http/authorization/Digest.vue new file mode 100644 index 000000000..18770f8d8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/authorization/Digest.vue @@ -0,0 +1,172 @@ + + + diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts index 6241a5536..bef8141b4 100644 --- a/packages/hoppscotch-common/src/composables/codemirror.ts +++ b/packages/hoppscotch-common/src/composables/codemirror.ts @@ -375,7 +375,7 @@ export function useCodemirror( language.of( getEditorLanguage( options.extendedEditorConfig.useLang - ? ((options.extendedEditorConfig.mode as any) ?? "") + ? (options.extendedEditorConfig.mode as any) ?? "" : "", options.linter ?? undefined, options.completer ?? undefined @@ -486,7 +486,7 @@ export function useCodemirror( effects: language.reconfigure( getEditorLanguage( options.extendedEditorConfig.useLang - ? ((options.extendedEditorConfig.mode as any) ?? "") + ? (options.extendedEditorConfig.mode as any) ?? "" : "", options.linter ?? undefined, options.completer ?? undefined diff --git a/packages/hoppscotch-common/src/helpers/auth/digest.ts b/packages/hoppscotch-common/src/helpers/auth/digest.ts new file mode 100644 index 000000000..92f4a68d8 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/digest.ts @@ -0,0 +1,147 @@ +import * as E from "fp-ts/Either" +import { md5 } from "js-md5" + +import { getService } from "~/modules/dioc" +import { getI18n } from "~/modules/i18n" +import { InterceptorService } from "~/services/interceptor.service" + +export interface DigestAuthParams { + username: string + password: string + realm: string + nonce: string + endpoint: string + method: string + algorithm: string + qop: string + nc?: string + opaque?: string + cnonce?: string // client nonce (optional but typically required in qop='auth') +} + +// Function to generate Digest Auth Header +export async function generateDigestAuthHeader(params: DigestAuthParams) { + const { + username, + password, + realm, + nonce, + endpoint, + method, + algorithm = "MD5", + qop, + nc = "00000001", + opaque, + cnonce, + } = params + + const uri = endpoint.replace(/(^\w+:|^)\/\//, "") + + // Generate client nonce if not provided + const generatedCnonce = cnonce || md5(`${Math.random()}`) + + // Step 1: Hash the username, realm, and password + const ha1 = md5(`${username}:${realm}:${password}`) + + // Step 2: Hash the method and URI + const ha2 = md5(`${method}:${uri}`) + + // Step 3: Compute the response hash + const response = md5(`${ha1}:${nonce}:${nc}:${generatedCnonce}:${qop}:${ha2}`) + + // Build the Digest header + let authHeader = `Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", algorithm="${algorithm}", response="${response}", qop=${qop}, nc=${nc}, cnonce="${generatedCnonce}"` + + if (opaque) { + authHeader += `, opaque="${opaque}"` + } + + return authHeader +} + +export interface DigestAuthInfo { + realm: string + nonce: string + qop: string + opaque?: string + algorithm: string +} + +export async function fetchInitialDigestAuthInfo( + url: string, + method: string, + disableRetry: boolean +): Promise { + const t = getI18n() + + try { + const service = getService(InterceptorService) + const initialResponse = await service.runRequest({ + url, + method, + }).response + + if (E.isLeft(initialResponse)) { + const initialFetchFailureReason = + initialResponse.left === "cancellation" + ? initialResponse.left + : initialResponse.left.humanMessage.heading(t) + + throw new Error(initialFetchFailureReason) + } + + // Check if the response status is 401 (which is expected in Digest Auth flow) + if (initialResponse.right.status === 401 && !disableRetry) { + const authHeader = initialResponse.right.headers["www-authenticate"] + + if (authHeader) { + const authParams = parseDigestAuthHeader(authHeader) + if ( + authParams && + authParams.realm && + authParams.nonce && + authParams.qop + ) { + return { + realm: authParams.realm, + nonce: authParams.nonce, + qop: authParams.qop, + opaque: authParams.opaque, + algorithm: authParams.algorithm, + } + } + } + throw new Error( + "Failed to parse authentication parameters from WWW-Authenticate header" + ) + } else if (initialResponse.right.status === 401 && disableRetry) { + throw new Error( + `401 Unauthorized received. Retry is disabled as specified, so no further attempts will be made.` + ) + } else { + throw new Error(`Unexpected response: ${initialResponse.right.status}`) + } + } catch (error) { + const errMsg = error instanceof Error ? error.message : error + + console.error(`Failed to fetch initial Digest Auth info: ${errMsg}`) + + throw error // Re-throw the error to handle it further up the chain if needed + } +} + +// Utility function to parse Digest auth header values +function parseDigestAuthHeader( + header: string +): { [key: string]: string } | null { + const matches = header.match(/([a-z0-9]+)="([^"]+)"/gi) + if (!matches) return null + + const authParams: { [key: string]: string } = {} + matches.forEach((match) => { + const parts = match.split("=") + authParams[parts[0]] = parts[1].replace(/"/g, "") + }) + + return authParams +} diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts index 7057b5771..d54dc9e9d 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts @@ -23,8 +23,9 @@ import { replaceInsomniaTemplating } from "./insomniaEnv" // TODO: Insomnia allows custom prefixes for Bearer token auth, Hoppscotch doesn't. We just ignore the prefix for now -type UnwrapPromise> = - T extends Promise ? Y : never +type UnwrapPromise> = T extends Promise + ? Y + : never type InsomniaDoc = UnwrapPromise> type InsomniaResource = ImportRequest diff --git a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts index 460bd1ebf..48ecbb798 100644 --- a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts +++ b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts @@ -30,6 +30,11 @@ import { tupleWithSameKeysToRecord } from "../functional/record" import { isJSONContentType } from "./contenttypes" import { stripComments } from "../editor/linting/jsonc" +import { + DigestAuthParams, + fetchInitialDigestAuthInfo, + generateDigestAuthHeader, +} from "../auth/digest" export interface EffectiveHoppRESTRequest extends HoppRESTRequest { /** * The effective final URL. @@ -100,6 +105,46 @@ export const getComputedAuthHeaders = async ( value: `Basic ${btoa(`${username}:${password}`)}`, description: "", }) + } else if (request.auth.authType === "digest") { + const { method, endpoint } = request as HoppRESTRequest + + // Step 1: Fetch the initial auth info (nonce, realm, etc.) + const authInfo = await fetchInitialDigestAuthInfo( + parseTemplateString(endpoint, envVars), + method, + request.auth.disableRetry + ) + + // Step 2: Set up the parameters for the digest authentication header + const digestAuthParams: DigestAuthParams = { + username: parseTemplateString(request.auth.username, envVars), + password: parseTemplateString(request.auth.password, envVars), + realm: request.auth.realm + ? parseTemplateString(request.auth.realm, envVars) + : authInfo.realm, + nonce: request.auth.nonce + ? parseTemplateString(authInfo.nonce, envVars) + : authInfo.nonce, + endpoint: parseTemplateString(endpoint, envVars), + method, + algorithm: request.auth.algorithm ?? authInfo.algorithm, + qop: request.auth.qop + ? parseTemplateString(request.auth.qop, envVars) + : authInfo.qop, + opaque: request.auth.opaque + ? parseTemplateString(request.auth.opaque, envVars) + : authInfo.opaque, + } + + // Step 3: Generate the Authorization header + const authHeaderValue = await generateDigestAuthHeader(digestAuthParams) + + headers.push({ + active: true, + key: "Authorization", + value: authHeaderValue, + description: "", + }) } else if ( request.auth.authType === "bearer" || (request.auth.authType === "oauth-2" && request.auth.addTo === "HEADERS") @@ -132,7 +177,7 @@ export const getComputedAuthHeaders = async ( false, showKeyIfSecret ) - : (request.auth.value ?? ""), + : request.auth.value ?? "", description: "", }) } diff --git a/packages/hoppscotch-common/src/pages/index.vue b/packages/hoppscotch-common/src/pages/index.vue index 8634d1fe3..4a3743e95 100644 --- a/packages/hoppscotch-common/src/pages/index.vue +++ b/packages/hoppscotch-common/src/pages/index.vue @@ -143,6 +143,7 @@ import { cloneDeep } from "lodash-es" import { RESTTabService } from "~/services/tab/rest" import { HoppTab } from "~/services/tab" import { HoppRequestDocument, HoppTabDocument } from "~/helpers/rest/document" +import { AuthorizationInspectorService } from "~/services/inspection/inspectors/authorization.inspector" const savingRequest = ref(false) const confirmingCloseForTabID = ref(null) @@ -400,6 +401,7 @@ defineActionHandler("tab.open-new", addNewTab) useService(HeaderInspectorService) useService(EnvironmentInspectorService) useService(ResponseInspectorService) +useService(AuthorizationInspectorService) for (const inspectorDef of platform.additionalInspectors ?? []) { useService(inspectorDef.service) } diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/authorization.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/authorization.inspector.ts new file mode 100644 index 000000000..43a801606 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/authorization.inspector.ts @@ -0,0 +1,106 @@ +import { + HoppRESTRequest, + HoppRESTResponseOriginalRequest, +} from "@hoppscotch/data" +import { Service } from "dioc" +import { computed, markRaw, Ref } from "vue" + +import { getI18n } from "~/modules/i18n" +import { AgentInterceptorService } from "~/platform/std/interceptors/agent" +import { InterceptorService } from "~/services/interceptor.service" +import { RESTTabService } from "~/services/tab/rest" +import IconAlertTriangle from "~icons/lucide/alert-triangle" +import { InspectionService, Inspector, InspectorResult } from ".." + +/** + * This inspector is responsible for inspecting the authorization properties of a request. + * Only applies to REST tabs currently. + * + * NOTE: Initializing this service registers it as a inspector with the Inspection Service. + */ +export class AuthorizationInspectorService + extends Service + implements Inspector +{ + public static readonly ID = "AUTHORIZATION_INSPECTOR_SERVICE" + + private t = getI18n() + + public readonly inspectorID = "authorization" + + private readonly inspection = this.bind(InspectionService) + private readonly interceptorService = this.bind(InterceptorService) + private readonly agentService = this.bind(AgentInterceptorService) + private readonly restTabService = this.bind(RESTTabService) + + override onServiceInit() { + this.inspection.registerInspector(this) + } + + private resolveAuthType(auth: HoppRESTRequest["auth"]) { + if (auth.authType !== "inherit") { + return auth.authType + } + + const activeTabDocument = + this.restTabService.currentActiveTab.value.document + + if (activeTabDocument.type === "example-response") { + return null + } + + const { inheritedProperties } = activeTabDocument + + if (!inheritedProperties) { + return null + } + + return inheritedProperties.auth.inheritedAuth.authType + } + + getInspections( + req: Readonly> + ) { + return computed(() => { + const currentInterceptorIDValue = + this.interceptorService.currentInterceptorID.value + + if (!currentInterceptorIDValue) { + return [] + } + + const auth = req.value.auth + + const results: InspectorResult[] = [] + + // `Agent` interceptor is recommended while using Digest Auth + const isUnsupportedInterceptor = + this.interceptorService.currentInterceptorID.value !== + this.agentService.interceptorID + + const resolvedAuthType = this.resolveAuthType(auth) + + if (resolvedAuthType === "digest" && isUnsupportedInterceptor) { + results.push({ + id: "url", + icon: markRaw(IconAlertTriangle), + text: { + type: "text", + text: this.t("authorization.digest.inspector_warning"), + }, + severity: 2, + isApplicable: true, + locations: { + type: "url", + }, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/documentation/features/inspections", + }, + }) + } + + return results + }) + } +} diff --git a/packages/hoppscotch-data/src/graphql/index.ts b/packages/hoppscotch-data/src/graphql/index.ts index 56f2dd647..98ac98705 100644 --- a/packages/hoppscotch-data/src/graphql/index.ts +++ b/packages/hoppscotch-data/src/graphql/index.ts @@ -17,7 +17,7 @@ export { export { HoppGQLAuthAPIKey } from "./v/4" -export { GQLHeader } from "./v/6" +export { GQLHeader, HoppGQLAuthAWSSignature } from "./v/6" export { HoppGQLAuth, HoppGQLAuthOAuth2 } from "./v/7" export const GQL_REQ_SCHEMA_VERSION = 7 diff --git a/packages/hoppscotch-data/src/rest/index.ts b/packages/hoppscotch-data/src/rest/index.ts index 567ee74a1..b61906bef 100644 --- a/packages/hoppscotch-data/src/rest/index.ts +++ b/packages/hoppscotch-data/src/rest/index.ts @@ -47,6 +47,7 @@ export { ClientCredentialsGrantTypeParams, HoppRESTAuth, HoppRESTAuthOAuth2, + HoppRESTAuthDigest, PasswordGrantTypeParams, HoppRESTResponseOriginalRequest, HoppRESTRequestResponse, diff --git a/packages/hoppscotch-data/src/rest/v/8.ts b/packages/hoppscotch-data/src/rest/v/8.ts index aced09ebd..5b34eba5f 100644 --- a/packages/hoppscotch-data/src/rest/v/8.ts +++ b/packages/hoppscotch-data/src/rest/v/8.ts @@ -49,6 +49,23 @@ export const HoppRESTAuthOAuth2 = z.object({ export type HoppRESTAuthOAuth2 = z.infer +// in this new version, we add a new auth type for Digest authentication +export const HoppRESTAuthDigest = z.object({ + authType: z.literal("digest"), + username: z.string().catch(""), + password: z.string().catch(""), + realm: z.string().catch(""), + nonce: z.string().catch(""), + algorithm: z.enum(["MD5", "MD5-sess"]).catch("MD5"), + qop: z.enum(["auth", "auth-int"]).catch("auth"), + nc: z.string().catch(""), + cnonce: z.string().catch(""), + opaque: z.string().catch(""), + disableRetry: z.boolean().catch(false), +}) + +export type HoppRESTAuthDigest = z.infer + export const HoppRESTAuth = z .discriminatedUnion("authType", [ HoppRESTAuthNone, @@ -58,6 +75,7 @@ export const HoppRESTAuth = z HoppRESTAuthOAuth2, HoppRESTAuthAPIKey, HoppRESTAuthAWSSignature, + HoppRESTAuthDigest, ]) .and( z.object({ diff --git a/packages/hoppscotch-js-sandbox/src/shared-utils.ts b/packages/hoppscotch-js-sandbox/src/shared-utils.ts index 9d3a564b6..44d30913f 100644 --- a/packages/hoppscotch-js-sandbox/src/shared-utils.ts +++ b/packages/hoppscotch-js-sandbox/src/shared-utils.ts @@ -43,9 +43,8 @@ const setEnv = ( selectedEnv.value = envValue } } else if (indexInGlobal >= 0) { - if ("value" in global[indexInGlobal]) { - ;(global[indexInGlobal] as { value: string }).value = envValue - } + if ("value" in global[indexInGlobal]) + (global[indexInGlobal] as { value: string }).value = envValue } else { selected.push({ key: envName, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e78b004fd..a7a73ea0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,6 +392,9 @@ importers: isolated-vm: specifier: 5.0.1 version: 5.0.1 + js-md5: + specifier: 0.8.3 + version: 0.8.3 lodash-es: specifier: 4.17.21 version: 4.17.21 @@ -570,6 +573,9 @@ importers: io-ts: specifier: 2.2.21 version: 2.2.21(fp-ts@2.16.9) + js-md5: + specifier: 0.8.3 + version: 0.8.3 js-yaml: specifier: 4.1.0 version: 4.1.0 @@ -8729,6 +8735,9 @@ packages: js-base64@3.7.7: resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + js-stringify@1.0.2: resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} @@ -22069,6 +22078,8 @@ snapshots: js-base64@3.7.7: {} + js-md5@0.8.3: {} + js-stringify@1.0.2: optional: true