diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/collection-level-headers-auth-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/collection-level-headers-auth-coll.json index 03d2598ee..5e779482f 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/collection-level-headers-auth-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/collection-level-headers-auth-coll.json @@ -17,7 +17,7 @@ "folders": [], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestD", "params": [], @@ -53,7 +53,7 @@ ], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestC", "params": [], @@ -90,7 +90,7 @@ ], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestB", "params": [], @@ -119,7 +119,7 @@ ], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestA", "params": [], @@ -162,7 +162,7 @@ "folders": [], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestB", "params": [], @@ -191,7 +191,7 @@ ], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestA", "params": [], diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/env-flag-tests-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/env-flag-tests-coll.json index 879264f7f..ded538ab1 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/env-flag-tests-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/env-flag-tests-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "2", + "v": "3", "endpoint": "<>", "name": "test1", "params": [], diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/fails-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/fails-coll.json index fec2e4e89..b81720491 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/fails-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/fails-coll.json @@ -5,7 +5,7 @@ "folders": [], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io/<>", "name": "", "params": [], @@ -13,10 +13,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");", "testScript": "// Check status code is 200\npwd.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"string\");\n});", @@ -24,10 +21,10 @@ "contentType": "application/json", "body": "{\n\"test\": \"<>\"\n}" }, - "requestVariables": [], + "requestVariables": [] }, { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.dio/<>", "name": "success", "params": [], @@ -35,10 +32,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.setd(\"HEADERS_TYPE2\", \"devblin_local2\");", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(300);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/malformed-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/malformed-coll.json index 9050f523c..a4c467215 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/malformed-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/malformed-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io/<>", "name": "fail", "params": [], @@ -12,10 +12,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"string\");\n});", @@ -26,7 +23,7 @@ "requestVariables": [], }, { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io/<>", "name": "success", "params": [], @@ -34,10 +31,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.set(\"HEADERS_TYPE2\", \"devblin_local2\");", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(300);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/passes-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/passes-coll.json index fb448bd17..e958164a3 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/passes-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/passes-coll.json @@ -5,7 +5,7 @@ "folders": [], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io/<>", "name": "", "params": [], @@ -13,10 +13,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", @@ -27,7 +24,7 @@ "requestVariables": [] }, { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io/<>", "name": "success", "params": [], @@ -35,10 +32,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.set(\"HEADERS_TYPE2\", \"devblin_local2\");", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/pre-req-script-env-var-persistence-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/pre-req-script-env-var-persistence-coll.json index c0197b092..0d625b81b 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/pre-req-script-env-var-persistence-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/pre-req-script-env-var-persistence-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "sample-req", diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/req-body-env-vars-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/req-body-env-vars-coll.json index 0e5f4590b..0ddf3ef31 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/req-body-env-vars-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/req-body-env-vars-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "2", + "v": "3", "name": "test-request", "endpoint": "https://echo.hoppscotch.io", "method": "POST", diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-coll.json index 57889f26b..2cea26e85 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "test-secret-headers", @@ -23,7 +23,7 @@ "preRequestScript": "const secretHeaderValueFromPreReqScript = pw.env.get(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true }, "body": { "body": "{\n \"secretBodyKey\": \"<>\"\n}", @@ -39,7 +39,7 @@ "preRequestScript": "const secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "test-secret-query-params", @@ -58,7 +58,7 @@ "preRequestScript": "const secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "basic", "password": "<>", @@ -76,7 +76,7 @@ "preRequestScript": "" }, { - "v": "2", + "v": "3", "auth": { "token": "<>", "authType": "bearer", @@ -95,7 +95,7 @@ "preRequestScript": "const secretBearerToken = pw.env.get(\"secretBearerToken\")\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)" }, { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "test-secret-fallback", diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-coll.json index 04a32e680..823eec801 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true @@ -29,7 +29,7 @@ "preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true @@ -54,7 +54,7 @@ "preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value-overriden\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true @@ -73,7 +73,7 @@ "preRequestScript": "const secretBodyValue = pw.env.get(\"secretBodyValue\")\n\nif (!secretBodyValue) { \n pw.env.set(\"secretBodyValue\", \"secret-body-value\")\n}\n\nconst secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true @@ -98,7 +98,7 @@ "preRequestScript": "const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n\nif (!secretQueryParamValue) {\n pw.env.set(\"secretQueryParamValue\", \"secret-query-param-value\")\n}\n\nconst secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "basic", "password": "<>", @@ -119,7 +119,7 @@ "preRequestScript": "let secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n\nlet secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\nif (!secretBasicAuthUsername) {\n pw.env.set(\"secretBasicAuthUsername\", \"test-user\")\n}\n\nif (!secretBasicAuthPassword) {\n pw.env.set(\"secretBasicAuthPassword\", \"test-pass\")\n}" }, { - "v": "2", + "v": "3", "auth": { "token": "<>", "authType": "bearer", diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-scripting-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-scripting-coll.json index 76ab9ea89..61dc17cd2 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-scripting-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-scripting-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://httpbin.org/post", "name": "req", "params": [], diff --git a/packages/hoppscotch-cli/src/options/test/env.ts b/packages/hoppscotch-cli/src/options/test/env.ts index 6e1c45271..1f0be6de4 100644 --- a/packages/hoppscotch-cli/src/options/test/env.ts +++ b/packages/hoppscotch-cli/src/options/test/env.ts @@ -6,7 +6,7 @@ import { error } from "../../types/errors"; import { HoppEnvKeyPairObject, HoppEnvPair, - HoppEnvs + HoppEnvs, } from "../../types/request"; import { readJsonFile } from "../../utils/mutators"; @@ -17,7 +17,7 @@ import { readJsonFile } from "../../utils/mutators"; */ export async function parseEnvsData(path: string) { const contents = await readJsonFile(path); - const envPairs: Array = []; + const envPairs: Array> = []; // The legacy key-value pair format that is still supported const HoppEnvKeyPairResult = HoppEnvKeyPairObject.safeParse(contents); @@ -26,7 +26,9 @@ export async function parseEnvsData(path: string) { const HoppEnvExportObjectResult = Environment.safeParse(contents); // Shape of the bulk environment export object that is exported from the app - const HoppBulkEnvExportObjectResult = z.array(entityReference(Environment)).safeParse(contents) + const HoppBulkEnvExportObjectResult = z + .array(entityReference(Environment)) + .safeParse(contents); // CLI doesnt support bulk environments export // Hence we check for this case and throw an error if it matches the format @@ -36,7 +38,10 @@ export async function parseEnvsData(path: string) { // Checks if the environment file is of the correct format // If it doesnt match either of them, we throw an error - if (!HoppEnvKeyPairResult.success && HoppEnvExportObjectResult.type === "err") { + if ( + !HoppEnvKeyPairResult.success && + HoppEnvExportObjectResult.type === "err" + ) { throw error({ code: "MALFORMED_ENV_FILE", path, data: error }); } diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index b576ac9aa..c4227c790 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -109,18 +109,31 @@ export function getEffectiveRESTRequest( key: "Authorization", value: `Basic ${btoa(`${username}:${password}`)}`, }); - } else if ( - request.auth.authType === "bearer" || - request.auth.authType === "oauth-2" - ) { + } else if (request.auth.authType === "bearer") { effectiveFinalHeaders.push({ active: true, key: "Authorization", - value: `Bearer ${parseTemplateString( - request.auth.token, - envVariables - )}`, + value: `Bearer ${parseTemplateString(request.auth.token, envVariables)}`, }); + } else if (request.auth.authType === "oauth-2") { + const { addTo } = request.auth; + + if (addTo === "HEADERS") { + effectiveFinalHeaders.push({ + active: true, + key: "Authorization", + value: `Bearer ${parseTemplateString(request.auth.grantTypeInfo.token, envVariables)}`, + }); + } else if (addTo === "QUERY_PARAMS") { + effectiveFinalParams.push({ + active: true, + key: "access_token", + value: parseTemplateString( + request.auth.grantTypeInfo.token, + envVariables + ), + }); + } } else if (request.auth.authType === "api-key") { const { key, value, addTo } = request.auth; if (addTo === "Headers") { diff --git a/packages/hoppscotch-cli/src/utils/request.ts b/packages/hoppscotch-cli/src/utils/request.ts index 3e15b2979..81513bd21 100644 --- a/packages/hoppscotch-cli/src/utils/request.ts +++ b/packages/hoppscotch-cli/src/utils/request.ts @@ -41,10 +41,10 @@ const processVariables = (variable: Environment["variables"][number]) => { ...variable, value: "value" in variable ? variable.value : process.env[variable.key] || "", - } + }; } - return variable -} + return variable; +}; /** * Processes given envs, which includes processing each variable in global @@ -56,10 +56,10 @@ const processEnvs = (envs: HoppEnvs) => { const processedEnvs = { global: envs.global.map(processVariables), selected: envs.selected.map(processVariables), - } + }; - return processedEnvs -} + return processedEnvs; +}; /** * Transforms given request data to request-config used by request-runner to @@ -70,7 +70,7 @@ const processEnvs = (envs: HoppEnvs) => { export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => { const config: RequestConfig = { supported: true, - displayUrl: req.effectiveFinalDisplayURL + displayUrl: req.effectiveFinalDisplayURL, }; const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest; const reqParams = finalParams(req); @@ -131,6 +131,7 @@ export const requestRunner = let status: number; const baseResponse = await axios(requestConfig); const { config } = baseResponse; + // PR-COMMENT: type error const runnerResponse: RequestRunnerResponse = { ...baseResponse, endpoint: getRequest.endpoint(config.url), @@ -257,10 +258,13 @@ export const processRequest = let updatedEnvs = {}; // Fetch values for secret environment variables from system environment - const processedEnvs = processEnvs(envs) + const processedEnvs = processEnvs(envs); // Executing pre-request-script - const preRequestRes = await preRequestScriptRunner(request, processedEnvs)(); + const preRequestRes = await preRequestScriptRunner( + request, + processedEnvs + )(); if (E.isLeft(preRequestRes)) { printPreRequestRunner.fail(); @@ -347,7 +351,7 @@ export const processRequest = */ export const preProcessRequest = ( request: HoppRESTRequest, - collection: HoppCollection, + collection: HoppCollection ): HoppRESTRequest => { const tempRequest = Object.assign({}, request); const { headers: parentHeaders, auth: parentAuth } = collection; @@ -372,8 +376,10 @@ export const preProcessRequest = ( // Filter out header entries present in the parent (folder/collection) under the same name // This ensures the child headers take precedence over the parent headers const filteredEntries = parentHeaders.filter((parentHeaderEntries) => { - return !tempRequest.headers.some((reqHeaderEntries) => reqHeaderEntries.key === parentHeaderEntries.key) - }) + return !tempRequest.headers.some( + (reqHeaderEntries) => reqHeaderEntries.key === parentHeaderEntries.key + ); + }); tempRequest.headers.push(...filteredEntries); } else if (!tempRequest.headers) { tempRequest.headers = []; diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 6ac98710a..a93843c80 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -137,7 +137,26 @@ "redirect_no_token_endpoint": "No Token Endpoint Defined", "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", "something_went_wrong_on_token_generation": "Something went wrong on token generation", - "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed" + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" }, "pass_key_by": "Pass by", "password": "Password", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 20783de26..75b5ec66e 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -1,5 +1,7 @@ -// generated by unplugin-vue-components -// We suggest you to commit this file into source control +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 import "@vue/runtime-core" diff --git a/packages/hoppscotch-common/src/components/collections/Properties.vue b/packages/hoppscotch-common/src/components/collections/Properties.vue index f23c64173..642679065 100644 --- a/packages/hoppscotch-common/src/components/collections/Properties.vue +++ b/packages/hoppscotch-common/src/components/collections/Properties.vue @@ -8,7 +8,7 @@ > diff --git a/packages/hoppscotch-common/src/composables/ref.ts b/packages/hoppscotch-common/src/composables/ref.ts index 446d8ba50..5d9cdbd08 100644 --- a/packages/hoppscotch-common/src/composables/ref.ts +++ b/packages/hoppscotch-common/src/composables/ref.ts @@ -1,4 +1,4 @@ -import { customRef, onBeforeUnmount, Ref, watch } from "vue" +import { customRef, onBeforeUnmount, ref, Ref, UnwrapRef, watch } from "vue" export function pluckRef(ref: Ref, key: K): Ref { return customRef((track, trigger) => { @@ -31,3 +31,16 @@ export function pluckMultipleFromRef>( ): { [key in K[number]]: Ref } { return Object.fromEntries(keys.map((x) => [x, pluckRef(sourceRef, x)])) as any } + +export const refWithCallbackOnChange = ( + initialValue: T, + callback: (value: UnwrapRef) => void +) => { + const targetRef = ref(initialValue) + + watch(targetRef, (value) => { + callback(value) + }) + + return targetRef +} diff --git a/packages/hoppscotch-common/src/helpers/collection/request.ts b/packages/hoppscotch-common/src/helpers/collection/request.ts index 31f0d5449..d5576c3d7 100644 --- a/packages/hoppscotch-common/src/helpers/collection/request.ts +++ b/packages/hoppscotch-common/src/helpers/collection/request.ts @@ -66,12 +66,20 @@ export function getRequestsByPath( let currentCollection = collections[pathArray[0]] if (pathArray.length === 1) { - return currentCollection.requests + const latestVersionedRequests = currentCollection.requests.filter( + (req): req is HoppRESTRequest => req.v === "3" + ) + + return latestVersionedRequests } for (let i = 1; i < pathArray.length; i++) { const folder = currentCollection.folders[pathArray[i]] if (folder) currentCollection = folder } - return currentCollection.requests + const latestVersionedRequests = currentCollection.requests.filter( + (req): req is HoppRESTRequest => req.v === "3" + ) + + return latestVersionedRequests } diff --git a/packages/hoppscotch-common/src/helpers/graphql/connection.ts b/packages/hoppscotch-common/src/helpers/graphql/connection.ts index 3d603c45d..e393ddc78 100644 --- a/packages/hoppscotch-common/src/helpers/graphql/connection.ts +++ b/packages/hoppscotch-common/src/helpers/graphql/connection.ts @@ -269,8 +269,16 @@ export const runGQLOperation = async (options: RunQueryOptions) => { const username = auth.username const password = auth.password finalHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}` - } else if (auth.authType === "bearer" || auth.authType === "oauth-2") { + } else if (auth.authType === "bearer") { finalHeaders.Authorization = `Bearer ${auth.token}` + } else if (auth.authType === "oauth-2") { + const { addTo } = auth + + if (addTo === "HEADERS") { + finalHeaders.Authorization = `Bearer ${auth.grantTypeInfo.token}` + } else if (addTo === "QUERY_PARAMS") { + params["access_token"] = auth.grantTypeInfo.token + } } else if (auth.authType === "api-key") { const { key, value, addTo } = auth if (addTo === "Headers") { 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 5cbd4b169..a69dbb9c3 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts @@ -111,12 +111,16 @@ const getHoppReqAuth = (req: InsomniaRequestResource): HoppRESTAuth => { return { authType: "oauth-2", authActive: !(auth.disabled ?? false), - accessTokenURL: replaceVarTemplating(auth.accessTokenUrl ?? ""), - authURL: replaceVarTemplating(auth.authorizationUrl ?? ""), - clientID: replaceVarTemplating(auth.clientId ?? ""), - oidcDiscoveryURL: "", - scope: replaceVarTemplating(auth.scope ?? ""), - token: "", + grantTypeInfo: { + authEndpoint: replaceVarTemplating(auth.authorizationUrl ?? ""), + clientID: replaceVarTemplating(auth.clientId ?? ""), + clientSecret: "", + grantType: "AUTHORIZATION_CODE", + scopes: replaceVarTemplating(auth.scope ?? ""), + token: "", + isPKCE: false, + tokenEndpoint: replaceVarTemplating(auth.accessTokenUrl ?? ""), + }, } else if (auth.type === "bearer") return { diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/openapi.ts b/packages/hoppscotch-common/src/helpers/import-export/import/openapi.ts index cc3d8cb9c..5be636606 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/openapi.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/openapi.ts @@ -279,67 +279,92 @@ const resolveOpenAPIV3SecurityObj = ( return { authType: "oauth-2", authActive: true, - accessTokenURL: scheme.flows.authorizationCode.tokenUrl ?? "", - authURL: scheme.flows.authorizationCode.authorizationUrl ?? "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: scheme.flows.authorizationCode.authorizationUrl ?? "", + clientID: "", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: scheme.flows.authorizationCode.tokenUrl ?? "", + clientSecret: "", + }, + addTo: "HEADERS", } } else if (scheme.flows.implicit) { return { authType: "oauth-2", authActive: true, - authURL: scheme.flows.implicit.authorizationUrl ?? "", - accessTokenURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "IMPLICIT", + authEndpoint: scheme.flows.implicit.authorizationUrl ?? "", + clientID: "", + token: "", + scopes: _schemeData.join(" "), + }, + addTo: "HEADERS", } } else if (scheme.flows.password) { return { authType: "oauth-2", authActive: true, - authURL: "", - accessTokenURL: scheme.flows.password.tokenUrl ?? "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "PASSWORD", + clientID: "", + authEndpoint: scheme.flows.password.tokenUrl, + clientSecret: "", + password: "", + username: "", + token: "", + scopes: _schemeData.join(" "), + }, + addTo: "HEADERS", } } else if (scheme.flows.clientCredentials) { return { authType: "oauth-2", authActive: true, - accessTokenURL: scheme.flows.clientCredentials.tokenUrl ?? "", - authURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "CLIENT_CREDENTIALS", + authEndpoint: scheme.flows.clientCredentials.tokenUrl ?? "", + clientID: "", + clientSecret: "", + scopes: _schemeData.join(" "), + token: "", + }, + addTo: "HEADERS", } } return { authType: "oauth-2", authActive: true, - accessTokenURL: "", - authURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "", + clientID: "", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: "", + clientSecret: "", + }, + addTo: "HEADERS", } } else if (scheme.type === "openIdConnect") { return { authType: "oauth-2", authActive: true, - accessTokenURL: "", - authURL: "", - clientID: "", - oidcDiscoveryURL: scheme.openIdConnectUrl ?? "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "", + clientID: "", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: "", + clientSecret: "", + }, + addTo: "HEADERS", } } @@ -416,56 +441,76 @@ const resolveOpenAPIV2SecurityScheme = ( return { authType: "oauth-2", authActive: true, - accessTokenURL: scheme.tokenUrl ?? "", - authURL: scheme.authorizationUrl ?? "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + authEndpoint: scheme.authorizationUrl ?? "", + clientID: "", + clientSecret: "", + grantType: "AUTHORIZATION_CODE", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: scheme.tokenUrl ?? "", + }, + addTo: "HEADERS", } } else if (scheme.flow === "implicit") { return { authType: "oauth-2", authActive: true, - accessTokenURL: "", - authURL: scheme.authorizationUrl ?? "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + authEndpoint: scheme.authorizationUrl ?? "", + clientID: "", + grantType: "IMPLICIT", + scopes: _schemeData.join(" "), + token: "", + }, + addTo: "HEADERS", } } else if (scheme.flow === "application") { return { authType: "oauth-2", authActive: true, - accessTokenURL: scheme.tokenUrl ?? "", - authURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + authEndpoint: scheme.tokenUrl ?? "", + clientID: "", + clientSecret: "", + grantType: "CLIENT_CREDENTIALS", + scopes: _schemeData.join(" "), + token: "", + }, + addTo: "HEADERS", } } else if (scheme.flow === "password") { return { authType: "oauth-2", authActive: true, - accessTokenURL: scheme.tokenUrl ?? "", - authURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "PASSWORD", + authEndpoint: scheme.tokenUrl ?? "", + clientID: "", + clientSecret: "", + password: "", + scopes: _schemeData.join(" "), + token: "", + username: "", + }, + addTo: "HEADERS", } } return { authType: "oauth-2", authActive: true, - accessTokenURL: "", - authURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + authEndpoint: "", + clientID: "", + clientSecret: "", + grantType: "AUTHORIZATION_CODE", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: "", + }, + addTo: "HEADERS", } } diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts index c9c9d8eb3..07c21a923 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts @@ -162,25 +162,36 @@ const getHoppReqAuth = (item: Item): HoppRESTAuth => { ), } } else if (auth.type === "oauth2") { + const accessTokenURL = replacePMVarTemplating( + getVariableValue(auth.oauth2, "accessTokenUrl") ?? "" + ) + const authURL = replacePMVarTemplating( + getVariableValue(auth.oauth2, "authUrl") ?? "" + ) + const clientId = replacePMVarTemplating( + getVariableValue(auth.oauth2, "clientId") ?? "" + ) + const scope = replacePMVarTemplating( + getVariableValue(auth.oauth2, "scope") ?? "" + ) + const token = replacePMVarTemplating( + getVariableValue(auth.oauth2, "accessToken") ?? "" + ) + return { authType: "oauth-2", authActive: true, - accessTokenURL: replacePMVarTemplating( - getVariableValue(auth.oauth2, "accessTokenUrl") ?? "" - ), - authURL: replacePMVarTemplating( - getVariableValue(auth.oauth2, "authUrl") ?? "" - ), - clientID: replacePMVarTemplating( - getVariableValue(auth.oauth2, "clientId") ?? "" - ), - scope: replacePMVarTemplating( - getVariableValue(auth.oauth2, "scope") ?? "" - ), - token: replacePMVarTemplating( - getVariableValue(auth.oauth2, "accessToken") ?? "" - ), - oidcDiscoveryURL: "", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: authURL, + clientID: clientId, + scopes: scope, + token: token, + tokenEndpoint: accessTokenURL, + clientSecret: "", + isPKCE: false, + }, + addTo: "HEADERS", } } diff --git a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts index 6b3721ff2..1e331570f 100644 --- a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts +++ b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts @@ -82,16 +82,17 @@ export const getComputedAuthHeaders = ( }) } else if ( request.auth.authType === "bearer" || - request.auth.authType === "oauth-2" + (request.auth.authType === "oauth-2" && request.auth.addTo === "HEADERS") ) { + const token = + request.auth.authType === "bearer" + ? request.auth.token + : request.auth.grantTypeInfo.token + headers.push({ active: true, key: "Authorization", - value: `Bearer ${ - parse - ? parseTemplateString(request.auth.token, envVars) - : request.auth.token - }`, + value: `Bearer ${parse ? parseTemplateString(token, envVars) : token}`, }) } else if (request.auth.authType === "api-key") { const { key, addTo } = request.auth @@ -196,17 +197,40 @@ export const getComputedParams = ( ): ComputedParam[] => { // When this gets complex, its best to split this function off (like with getComputedHeaders) // API-key auth can be added to query params - if (!req.auth || !req.auth.authActive) return [] - if (req.auth.authType !== "api-key") return [] - if (req.auth.addTo !== "Query params") return [] + if (!req.auth || !req.auth.authActive) { + return [] + } + + if (req.auth.authType !== "api-key" && req.auth.authType !== "oauth-2") { + return [] + } + + if (req.auth.addTo !== "QUERY_PARAMS") { + return [] + } + + if (req.auth.authType === "api-key") { + return [ + { + source: "auth" as const, + param: { + active: true, + key: parseTemplateString(req.auth.key, envVars), + value: parseTemplateString(req.auth.value, envVars), + }, + }, + ] + } + + const { grantTypeInfo } = req.auth return [ { source: "auth", param: { active: true, - key: parseTemplateString(req.auth.key, envVars), - value: parseTemplateString(req.auth.value, envVars), + key: "access_token", + value: parseTemplateString(grantTypeInfo.token, envVars), }, }, ] diff --git a/packages/hoppscotch-common/src/pages/oauth.vue b/packages/hoppscotch-common/src/pages/oauth.vue index 14d3d766e..912d7848e 100644 --- a/packages/hoppscotch-common/src/pages/oauth.vue +++ b/packages/hoppscotch-common/src/pages/oauth.vue @@ -5,23 +5,31 @@ diff --git a/packages/hoppscotch-common/src/platform/std/interceptors/extension.ts b/packages/hoppscotch-common/src/platform/std/interceptors/extension.ts index a6095c0bc..64395b124 100644 --- a/packages/hoppscotch-common/src/platform/std/interceptors/extension.ts +++ b/packages/hoppscotch-common/src/platform/std/interceptors/extension.ts @@ -231,6 +231,7 @@ export class ExtensionInterceptorService try { const result = await extensionHook.sendRequest({ ...req, + headers: req.headers ?? {}, wantsBinary: true, }) diff --git a/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts b/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts new file mode 100644 index 000000000..f46db7212 --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts @@ -0,0 +1,293 @@ +import { PersistenceService } from "~/services/persistence" +import { + OauthAuthService, + PersistedOAuthConfig, + createFlowConfig, + decodeResponseAsJSON, + generateRandomString, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import { InterceptorService } from "~/services/interceptor.service" +import { AuthCodeGrantTypeParams } from "@hoppscotch/data" + +const persistenceService = getService(PersistenceService) +const interceptorService = getService(InterceptorService) + +const AuthCodeOauthFlowParamsSchema = AuthCodeGrantTypeParams.pick({ + authEndpoint: true, + tokenEndpoint: true, + clientID: true, + clientSecret: true, + scopes: true, + isPKCE: true, + codeVerifierMethod: true, +}) + .refine( + (params) => { + return ( + params.authEndpoint.length >= 1 && + params.tokenEndpoint.length >= 1 && + params.clientID.length >= 1 && + params.clientSecret.length >= 1 && + (!params.scopes || params.scopes.trim().length >= 1) + ) + }, + { + message: "Minimum length requirement not met for one or more parameters", + } + ) + .refine((params) => (params.isPKCE ? !!params.codeVerifierMethod : true), { + message: "codeVerifierMethod is required when using PKCE", + path: ["codeVerifierMethod"], + }) + +export type AuthCodeOauthFlowParams = z.infer< + typeof AuthCodeOauthFlowParamsSchema +> + +export const getDefaultAuthCodeOauthFlowParams = + (): AuthCodeOauthFlowParams => ({ + authEndpoint: "", + tokenEndpoint: "", + clientID: "", + clientSecret: "", + scopes: undefined, + isPKCE: false, + codeVerifierMethod: "S256", + }) + +const initAuthCodeOauthFlow = async ({ + tokenEndpoint, + clientID, + clientSecret, + scopes, + authEndpoint, + isPKCE, + codeVerifierMethod, +}: AuthCodeOauthFlowParams) => { + const state = generateRandomString() + + let codeVerifier: string | undefined + let codeChallenge: string | undefined + + if (isPKCE) { + codeVerifier = generateCodeVerifier() + codeChallenge = await generateCodeChallenge( + codeVerifier, + codeVerifierMethod + ) + } + + let oauthTempConfig: { + state: string + grant_type: "AUTHORIZATION_CODE" + authEndpoint: string + tokenEndpoint: string + clientSecret: string + clientID: string + isPKCE: boolean + codeVerifier?: string + codeVerifierMethod?: string + codeChallenge?: string + scopes?: string + } = { + state, + grant_type: "AUTHORIZATION_CODE", + authEndpoint, + tokenEndpoint, + clientSecret, + clientID, + isPKCE, + codeVerifierMethod, + scopes, + } + + if (codeVerifier && codeChallenge) { + oauthTempConfig = { + ...oauthTempConfig, + codeVerifier, + codeChallenge, + } + } + + const localOAuthTempConfig = + persistenceService.getLocalConfig("oauth_temp_config") + + const persistedOAuthConfig: PersistedOAuthConfig = localOAuthTempConfig + ? { ...JSON.parse(localOAuthTempConfig) } + : {} + + const { grant_type, ...rest } = oauthTempConfig + + // persist the state so we can compare it when we get redirected back + // also persist the grant_type,tokenEndpoint and clientSecret so we can use them when we get redirected back + persistenceService.setLocalConfig( + "oauth_temp_config", + JSON.stringify({ + ...persistedOAuthConfig, + fields: rest, + grant_type, + }) + ) + + let url: URL + + try { + url = new URL(authEndpoint) + } catch (e) { + return E.left("INVALID_AUTH_ENDPOINT") + } + + url.searchParams.set("grant_type", "authorization_code") + url.searchParams.set("client_id", clientID) + url.searchParams.set("state", state) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", OauthAuthService.redirectURI) + + if (scopes) url.searchParams.set("scope", scopes) + + if (codeVerifierMethod && codeChallenge) { + url.searchParams.set("code_challenge", codeChallenge) + url.searchParams.set("code_challenge_method", codeVerifierMethod) + } + + // Redirect to the authorization server + window.location.assign(url.toString()) + + return E.right(undefined) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + + const code = params.get("code") + const state = params.get("state") + const error = params.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!code) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + source: z.optional(z.string()), + state: z.string(), + tokenEndpoint: z.string(), + clientSecret: z.string(), + clientID: z.string(), + codeVerifier: z.string().optional(), + codeChallenge: z.string().optional(), + }) + + const decodedLocalConfig = expectedSchema.safeParse( + JSON.parse(localConfig).fields + ) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + // exchange the code for a token + const formData = new URLSearchParams() + formData.append("grant_type", "authorization_code") + formData.append("code", code) + formData.append("client_id", decodedLocalConfig.data.clientID) + formData.append("client_secret", decodedLocalConfig.data.clientSecret) + formData.append("redirect_uri", OauthAuthService.redirectURI) + + if (decodedLocalConfig.data.codeVerifier) { + formData.append("code_verifier", decodedLocalConfig.data.codeVerifier) + } + + const { response } = interceptorService.runRequest({ + url: decodedLocalConfig.data.tokenEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: formData.toString(), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = decodeResponseAsJSON(res.right) + + if (E.isLeft(responsePayload)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + responsePayload.right + ) + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +const generateCodeVerifier = () => { + const characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const length = Math.floor(Math.random() * (128 - 43 + 1)) + 43 // Random length between 43 and 128 + let codeVerifier = "" + + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * characters.length) + codeVerifier += characters[randomIndex] + } + + return codeVerifier +} + +const generateCodeChallenge = async ( + codeVerifier: string, + strategy: AuthCodeOauthFlowParams["codeVerifierMethod"] +) => { + if (strategy === "plain") { + return codeVerifier + } + + const encoder = new TextEncoder() + const data = encoder.encode(codeVerifier) + + const buffer = await crypto.subtle.digest("SHA-256", data) + + return encodeArrayBufferAsUrlEncodedBase64(buffer) +} + +const encodeArrayBufferAsUrlEncodedBase64 = (buffer: ArrayBuffer) => { + const hashArray = Array.from(new Uint8Array(buffer)) + const hashBase64URL = btoa(String.fromCharCode(...hashArray)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_") + + return hashBase64URL +} + +export default createFlowConfig( + "AUTHORIZATION_CODE" as const, + AuthCodeOauthFlowParamsSchema, + initAuthCodeOauthFlow, + handleRedirectForAuthCodeOauthFlow +) diff --git a/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts b/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts new file mode 100644 index 000000000..9582b8236 --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts @@ -0,0 +1,183 @@ +import { + OauthAuthService, + createFlowConfig, + decodeResponseAsJSON, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import { InterceptorService } from "~/services/interceptor.service" +import { useToast } from "~/composables/toast" +import { ClientCredentialsGrantTypeParams } from "@hoppscotch/data" + +const interceptorService = getService(InterceptorService) + +const ClientCredentialsFlowParamsSchema = ClientCredentialsGrantTypeParams.pick( + { + authEndpoint: true, + clientID: true, + clientSecret: true, + scopes: true, + } +).refine( + (params) => { + return ( + params.authEndpoint.length >= 1 && + params.clientID.length >= 1 && + params.clientSecret.length >= 1 && + (!params.scopes || params.scopes.length >= 1) + ) + }, + { + message: "Minimum length requirement not met for one or more parameters", + } +) + +export type ClientCredentialsFlowParams = z.infer< + typeof ClientCredentialsFlowParamsSchema +> + +export const getDefaultClientCredentialsFlowParams = + (): ClientCredentialsFlowParams => ({ + authEndpoint: "", + clientID: "", + clientSecret: "", + scopes: undefined, + }) + +const initClientCredentialsOAuthFlow = async ({ + clientID, + clientSecret, + scopes, + authEndpoint, +}: ClientCredentialsFlowParams) => { + const toast = useToast() + + const formData = new URLSearchParams() + formData.append("grant_type", "client_credentials") + formData.append("client_id", clientID) + formData.append("client_secret", clientSecret) + + if (scopes) { + formData.append("scope", scopes) + } + + const { response } = interceptorService.runRequest({ + url: authEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: formData.toString(), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = decodeResponseAsJSON(res.right) + + if (E.isLeft(responsePayload)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + responsePayload.right + ) + + if (!parsedTokenResponse.success) { + toast.error("AUTH_TOKEN_REQUEST_INVALID_RESPONSE") + } + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + + const code = params.get("code") + const state = params.get("state") + const error = params.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!code) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + state: z.string(), + tokenEndpoint: z.string(), + clientSecret: z.string(), + clientID: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse(JSON.parse(localConfig)) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + // exchange the code for a token + const formData = new URLSearchParams() + formData.append("code", code) + formData.append("client_id", decodedLocalConfig.data.clientID) + formData.append("client_secret", decodedLocalConfig.data.clientSecret) + formData.append("redirect_uri", OauthAuthService.redirectURI) + + const { response } = interceptorService.runRequest({ + url: decodedLocalConfig.data.tokenEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: formData.toString(), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = new TextDecoder("utf-8") + .decode(res.right.data as any) + .replaceAll("\x00", "") + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + JSON.parse(responsePayload) + ) + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +export default createFlowConfig( + "CLIENT_CREDENTIALS" as const, + ClientCredentialsFlowParamsSchema, + initClientCredentialsOAuthFlow, + handleRedirectForAuthCodeOauthFlow +) diff --git a/packages/hoppscotch-common/src/services/oauth/flows/implicit.ts b/packages/hoppscotch-common/src/services/oauth/flows/implicit.ts new file mode 100644 index 000000000..08293c604 --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/implicit.ts @@ -0,0 +1,135 @@ +import { PersistenceService } from "~/services/persistence" +import { + OauthAuthService, + PersistedOAuthConfig, + createFlowConfig, + generateRandomString, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import { ImplicitOauthFlowParams } from "@hoppscotch/data" + +const persistenceService = getService(PersistenceService) + +const ImplicitOauthFlowParamsSchema = ImplicitOauthFlowParams.pick({ + authEndpoint: true, + clientID: true, + scopes: true, +}).refine((params) => { + return ( + params.authEndpoint.length >= 1 && + params.clientID.length >= 1 && + (params.scopes === undefined || params.scopes.length >= 1) + ) +}) + +export type ImplicitOauthFlowParams = z.infer< + typeof ImplicitOauthFlowParamsSchema +> + +export const getDefaultImplicitOauthFlowParams = + (): ImplicitOauthFlowParams => ({ + authEndpoint: "", + clientID: "", + scopes: undefined, + }) + +const initImplicitOauthFlow = async ({ + clientID, + scopes, + authEndpoint, +}: ImplicitOauthFlowParams) => { + const state = generateRandomString() + + const localOAuthTempConfig = + persistenceService.getLocalConfig("oauth_temp_config") + + const persistedOAuthConfig: PersistedOAuthConfig = localOAuthTempConfig + ? { ...JSON.parse(localOAuthTempConfig) } + : {} + + // Persist the necessary information for retrieval while getting redirected back + persistenceService.setLocalConfig( + "oauth_temp_config", + JSON.stringify({ + ...persistedOAuthConfig, + fields: { + clientID, + authEndpoint, + scopes, + state, + }, + grant_type: "IMPLICIT", + }) + ) + + let url: URL + + try { + url = new URL(authEndpoint) + } catch { + return E.left("INVALID_AUTH_ENDPOINT") + } + + url.searchParams.set("client_id", clientID) + url.searchParams.set("state", state) + url.searchParams.set("response_type", "token") + url.searchParams.set("redirect_uri", OauthAuthService.redirectURI) + + if (scopes) url.searchParams.set("scope", scopes) + + // Redirect to the authorization server + window.location.assign(url.toString()) + + return E.right(undefined) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + const paramsFromHash = new URLSearchParams(window.location.hash.substring(1)) + + const accessToken = + params.get("access_token") || paramsFromHash.get("access_token") + const state = params.get("state") || paramsFromHash.get("state") + const error = params.get("error") || paramsFromHash.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!accessToken) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + source: z.optional(z.string()), + state: z.string(), + clientID: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse( + JSON.parse(localConfig).fields + ) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + return E.right({ + access_token: accessToken, + }) +} + +export default createFlowConfig( + "IMPLICIT" as const, + ImplicitOauthFlowParamsSchema, + initImplicitOauthFlow, + handleRedirectForAuthCodeOauthFlow +) diff --git a/packages/hoppscotch-common/src/services/oauth/flows/password.ts b/packages/hoppscotch-common/src/services/oauth/flows/password.ts new file mode 100644 index 000000000..d572b6471 --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/password.ts @@ -0,0 +1,189 @@ +import { + OauthAuthService, + createFlowConfig, + decodeResponseAsJSON, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import { InterceptorService } from "~/services/interceptor.service" +import { useToast } from "~/composables/toast" +import { PasswordGrantTypeParams } from "@hoppscotch/data" + +const interceptorService = getService(InterceptorService) + +const PasswordFlowParamsSchema = PasswordGrantTypeParams.pick({ + authEndpoint: true, + clientID: true, + clientSecret: true, + scopes: true, + username: true, + password: true, +}).refine( + (params) => { + return ( + params.authEndpoint.length >= 1 && + params.clientID.length >= 1 && + params.clientSecret.length >= 1 && + params.username.length >= 1 && + params.password.length >= 1 && + (!params.scopes || params.scopes.length >= 1) + ) + }, + { + message: "Minimum length requirement not met for one or more parameters", + } +) + +export type PasswordFlowParams = z.infer + +export const getDefaultPasswordFlowParams = (): PasswordFlowParams => ({ + authEndpoint: "", + clientID: "", + clientSecret: "", + scopes: undefined, + username: "", + password: "", +}) + +const initPasswordOauthFlow = async ({ + password, + username, + clientID, + clientSecret, + scopes, + authEndpoint, +}: PasswordFlowParams) => { + const toast = useToast() + + const formData = new URLSearchParams() + formData.append("grant_type", "password") + formData.append("client_id", clientID) + formData.append("client_secret", clientSecret) + formData.append("username", username) + formData.append("password", password) + + if (scopes) { + formData.append("scope", scopes) + } + + const { response } = interceptorService.runRequest({ + url: authEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: formData.toString(), + }) + + const res = await response + + if (E.isLeft(res) || res.right.status !== 200) { + toast.error("AUTH_TOKEN_REQUEST_FAILED") + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = new TextDecoder("utf-8") + .decode(res.right.data as any) + .replaceAll("\x00", "") + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + JSON.parse(responsePayload) + ) + + if (!parsedTokenResponse.success) { + toast.error("AUTH_TOKEN_REQUEST_INVALID_RESPONSE") + } + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + + const code = params.get("code") + const state = params.get("state") + const error = params.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!code) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + state: z.string(), + tokenEndpoint: z.string(), + clientSecret: z.string(), + clientID: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse(JSON.parse(localConfig)) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + // exchange the code for a token + const formData = new URLSearchParams() + formData.append("code", code) + formData.append("client_id", decodedLocalConfig.data.clientID) + formData.append("client_secret", decodedLocalConfig.data.clientSecret) + formData.append("redirect_uri", OauthAuthService.redirectURI) + + const { response } = interceptorService.runRequest({ + url: decodedLocalConfig.data.tokenEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: formData.toString(), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = decodeResponseAsJSON(res.right) + + if (E.isLeft(responsePayload)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + responsePayload.right + ) + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +export default createFlowConfig( + "PASSWORD" as const, + PasswordFlowParamsSchema, + initPasswordOauthFlow, + handleRedirectForAuthCodeOauthFlow +) diff --git a/packages/hoppscotch-common/src/services/oauth/oauth.service.ts b/packages/hoppscotch-common/src/services/oauth/oauth.service.ts new file mode 100644 index 000000000..e0b01778b --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/oauth.service.ts @@ -0,0 +1,124 @@ +import { Service } from "dioc" +import { PersistenceService } from "../persistence" +import { ZodType, z } from "zod" +import * as E from "fp-ts/Either" +import authCode, { AuthCodeOauthFlowParams } from "./flows/authCode" +import implicit, { ImplicitOauthFlowParams } from "./flows/implicit" +import { getService } from "~/modules/dioc" +import { HoppCollection } from "@hoppscotch/data" +import { TeamCollection } from "~/helpers/backend/graphql" + +export type PersistedOAuthConfig = { + source: "REST" | "GraphQL" + context?: { + type: "collection-properties" | "request-tab" + metadata: { + collection?: HoppCollection | TeamCollection + collectionID?: string + } + } + grant_type: string + fields?: (AuthCodeOauthFlowParams | ImplicitOauthFlowParams) & { + state: string + } + token?: string +} + +const persistenceService = getService(PersistenceService) + +export const grantTypesInvolvingRedirect = ["AUTHORIZATION_CODE", "IMPLICIT"] + +export const routeOAuthRedirect = async () => { + // get the temp data from the local storage + const localOAuthTempConfig = + persistenceService.getLocalConfig("oauth_temp_config") + + if (!localOAuthTempConfig) { + return E.left("INVALID_STATE") + } + + const expectedSchema = z.object({ + source: z.optional(z.string()), + grant_type: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse( + JSON.parse(localOAuthTempConfig) + ) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_STATE") + } + + // route the request to the correct flow + const flowConfig = [authCode, implicit].find( + (flow) => flow.flow === decodedLocalConfig.data.grant_type + ) + + if (!flowConfig) { + return E.left("INVALID_STATE") + } + + return flowConfig?.onRedirectReceived(localOAuthTempConfig) +} + +export function createFlowConfig< + Flow extends string, + AuthParams extends Record, + InitFuncReturnObject extends Record, +>( + flow: Flow, + params: ZodType, + init: ( + params: AuthParams + ) => + | E.Either + | Promise> + | E.Either + | Promise>, + onRedirectReceived: (localConfig: string) => Promise< + E.Either< + string, + { + access_token: string + } + > + > +) { + return { + flow, + params, + init, + onRedirectReceived, + } +} + +export const decodeResponseAsJSON = (response: { data: any }) => { + try { + const responsePayload = new TextDecoder("utf-8") + .decode(response.data as any) + .replaceAll("\x00", "") + + return E.right(JSON.parse(responsePayload) as Record) + } catch (error) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } +} + +export class OauthAuthService extends Service { + public static readonly ID = "OAUTH_AUTH_SERVICE" + + static redirectURI = `${window.location.origin}/oauth` + + constructor() { + super() + } +} + +export const generateRandomString = () => { + const length = 64 + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + const values = crypto.getRandomValues(new Uint8Array(length)) + return values.reduce((acc, x) => acc + possible[x % possible.length], "") +} diff --git a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts index 050237b2b..12fe74e27 100644 --- a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts @@ -25,7 +25,7 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ folders: [], requests: [ { - v: "2", + v: "3", endpoint: "https://echo.hoppscotch.io", name: "Echo test", params: [], @@ -50,7 +50,7 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ folders: [], requests: [ { - v: 2, + v: 3, name: "Echo test", url: "https://echo.hoppscotch.io/graphql", headers: [], @@ -138,7 +138,7 @@ export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [ preRequestScript: "", testScript: "", requestVariables: [], - v: "2", + v: "3", }, responseMeta: { duration: 807, statusCode: 200 }, star: false, @@ -150,7 +150,7 @@ export const GQL_HISTORY_MOCK: GQLHistoryEntry[] = [ { v: 1, request: { - v: 2, + v: 3, name: "Untitled", url: "https://echo.hoppscotch.io/graphql", query: "query Request { url }", @@ -171,7 +171,7 @@ export const GQL_TAB_STATE_MOCK: PersistableTabState = { tabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc", doc: { request: { - v: 2, + v: 3, name: "Untitled", url: "https://echo.hoppscotch.io/graphql", headers: [], @@ -194,7 +194,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState = { tabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa", doc: { request: { - v: "2", + v: "3", endpoint: "https://echo.hoppscotch.io", name: "Echo test", params: [], diff --git a/packages/hoppscotch-data/src/graphql/index.ts b/packages/hoppscotch-data/src/graphql/index.ts index ab5823541..c314a3437 100644 --- a/packages/hoppscotch-data/src/graphql/index.ts +++ b/packages/hoppscotch-data/src/graphql/index.ts @@ -2,29 +2,32 @@ import { InferredEntity, createVersionedEntity } from "verzod" import { z } from "zod" import V1_VERSION from "./v/1" import V2_VERSION from "./v/2" +import V3_VERSION from "./v/3" export { GQLHeader } from "./v/1" export { - HoppGQLAuth, HoppGQLAuthAPIKey, HoppGQLAuthBasic, HoppGQLAuthBearer, HoppGQLAuthNone, - HoppGQLAuthOAuth2, HoppGQLAuthInherit, } from "./v/2" -export const GQL_REQ_SCHEMA_VERSION = 2 +export { HoppGQLAuth } from "./v/3" +export { HoppGQLAuthOAuth2 } from "./v/3" + +export const GQL_REQ_SCHEMA_VERSION = 3 const versionedObject = z.object({ v: z.number(), }) export const HoppGQLRequest = createVersionedEntity({ - latestVersion: 2, + latestVersion: 3, versionMap: { 1: V1_VERSION, 2: V2_VERSION, + 3: V3_VERSION, }, getVersion(x) { const result = versionedObject.safeParse(x) diff --git a/packages/hoppscotch-data/src/graphql/v/2.ts b/packages/hoppscotch-data/src/graphql/v/2.ts index 50ef6373f..e4392a314 100644 --- a/packages/hoppscotch-data/src/graphql/v/2.ts +++ b/packages/hoppscotch-data/src/graphql/v/2.ts @@ -71,7 +71,7 @@ export const HoppGQLAuth = z export type HoppGQLAuth = z.infer -const V2_SCHEMA = z.object({ +export const V2_SCHEMA = z.object({ id: z.optional(z.string()), v: z.literal(2), diff --git a/packages/hoppscotch-data/src/graphql/v/3.ts b/packages/hoppscotch-data/src/graphql/v/3.ts new file mode 100644 index 000000000..60fb87ecf --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/3.ts @@ -0,0 +1,77 @@ +import { z } from "zod" + +import { defineVersion } from "verzod" + +import { HoppRESTAuthOAuth2 } from "../../rest" +import { + HoppGQLAuthAPIKey, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, + V2_SCHEMA, +} from "./2" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest" + +export type HoppGqlAuthOAuth2 = z.infer + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthInherit, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthAPIKey, + HoppRESTAuthOAuth2, // both rest and gql have the same auth type for oauth2 + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V3_SCHEMA = V2_SCHEMA.extend({ + v: z.literal(3), + auth: HoppGQLAuth, +}) + +export default defineVersion({ + initial: false, + schema: V3_SCHEMA, + up(old: z.infer) { + if (old.auth.authType === "oauth-2") { + const { token, accessTokenURL, scope, clientID, authURL } = old.auth + + return { + ...old, + v: 3 as const, + auth: { + ...old.auth, + authType: "oauth-2" as const, + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE" as const, + authEndpoint: authURL, + tokenEndpoint: accessTokenURL, + clientID: clientID, + clientSecret: "", + scopes: scope, + isPKCE: false, + token, + }, + addTo: "HEADERS" as const, + }, + } + } + + return { + ...old, + v: 3 as const, + auth: { + ...old.auth, + }, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/index.ts b/packages/hoppscotch-data/src/rest/index.ts index 72390ec5b..8fea04d40 100644 --- a/packages/hoppscotch-data/src/rest/index.ts +++ b/packages/hoppscotch-data/src/rest/index.ts @@ -4,32 +4,39 @@ import cloneDeep from "lodash/cloneDeep" import V0_VERSION from "./v/0" import V1_VERSION from "./v/1" import V2_VERSION from "./v/2" +import V3_VERSION from "./v/3" import { createVersionedEntity, InferredEntity } from "verzod" import { lodashIsEqualEq, mapThenEq, undefinedEq } from "../utils/eq" -import { - HoppRESTAuth, - HoppRESTReqBody, - HoppRESTHeaders, - HoppRESTParams, -} from "./v/1" + +import { HoppRESTReqBody, HoppRESTHeaders, HoppRESTParams } from "./v/1" + +import { HoppRESTAuth } from "./v/3" import { HoppRESTRequestVariables } from "./v/2" import { z } from "zod" export * from "./content-types" + export { FormDataKeyValue, HoppRESTReqBodyFormData, - HoppRESTAuth, HoppRESTAuthAPIKey, HoppRESTAuthBasic, HoppRESTAuthInherit, HoppRESTAuthBearer, HoppRESTAuthNone, - HoppRESTAuthOAuth2, HoppRESTReqBody, HoppRESTHeaders, } from "./v/1" +export { + HoppRESTAuth, + HoppRESTAuthOAuth2, + AuthCodeGrantTypeParams, + ClientCredentialsGrantTypeParams, + ImplicitOauthFlowParams, + PasswordGrantTypeParams, +} from "./v/3" + export { HoppRESTRequestVariables } from "./v/2" const versionedObject = z.object({ @@ -38,11 +45,12 @@ const versionedObject = z.object({ }) export const HoppRESTRequest = createVersionedEntity({ - latestVersion: 2, + latestVersion: 3, versionMap: { 0: V0_VERSION, 1: V1_VERSION, 2: V2_VERSION, + 3: V3_VERSION, }, getVersion(data) { // For V1 onwards we have the v string storing the number @@ -84,7 +92,7 @@ const HoppRESTRequestEq = Eq.struct({ ), }) -export const RESTReqSchemaVersion = "2" +export const RESTReqSchemaVersion = "3" export type HoppRESTParam = HoppRESTRequest["params"][number] export type HoppRESTHeader = HoppRESTRequest["headers"][number] @@ -179,7 +187,7 @@ export function makeRESTRequest( export function getDefaultRESTRequest(): HoppRESTRequest { return { - v: "2", + v: "3", endpoint: "https://echo.hoppscotch.io", name: "Untitled", params: [], diff --git a/packages/hoppscotch-data/src/rest/v/2.ts b/packages/hoppscotch-data/src/rest/v/2.ts index 894a37271..db49ebfb4 100644 --- a/packages/hoppscotch-data/src/rest/v/2.ts +++ b/packages/hoppscotch-data/src/rest/v/2.ts @@ -18,7 +18,7 @@ export const HoppRESTRequestVariables = z.array( export type HoppRESTRequestVariables = z.infer -const V2_SCHEMA = V1_SCHEMA.extend({ +export const V2_SCHEMA = V1_SCHEMA.extend({ v: z.literal("2"), requestVariables: HoppRESTRequestVariables, }) diff --git a/packages/hoppscotch-data/src/rest/v/3.ts b/packages/hoppscotch-data/src/rest/v/3.ts new file mode 100644 index 000000000..2a2a7bb36 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/3.ts @@ -0,0 +1,127 @@ +import { z } from "zod" +import { + HoppRESTAuthAPIKey, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "./1" +import { V2_SCHEMA } from "./2" + +import { defineVersion } from "verzod" + +export const AuthCodeGrantTypeParams = z.object({ + grantType: z.literal("AUTHORIZATION_CODE"), + authEndpoint: z.string().trim(), + tokenEndpoint: z.string().trim(), + clientID: z.string().trim(), + clientSecret: z.string().trim(), + scopes: z.string().trim().optional(), + token: z.string().catch(""), + isPKCE: z.boolean(), + codeVerifierMethod: z + .union([z.literal("plain"), z.literal("S256")]) + .optional(), +}) + +export const ClientCredentialsGrantTypeParams = z.object({ + grantType: z.literal("CLIENT_CREDENTIALS"), + authEndpoint: z.string().trim(), + clientID: z.string().trim(), + clientSecret: z.string().trim(), + scopes: z.string().trim().optional(), + token: z.string().catch(""), +}) + +export const PasswordGrantTypeParams = z.object({ + grantType: z.literal("PASSWORD"), + authEndpoint: z.string().trim(), + clientID: z.string().trim(), + clientSecret: z.string().trim(), + scopes: z.string().trim().optional(), + username: z.string().trim(), + password: z.string().trim(), + token: z.string().catch(""), +}) + +export const ImplicitOauthFlowParams = z.object({ + grantType: z.literal("IMPLICIT"), + authEndpoint: z.string().trim(), + clientID: z.string().trim(), + scopes: z.string().trim().optional(), + token: z.string().catch(""), +}) + +export const HoppRESTAuthOAuth2 = z.object({ + authType: z.literal("oauth-2"), + grantTypeInfo: z.discriminatedUnion("grantType", [ + AuthCodeGrantTypeParams, + ClientCredentialsGrantTypeParams, + PasswordGrantTypeParams, + ImplicitOauthFlowParams, + ]), + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), +}) + +export type HoppRESTAuthOAuth2 = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer + +// V2_SCHEMA has one change in HoppRESTAuthOAuth2, we'll add the grant_type field +export const V3_SCHEMA = V2_SCHEMA.extend({ + v: z.literal("3"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + initial: false, + schema: V3_SCHEMA, + up(old: z.infer) { + if (old.auth.authType === "oauth-2") { + const { token, accessTokenURL, scope, clientID, authURL } = old.auth + + return { + ...old, + v: "3" as const, + auth: { + ...old.auth, + authType: "oauth-2" as const, + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE" as const, + authEndpoint: authURL, + tokenEndpoint: accessTokenURL, + clientID: clientID, + clientSecret: "", + scopes: scope, + isPKCE: false, + token, + }, + addTo: "HEADERS" as const, + }, + } + } + + return { + ...old, + v: "3" as const, + auth: { + ...old.auth, + }, + } + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c50167b8..0c943dc33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24543,3 +24543,4 @@ packages: /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false + \ No newline at end of file