feat: ability to refresh tokens for oauth flows (#4302)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
@@ -159,6 +159,30 @@ describe("hopp test [options] <file_path_or_id>", () => {
|
||||
|
||||
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 <file_path_or_id> --env <file_path_or_id>` command:", () => {
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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(<AxiosError>{
|
||||
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(<AxiosResponse>{
|
||||
data: "data",
|
||||
status: 200,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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: "",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HoppEnvs>) => {
|
||||
*/
|
||||
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) }));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -165,12 +165,18 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="p-2 gap-1 flex">
|
||||
<HoppButtonSecondary
|
||||
filled
|
||||
:label="`${t('authorization.generate_token')}`"
|
||||
@click="generateOAuthToken()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="runTokenRefresh"
|
||||
filled
|
||||
:label="`${t('authorization.refresh_token')}`"
|
||||
@click="refreshOauthToken()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
InitFuncReturnObject extends Record<string, unknown>,
|
||||
RefreshTokenParams extends Record<string, unknown>,
|
||||
>(
|
||||
flow: Flow,
|
||||
params: ZodType<AuthParams>,
|
||||
@@ -81,8 +83,14 @@ export function createFlowConfig<
|
||||
string,
|
||||
{
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
}
|
||||
>
|
||||
>,
|
||||
refreshToken?: (
|
||||
params: RefreshTokenParams
|
||||
) => Promise<
|
||||
E.Either<string, { access_token: string; refresh_token?: string }>
|
||||
>
|
||||
) {
|
||||
return {
|
||||
@@ -90,6 +98,7 @@ export function createFlowConfig<
|
||||
params,
|
||||
init,
|
||||
onRedirectReceived,
|
||||
refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof v3_baseCollectionSchema> & {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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<typeof HoppGQLAuth>
|
||||
|
||||
export const GQLHeader = z.object({
|
||||
key: z.string().catch(""),
|
||||
@@ -15,6 +41,7 @@ export type GQLHeader = z.infer<typeof GQLHeader>
|
||||
export const V6_SCHEMA = V5_SCHEMA.extend({
|
||||
v: z.literal(6),
|
||||
headers: z.array(GQLHeader).catch([]),
|
||||
auth: HoppGQLAuth,
|
||||
})
|
||||
|
||||
export default defineVersion({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof HoppRESTAuthOAuth2>
|
||||
|
||||
export const HoppRESTAuth = z
|
||||
.discriminatedUnion("authType", [
|
||||
HoppRESTAuthNone,
|
||||
HoppRESTAuthInherit,
|
||||
HoppRESTAuthBasic,
|
||||
HoppRESTAuthBearer,
|
||||
HoppRESTAuthOAuth2,
|
||||
HoppRESTAuthAPIKey,
|
||||
])
|
||||
.and(
|
||||
z.object({
|
||||
authActive: z.boolean(),
|
||||
})
|
||||
)
|
||||
|
||||
export type HoppRESTAuth = z.infer<typeof HoppRESTAuth>
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user