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 22f4024cb..91b6659af 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts @@ -159,6 +159,30 @@ describe("hopp test [options] ", () => { expect(error).toBeNull(); }); + + describe("OAuth 2 Authorization type with Authorization Code Grant Type", () => { + test("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => { + const args = `test ${getTestJsonFilePath( + "oauth2-auth-code-coll.json", + "collection" + )}`; + const { error } = await runCLI(args); + + expect(error).toBeNull(); + }); + }); + + describe("multipart/form-data content type", () => { + test("Successfully derives the relevant headers based and sends the form data in the request body", async () => { + const args = `test ${getTestJsonFilePath( + "oauth2-auth-code-coll.json", + "collection" + )}`; + const { error } = await runCLI(args); + + expect(error).toBeNull(); + }); + }); }); describe("Test `hopp test --env ` command:", () => { diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/multipart-form-data-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/multipart-form-data-coll.json new file mode 100644 index 000000000..9853183bc --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/multipart-form-data-coll.json @@ -0,0 +1,55 @@ +{ + "v": 3, + "name": "Multpart form data content type - Collection", + "folders": [], + "requests": [ + { + "v": "7", + "endpoint": "https://echo.hoppscotch.io", + "name": "multipart-form-data-sample-req", + "params": [], + "headers": [], + "method": "POST", + "auth": { + "authType": "none", + "authActive": true, + "addTo": "HEADERS", + "grantTypeInfo": { + "authEndpoint": "test-authorization-endpoint", + "tokenEndpoint": "test-token-endpont", + "clientID": "test-client-id", + "clientSecret": "test-client-secret", + "isPKCE": true, + "codeVerifierMethod": "S256", + "grantType": "AUTHORIZATION_CODE", + "token": "test-token" + } + }, + "preRequestScript": "", + "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully derives the relevant headers based on the content type\", () => {\n pw.expect(pw.response.body.headers['content-type']).toInclude(\"multipart/form-data\");\n});\n\npw.test(\"Successfully sends the form data in the request body\", () => {\n // Dynamic value\n pw.expect(pw.response.body.data).toBeType(\"string\");\n});", + "body": { + "contentType": "multipart/form-data", + "body": [ + { + "key": "key1", + "value": "value1", + "active": true, + "isFile": false + }, + { + "key": "key2", + "value": [{}], + "active": true, + "isFile": true + } + ] + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/oauth2-auth-code-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/oauth2-auth-code-coll.json new file mode 100644 index 000000000..12c91fbd1 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/oauth2-auth-code-coll.json @@ -0,0 +1,72 @@ +{ + "v": 3, + "name": "OAuth2 Authorization Code Grant Type - Collection", + "folders": [], + "requests": [ + { + "v": "7", + "endpoint": "https://echo.hoppscotch.io", + "name": "oauth2-auth-code-sample-req-pass-by-headers", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "oauth-2", + "authActive": true, + "addTo": "HEADERS", + "grantTypeInfo": { + "authEndpoint": "test-authorization-endpoint", + "tokenEndpoint": "test-token-endpont", + "clientID": "test-client-id", + "clientSecret": "test-client-secret", + "isPKCE": true, + "codeVerifierMethod": "S256", + "grantType": "AUTHORIZATION_CODE", + "token": "test-token" + } + }, + "preRequestScript": "", + "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully derives Authorization header from the supplied fields\", ()=> {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBeType(\"string\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [] + }, + { + "v": "7", + "endpoint": "https://echo.hoppscotch.io", + "name": "oauth2-auth-code-sample-req-pass-by-query-params", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "oauth-2", + "authActive": true, + "addTo": "HEADERS", + "grantTypeInfo": { + "authEndpoint": "test-authorization-endpoint", + "tokenEndpoint": "test-token-endpont", + "clientID": "test-client-id", + "clientSecret": "test-client-secret", + "isPKCE": true, + "codeVerifierMethod": "S256", + "grantType": "AUTHORIZATION_CODE", + "token": "test-token" + } + }, + "preRequestScript": "", + "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully derives Authorization header from the supplied fields\", ()=> {\n pw.expect(pw.response.body.headers[\"authorization\"]).toBeType(\"string\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [] +} diff --git a/packages/hoppscotch-cli/src/__tests__/functions/request/requestRunner.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/request/requestRunner.spec.ts index e1df4a2df..7fb45aca7 100644 --- a/packages/hoppscotch-cli/src/__tests__/functions/request/requestRunner.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/functions/request/requestRunner.spec.ts @@ -15,7 +15,6 @@ describe("requestRunner", () => { }; beforeEach(() => { - SAMPLE_REQUEST_CONFIG.supported = false; SAMPLE_REQUEST_CONFIG.url = "https://example.com"; SAMPLE_REQUEST_CONFIG.method = "GET"; jest.clearAllMocks(); @@ -70,7 +69,6 @@ describe("requestRunner", () => { it("Should handle axios-error with request info.", () => { jest.spyOn(axios, "isAxiosError").mockReturnValue(true); - SAMPLE_REQUEST_CONFIG.supported = true; (axios as unknown as jest.Mock).mockRejectedValueOnce({ name: "name", message: "message", @@ -91,7 +89,6 @@ describe("requestRunner", () => { }); it("Should successfully execute.", () => { - SAMPLE_REQUEST_CONFIG.supported = true; (axios as unknown as jest.Mock).mockResolvedValue({ data: "data", status: 200, diff --git a/packages/hoppscotch-cli/src/interfaces/request.ts b/packages/hoppscotch-cli/src/interfaces/request.ts index 9eb3982f7..c8cfbd272 100644 --- a/packages/hoppscotch-cli/src/interfaces/request.ts +++ b/packages/hoppscotch-cli/src/interfaces/request.ts @@ -20,8 +20,7 @@ export interface RequestStack { * @property {boolean} supported - Boolean check for supported or unsupported requests. */ export interface RequestConfig extends AxiosRequestConfig { - supported: boolean; - displayUrl?: string + displayUrl?: string; } export interface EffectiveHoppRESTRequest extends HoppRESTRequest { @@ -32,7 +31,17 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest { */ effectiveFinalURL: string; effectiveFinalDisplayURL?: string; - effectiveFinalHeaders: { key: string; value: string; active: boolean }[]; - effectiveFinalParams: { key: string; value: string; active: boolean }[]; + effectiveFinalHeaders: { + key: string; + value: string; + active: boolean; + description: string; + }[]; + effectiveFinalParams: { + key: string; + value: string; + active: boolean; + description: string; + }[]; effectiveFinalBody: FormData | string | null; } diff --git a/packages/hoppscotch-cli/src/utils/getters.ts b/packages/hoppscotch-cli/src/utils/getters.ts index 50a7a47b0..07fe43fc2 100644 --- a/packages/hoppscotch-cli/src/utils/getters.ts +++ b/packages/hoppscotch-cli/src/utils/getters.ts @@ -72,11 +72,12 @@ export const getEffectiveFinalMetaData = ( * Selecting only non-empty and active pairs. */ A.filter(({ key, active }) => !S.isEmpty(key) && active), - A.map(({ key, value }) => { + A.map(({ key, value, description }) => { return { active: true, key: parseTemplateStringE(key, resolvedVariables), value: parseTemplateStringE(value, resolvedVariables), + description, }; }), E.fromPredicate( @@ -91,9 +92,14 @@ export const getEffectiveFinalMetaData = ( /** * Filtering and mapping only right-eithers for each key-value as [string, string]. */ - A.filterMap(({ key, value }) => + A.filterMap(({ key, value, description }) => E.isRight(key) && E.isRight(value) - ? O.some({ active: true, key: key.right, value: value.right }) + ? O.some({ + active: true, + key: key.right, + value: value.right, + description, + }) : O.none ) ) diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index 267cac46b..b8d41cf1d 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -123,12 +123,14 @@ export function getEffectiveRESTRequest( active: true, key: "Authorization", value: `Basic ${btoa(`${username}:${password}`)}`, + description: "", }); } else if (request.auth.authType === "bearer") { effectiveFinalHeaders.push({ active: true, key: "Authorization", value: `Bearer ${parseTemplateString(request.auth.token, resolvedVariables)}`, + description: "", }); } else if (request.auth.authType === "oauth-2") { const { addTo } = request.auth; @@ -138,6 +140,7 @@ export function getEffectiveRESTRequest( active: true, key: "Authorization", value: `Bearer ${parseTemplateString(request.auth.grantTypeInfo.token, resolvedVariables)}`, + description: "", }); } else if (addTo === "QUERY_PARAMS") { effectiveFinalParams.push({ @@ -147,6 +150,7 @@ export function getEffectiveRESTRequest( request.auth.grantTypeInfo.token, resolvedVariables ), + description: "", }); } } else if (request.auth.authType === "api-key") { @@ -156,12 +160,14 @@ export function getEffectiveRESTRequest( active: true, key: parseTemplateString(key, resolvedVariables), value: parseTemplateString(value, resolvedVariables), + description: "", }); } else if (addTo === "QUERY_PARAMS") { effectiveFinalParams.push({ active: true, key: parseTemplateString(key, resolvedVariables), value: parseTemplateString(value, resolvedVariables), + description: "", }); } } @@ -187,6 +193,7 @@ export function getEffectiveRESTRequest( active: true, key: "Content-Type", value: request.body.contentType, + description: "", }); } diff --git a/packages/hoppscotch-cli/src/utils/request.ts b/packages/hoppscotch-cli/src/utils/request.ts index c8e0f04fa..97bd02041 100644 --- a/packages/hoppscotch-cli/src/utils/request.ts +++ b/packages/hoppscotch-cli/src/utils/request.ts @@ -32,8 +32,6 @@ import { getDurationInSeconds, getMetaDataPairs } from "./getters"; import { preRequestScriptRunner } from "./pre-request"; import { getTestScriptParams, hasFailedTestCases, testRunner } from "./test"; -// !NOTE: The `config.supported` checks are temporary until OAuth2 and Multipart Forms are supported - /** * Processes given variable, which includes checking for secret variables * and getting value from system environment @@ -75,46 +73,20 @@ const processEnvs = (envs: Partial) => { */ export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => { const config: RequestConfig = { - supported: true, displayUrl: req.effectiveFinalDisplayURL, }; + const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest; + const reqParams = finalParams(req); const reqHeaders = finalHeaders(req); + config.url = finalEndpoint(req); config.method = req.method as Method; config.params = getMetaDataPairs(reqParams); config.headers = getMetaDataPairs(reqHeaders); - if (req.auth.authActive) { - switch (req.auth.authType) { - case "oauth-2": { - // TODO: OAuth2 Request Parsing - // !NOTE: Temporary `config.supported` check - config.supported = false; - } - default: { - break; - } - } - } - const resolvedContentType = - config.headers["Content-Type"] ?? req.body.contentType; - - if (resolvedContentType) { - switch (resolvedContentType) { - case "multipart/form-data": { - // TODO: Parse Multipart Form Data - // !NOTE: Temporary `config.supported` check - config.supported = false; - break; - } - default: { - config.data = finalBody(req); - break; - } - } - } + config.data = finalBody(req); return config; }; @@ -149,13 +121,6 @@ export const requestRunner = duration: 0, }; - // !NOTE: Temporary `config.supported` check - if ((config as RequestConfig).supported === false) { - status = 501; - runnerResponse.status = status; - runnerResponse.statusText = responseErrors[status]; - } - const end = hrtime(start); const duration = getDurationInSeconds(end); runnerResponse.duration = duration; @@ -182,10 +147,6 @@ export const requestRunner = runnerResponse.statusText = statusText; runnerResponse.status = status; runnerResponse.headers = headers; - } else if ((e.config as RequestConfig).supported === false) { - status = 501; - runnerResponse.status = status; - runnerResponse.statusText = responseErrors[status]; } else if (e.request) { return E.left(error({ code: "REQUEST_ERROR", data: E.toError(e) })); } diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 982a1488d..4ce8e8df8 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -126,6 +126,7 @@ }, "authorization": { "generate_token": "Generate Token", + "refresh_token": "Refresh Token", "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", "include_in_url": "Include in URL", "inherited_from": "Inherited {auth} from parent collection {collection} ", @@ -148,6 +149,9 @@ "token_fetched_successfully": "Token fetched successfully", "token_fetch_failed": "Failed to fetch token", "validation_failed": "Validation Failed, please check the form fields", + "no_refresh_token_present": "No Refresh Token present. Please run the token generation flow again", + "refresh_token_request_failed": "Refresh token request failed", + "token_refreshed_successfully": "Token refreshed successfully", "label_authorization_endpoint": "Authorization Endpoint", "label_client_id": "Client ID", "label_client_secret": "Client Secret", diff --git a/packages/hoppscotch-common/src/components/collections/graphql/index.vue b/packages/hoppscotch-common/src/components/collections/graphql/index.vue index 08ee563dd..7649178f3 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/index.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/index.vue @@ -255,7 +255,7 @@ onMounted(() => { return } - const { context, source, token }: PersistedOAuthConfig = + const { context, source, token, refresh_token }: PersistedOAuthConfig = JSON.parse(localOAuthTempConfig) if (source === "REST") { @@ -279,6 +279,10 @@ onMounted(() => { const grantTypeInfo = auth.grantTypeInfo grantTypeInfo && (grantTypeInfo.token = token ?? "") + + if (refresh_token && grantTypeInfo.grantType === "AUTHORIZATION_CODE") { + grantTypeInfo.refreshToken = refresh_token + } } editingProperties.value = unsavedCollectionProperties diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue index 4e22ff6b0..8ec2c4bb7 100644 --- a/packages/hoppscotch-common/src/components/collections/index.vue +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -428,7 +428,7 @@ onMounted(() => { return } - const { context, source, token }: PersistedOAuthConfig = + const { context, source, token, refresh_token }: PersistedOAuthConfig = JSON.parse(localOAuthTempConfig) if (source === "GraphQL") { @@ -452,6 +452,10 @@ onMounted(() => { const grantTypeInfo = auth.grantTypeInfo grantTypeInfo && (grantTypeInfo.token = token ?? "") + + if (refresh_token && grantTypeInfo.grantType === "AUTHORIZATION_CODE") { + grantTypeInfo.refreshToken = refresh_token + } } editingProperties.value = unsavedCollectionProperties diff --git a/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue b/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue index 326d2a878..afe17575a 100644 --- a/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue +++ b/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue @@ -165,12 +165,18 @@ -
+
+
@@ -192,6 +198,7 @@ import { getCombinedEnvVariables } from "~/helpers/preRequest" import { AggregateEnvironment } from "~/newstore/environments" import authCode, { AuthCodeOauthFlowParams, + AuthCodeOauthRefreshParams, getDefaultAuthCodeOauthFlowParams, } from "~/services/oauth/flows/authCode" import clientCredentials, { @@ -356,6 +363,48 @@ const supportedGrantTypes = [ } ) + const refreshToken = async () => { + const grantTypeInfo = auth.value.grantTypeInfo + + if (!("refreshToken" in grantTypeInfo)) { + return E.left("NO_REFRESH_TOKEN_PRESENT" as const) + } + + const refreshToken = grantTypeInfo.refreshToken + + if (!refreshToken) { + return E.left("NO_REFRESH_TOKEN_PRESENT" as const) + } + + const params: AuthCodeOauthRefreshParams = { + clientID: clientID.value, + clientSecret: clientSecret.value, + tokenEndpoint: tokenEndpoint.value, + refreshToken, + } + + const unwrappedParams = replaceTemplateStringsInObjectValues(params) + + const refreshTokenFunc = authCode.refreshToken + + if (!refreshTokenFunc) { + return E.left("REFRESH_TOKEN_FUNCTION_NOT_DEFINED" as const) + } + + const res = await refreshTokenFunc(unwrappedParams) + + if (E.isLeft(res)) { + return E.left("OAUTH_REFRESH_TOKEN_FAILED" as const) + } + + setAccessTokenInActiveContext( + res.right.access_token, + res.right.refresh_token + ) + + return E.right(undefined) + } + const runAction = () => { const params: AuthCodeOauthFlowParams = { authEndpoint: authEndpoint.value, @@ -456,6 +505,7 @@ const supportedGrantTypes = [ return { runAction, + refreshToken, elements, } }), @@ -854,13 +904,28 @@ const selectedGrantType = computed(() => { ) }) -const setAccessTokenInActiveContext = (accessToken?: string) => { +const setAccessTokenInActiveContext = ( + accessToken?: string, + refreshToken?: string +) => { if (props.isCollectionProperty && accessToken) { auth.value.grantTypeInfo = { ...auth.value.grantTypeInfo, token: accessToken, } + // set the refresh token if provided + // we also make sure the grantTypes supporting refreshTokens are + if ( + refreshToken && + auth.value.grantTypeInfo.grantType === "AUTHORIZATION_CODE" + ) { + auth.value.grantTypeInfo = { + ...auth.value.grantTypeInfo, + refreshToken, + } + } + return } @@ -874,6 +939,16 @@ const setAccessTokenInActiveContext = (accessToken?: string) => { tabService.currentActiveTab.value.document.request.auth.grantTypeInfo.token = accessToken } + + if ( + refreshToken && + tabService.currentActiveTab.value.document.request.auth.authType === + "oauth-2" + ) { + // @ts-expect-error - todo: narrow the grantType to only supporting refresh tokens + tabService.currentActiveTab.value.document.request.auth.grantTypeInfo.refreshToken = + refreshToken + } } const changeSelectedGrantType = ( @@ -905,10 +980,53 @@ const runAction = computed(() => { return selectedGrantType.value?.formElements.value?.runAction }) +const runTokenRefresh = computed(() => { + // the only grant type that supports refresh tokens is the authCode grant type + if (selectedGrantType.value?.id === "authCode") { + return selectedGrantType.value?.formElements.value?.refreshToken + } + + return null +}) + const currentOAuthGrantTypeFormElements = computed(() => { return selectedGrantType.value?.formElements.value?.elements.value }) +const refreshOauthToken = async () => { + if (!runTokenRefresh.value) { + return + } + + const res = await runTokenRefresh.value() + + if (E.isLeft(res)) { + const errorMessages = { + NO_REFRESH_TOKEN_PRESENT: t( + "authorization.oauth.no_refresh_token_present" + ), + REFRESH_TOKEN_FUNCTION_NOT_DEFINED: t( + "authorization.oauth.refresh_token_request_failed" + ), + OAUTH_REFRESH_TOKEN_FAILED: t( + "authorization.oauth.refresh_token_request_failed" + ), + } + + const isKnownError = res.left in errorMessages + + if (!isKnownError) { + toast.error(t("authorization.oauth.refresh_token_failed")) + return + } + + toast.error(errorMessages[res.left]) + return + } + + toast.success(t("authorization.oauth.token_refreshed_successfully")) +} + const generateOAuthToken = async () => { if ( grantTypesInvolvingRedirect.includes(auth.value.grantTypeInfo.grantType) diff --git a/packages/hoppscotch-common/src/pages/oauth.vue b/packages/hoppscotch-common/src/pages/oauth.vue index ac6aa3fce..5a94bc6d0 100644 --- a/packages/hoppscotch-common/src/pages/oauth.vue +++ b/packages/hoppscotch-common/src/pages/oauth.vue @@ -97,6 +97,11 @@ onMounted(async () => { ...persistedOAuthConfig, token: tokenInfo.right.access_token, } + + if (tokenInfo.right.refresh_token) { + authConfig.refresh_token = tokenInfo.right.refresh_token + } + persistenceService.setLocalConfig( "oauth_temp_config", JSON.stringify(authConfig) @@ -118,6 +123,14 @@ onMounted(async () => { tabService.currentActiveTab.value.document.request.auth.grantTypeInfo.token = tokenInfo.right.access_token + if ( + tabService.currentActiveTab.value.document.request.auth.grantTypeInfo + .grantType === "AUTHORIZATION_CODE" + ) { + tabService.currentActiveTab.value.document.request.auth.grantTypeInfo.refreshToken = + tokenInfo.right.refresh_token + } + toast.success(t("authorization.oauth.token_fetched_successfully")) } diff --git a/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts b/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts index 05c80cc1b..0e426fd8f 100644 --- a/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts +++ b/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts @@ -46,6 +46,13 @@ export type AuthCodeOauthFlowParams = z.infer< typeof AuthCodeOauthFlowParamsSchema > +export type AuthCodeOauthRefreshParams = { + tokenEndpoint: string + clientID: string + clientSecret?: string + refreshToken: string +} + export const getDefaultAuthCodeOauthFlowParams = (): AuthCodeOauthFlowParams => ({ authEndpoint: "", @@ -233,6 +240,7 @@ const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { const withAccessTokenSchema = z.object({ access_token: z.string(), + refresh_token: z.string().optional(), }) const parsedTokenResponse = withAccessTokenSchema.safeParse( @@ -284,9 +292,60 @@ const encodeArrayBufferAsUrlEncodedBase64 = (buffer: ArrayBuffer) => { return hashBase64URL } +const refreshToken = async ({ + tokenEndpoint, + clientID, + refreshToken, + clientSecret, +}: AuthCodeOauthRefreshParams) => { + const formData = new URLSearchParams() + formData.append("grant_type", "refresh_token") + formData.append("refresh_token", refreshToken) + formData.append("client_id", clientID) + if (clientSecret) { + formData.append("client_secret", clientSecret) + } + + const { response } = interceptorService.runRequest({ + url: 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 withAccessTokenAndRefreshTokenSchema = z.object({ + access_token: z.string(), + refresh_token: z.string().optional(), + }) + + const parsedTokenResponse = withAccessTokenAndRefreshTokenSchema.safeParse( + responsePayload.right + ) + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + export default createFlowConfig( "AUTHORIZATION_CODE" as const, AuthCodeOauthFlowParamsSchema, initAuthCodeOauthFlow, - handleRedirectForAuthCodeOauthFlow + handleRedirectForAuthCodeOauthFlow, + refreshToken ) diff --git a/packages/hoppscotch-common/src/services/oauth/oauth.service.ts b/packages/hoppscotch-common/src/services/oauth/oauth.service.ts index 85140557c..e5bd13132 100644 --- a/packages/hoppscotch-common/src/services/oauth/oauth.service.ts +++ b/packages/hoppscotch-common/src/services/oauth/oauth.service.ts @@ -22,6 +22,7 @@ export type PersistedOAuthConfig = { state: string } token?: string + refresh_token?: string } const persistenceService = getService(PersistenceService) @@ -66,6 +67,7 @@ export function createFlowConfig< Flow extends string, AuthParams extends Record, InitFuncReturnObject extends Record, + RefreshTokenParams extends Record, >( flow: Flow, params: ZodType, @@ -81,8 +83,14 @@ export function createFlowConfig< string, { access_token: string + refresh_token?: string } > + >, + refreshToken?: ( + params: RefreshTokenParams + ) => Promise< + E.Either > ) { return { @@ -90,6 +98,7 @@ export function createFlowConfig< params, init, onRedirectReceived, + refreshToken, } } diff --git a/packages/hoppscotch-data/src/collection/v/3.ts b/packages/hoppscotch-data/src/collection/v/3.ts index d6d0a524e..d06cf6b18 100644 --- a/packages/hoppscotch-data/src/collection/v/3.ts +++ b/packages/hoppscotch-data/src/collection/v/3.ts @@ -5,11 +5,14 @@ import { GQLHeader as GQLHeaderV1 } from "../../graphql/v/1" import { GQLHeader as GQLHeaderV2 } from "../../graphql/v/6" import { HoppRESTHeaders as V1_HoppRESTHeaders } from "../../rest/v/1" import { HoppRESTHeaders as V2_HoppRESTHeaders } from "../../rest/v/7" +import { HoppRESTAuth } from "../../rest/v/7" +import { HoppGQLAuth } from "../../graphql/v/6" import { v2_baseCollectionSchema, V2_SCHEMA } from "./2" const v3_baseCollectionSchema = v2_baseCollectionSchema.extend({ v: z.literal(3), headers: z.union([V2_HoppRESTHeaders, z.array(GQLHeaderV2)]), + auth: z.union([HoppRESTAuth, HoppGQLAuth]), }) type Input = z.input & { diff --git a/packages/hoppscotch-data/src/graphql/index.ts b/packages/hoppscotch-data/src/graphql/index.ts index 1b067b527..7c2565f10 100644 --- a/packages/hoppscotch-data/src/graphql/index.ts +++ b/packages/hoppscotch-data/src/graphql/index.ts @@ -15,7 +15,7 @@ export { } from "./v/2" export { GQLHeader } from "./v/6" -export { HoppGQLAuth, HoppGQLAuthOAuth2 } from "./v/5" +export { HoppGQLAuth, HoppGQLAuthOAuth2 } from "./v/6" export { HoppGQLAuthAPIKey } from "./v/4" diff --git a/packages/hoppscotch-data/src/graphql/v/6.ts b/packages/hoppscotch-data/src/graphql/v/6.ts index 86f52f694..efc2330af 100644 --- a/packages/hoppscotch-data/src/graphql/v/6.ts +++ b/packages/hoppscotch-data/src/graphql/v/6.ts @@ -1,7 +1,33 @@ import { defineVersion } from "verzod" import { z } from "zod" - import { V5_SCHEMA } from "./5" +import { HoppRESTAuthOAuth2 } from "./../../rest/v/7" +import { + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, +} from "./2" +import { HoppGQLAuthAPIKey } from "./4" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/7" + +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 GQLHeader = z.object({ key: z.string().catch(""), @@ -15,6 +41,7 @@ export type GQLHeader = z.infer export const V6_SCHEMA = V5_SCHEMA.extend({ v: z.literal(6), headers: z.array(GQLHeader).catch([]), + auth: HoppGQLAuth, }) export default defineVersion({ diff --git a/packages/hoppscotch-data/src/rest/index.ts b/packages/hoppscotch-data/src/rest/index.ts index 03d9a5415..8e8636fe4 100644 --- a/packages/hoppscotch-data/src/rest/index.ts +++ b/packages/hoppscotch-data/src/rest/index.ts @@ -13,12 +13,10 @@ import V3_VERSION from "./v/3" import V4_VERSION from "./v/4" import V5_VERSION from "./v/5" import V6_VERSION, { HoppRESTReqBody } from "./v/6" +import V7_VERSION, { HoppRESTAuth } from "./v/7" import { HoppRESTHeaders, HoppRESTParams } from "./v/7" -import V7_VERSION from "./v/7" - import { HoppRESTRequestVariables } from "./v/2" -import { HoppRESTAuth } from "./v/5" export * from "./content-types" @@ -37,11 +35,8 @@ export { PasswordGrantTypeParams, } from "./v/3" -export { - AuthCodeGrantTypeParams, - HoppRESTAuth, - HoppRESTAuthOAuth2, -} from "./v/5" +export { AuthCodeGrantTypeParams } from "./v/5" +export { HoppRESTAuthOAuth2, HoppRESTAuth } from "./v/7" export { HoppRESTAuthAPIKey } from "./v/4" @@ -159,6 +154,7 @@ export function safelyExtractRESTRequest( const result = HoppRESTAuth.safeParse(x.auth) if (result.success) { + // @ts-ignore req.auth = result.data } } diff --git a/packages/hoppscotch-data/src/rest/v/7.ts b/packages/hoppscotch-data/src/rest/v/7.ts index 1a43f8669..719dedd95 100644 --- a/packages/hoppscotch-data/src/rest/v/7.ts +++ b/packages/hoppscotch-data/src/rest/v/7.ts @@ -3,6 +3,56 @@ import { z } from "zod" import { V6_SCHEMA } from "./6" +import { AuthCodeGrantTypeParams as AuthCodeGrantTypeParamsOld } from "./5" + +import { + ClientCredentialsGrantTypeParams, + ImplicitOauthFlowParams, + PasswordGrantTypeParams, +} from "./3" +import { + HoppRESTAuthAPIKey, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "./1" + +// Add refreshToken to all grant types except Implicit +export const AuthCodeGrantTypeParams = AuthCodeGrantTypeParamsOld.extend({ + refreshToken: z.string().optional(), +}) + +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 + export const HoppRESTParams = z.array( z.object({ key: z.string().catch(""), @@ -29,6 +79,7 @@ export const V7_SCHEMA = V6_SCHEMA.extend({ v: z.literal("7"), params: HoppRESTParams, headers: HoppRESTHeaders, + auth: HoppRESTAuth, }) export default defineVersion({ @@ -54,6 +105,7 @@ export default defineVersion({ v: "7" as const, params, headers, + // no need to update anything for HoppRESTAuth, because the newly added refreshToken is optional } }, })