feat: ability to refresh tokens for oauth flows (#4302)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Akash K
2024-08-29 13:27:31 +05:30
committed by GitHub
parent 2ed7221182
commit 181ad098e0
20 changed files with 488 additions and 68 deletions

View File

@@ -159,6 +159,30 @@ describe("hopp test [options] <file_path_or_id>", () => {
expect(error).toBeNull(); 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:", () => { describe("Test `hopp test <file_path_or_id> --env <file_path_or_id>` command:", () => {

View File

@@ -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": []
}

View File

@@ -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": []
}

View File

@@ -15,7 +15,6 @@ describe("requestRunner", () => {
}; };
beforeEach(() => { beforeEach(() => {
SAMPLE_REQUEST_CONFIG.supported = false;
SAMPLE_REQUEST_CONFIG.url = "https://example.com"; SAMPLE_REQUEST_CONFIG.url = "https://example.com";
SAMPLE_REQUEST_CONFIG.method = "GET"; SAMPLE_REQUEST_CONFIG.method = "GET";
jest.clearAllMocks(); jest.clearAllMocks();
@@ -70,7 +69,6 @@ describe("requestRunner", () => {
it("Should handle axios-error with request info.", () => { it("Should handle axios-error with request info.", () => {
jest.spyOn(axios, "isAxiosError").mockReturnValue(true); jest.spyOn(axios, "isAxiosError").mockReturnValue(true);
SAMPLE_REQUEST_CONFIG.supported = true;
(axios as unknown as jest.Mock).mockRejectedValueOnce(<AxiosError>{ (axios as unknown as jest.Mock).mockRejectedValueOnce(<AxiosError>{
name: "name", name: "name",
message: "message", message: "message",
@@ -91,7 +89,6 @@ describe("requestRunner", () => {
}); });
it("Should successfully execute.", () => { it("Should successfully execute.", () => {
SAMPLE_REQUEST_CONFIG.supported = true;
(axios as unknown as jest.Mock).mockResolvedValue(<AxiosResponse>{ (axios as unknown as jest.Mock).mockResolvedValue(<AxiosResponse>{
data: "data", data: "data",
status: 200, status: 200,

View File

@@ -20,8 +20,7 @@ export interface RequestStack {
* @property {boolean} supported - Boolean check for supported or unsupported requests. * @property {boolean} supported - Boolean check for supported or unsupported requests.
*/ */
export interface RequestConfig extends AxiosRequestConfig { export interface RequestConfig extends AxiosRequestConfig {
supported: boolean; displayUrl?: string;
displayUrl?: string
} }
export interface EffectiveHoppRESTRequest extends HoppRESTRequest { export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
@@ -32,7 +31,17 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
*/ */
effectiveFinalURL: string; effectiveFinalURL: string;
effectiveFinalDisplayURL?: string; effectiveFinalDisplayURL?: string;
effectiveFinalHeaders: { key: string; value: string; active: boolean }[]; effectiveFinalHeaders: {
effectiveFinalParams: { key: string; value: string; active: boolean }[]; key: string;
value: string;
active: boolean;
description: string;
}[];
effectiveFinalParams: {
key: string;
value: string;
active: boolean;
description: string;
}[];
effectiveFinalBody: FormData | string | null; effectiveFinalBody: FormData | string | null;
} }

View File

@@ -72,11 +72,12 @@ export const getEffectiveFinalMetaData = (
* Selecting only non-empty and active pairs. * Selecting only non-empty and active pairs.
*/ */
A.filter(({ key, active }) => !S.isEmpty(key) && active), A.filter(({ key, active }) => !S.isEmpty(key) && active),
A.map(({ key, value }) => { A.map(({ key, value, description }) => {
return { return {
active: true, active: true,
key: parseTemplateStringE(key, resolvedVariables), key: parseTemplateStringE(key, resolvedVariables),
value: parseTemplateStringE(value, resolvedVariables), value: parseTemplateStringE(value, resolvedVariables),
description,
}; };
}), }),
E.fromPredicate( E.fromPredicate(
@@ -91,9 +92,14 @@ export const getEffectiveFinalMetaData = (
/** /**
* Filtering and mapping only right-eithers for each key-value as [string, string]. * 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) 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 : O.none
) )
) )

View File

@@ -123,12 +123,14 @@ export function getEffectiveRESTRequest(
active: true, active: true,
key: "Authorization", key: "Authorization",
value: `Basic ${btoa(`${username}:${password}`)}`, value: `Basic ${btoa(`${username}:${password}`)}`,
description: "",
}); });
} else if (request.auth.authType === "bearer") { } else if (request.auth.authType === "bearer") {
effectiveFinalHeaders.push({ effectiveFinalHeaders.push({
active: true, active: true,
key: "Authorization", key: "Authorization",
value: `Bearer ${parseTemplateString(request.auth.token, resolvedVariables)}`, value: `Bearer ${parseTemplateString(request.auth.token, resolvedVariables)}`,
description: "",
}); });
} else if (request.auth.authType === "oauth-2") { } else if (request.auth.authType === "oauth-2") {
const { addTo } = request.auth; const { addTo } = request.auth;
@@ -138,6 +140,7 @@ export function getEffectiveRESTRequest(
active: true, active: true,
key: "Authorization", key: "Authorization",
value: `Bearer ${parseTemplateString(request.auth.grantTypeInfo.token, resolvedVariables)}`, value: `Bearer ${parseTemplateString(request.auth.grantTypeInfo.token, resolvedVariables)}`,
description: "",
}); });
} else if (addTo === "QUERY_PARAMS") { } else if (addTo === "QUERY_PARAMS") {
effectiveFinalParams.push({ effectiveFinalParams.push({
@@ -147,6 +150,7 @@ export function getEffectiveRESTRequest(
request.auth.grantTypeInfo.token, request.auth.grantTypeInfo.token,
resolvedVariables resolvedVariables
), ),
description: "",
}); });
} }
} else if (request.auth.authType === "api-key") { } else if (request.auth.authType === "api-key") {
@@ -156,12 +160,14 @@ export function getEffectiveRESTRequest(
active: true, active: true,
key: parseTemplateString(key, resolvedVariables), key: parseTemplateString(key, resolvedVariables),
value: parseTemplateString(value, resolvedVariables), value: parseTemplateString(value, resolvedVariables),
description: "",
}); });
} else if (addTo === "QUERY_PARAMS") { } else if (addTo === "QUERY_PARAMS") {
effectiveFinalParams.push({ effectiveFinalParams.push({
active: true, active: true,
key: parseTemplateString(key, resolvedVariables), key: parseTemplateString(key, resolvedVariables),
value: parseTemplateString(value, resolvedVariables), value: parseTemplateString(value, resolvedVariables),
description: "",
}); });
} }
} }
@@ -187,6 +193,7 @@ export function getEffectiveRESTRequest(
active: true, active: true,
key: "Content-Type", key: "Content-Type",
value: request.body.contentType, value: request.body.contentType,
description: "",
}); });
} }

View File

@@ -32,8 +32,6 @@ import { getDurationInSeconds, getMetaDataPairs } from "./getters";
import { preRequestScriptRunner } from "./pre-request"; import { preRequestScriptRunner } from "./pre-request";
import { getTestScriptParams, hasFailedTestCases, testRunner } from "./test"; 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 * Processes given variable, which includes checking for secret variables
* and getting value from system environment * and getting value from system environment
@@ -75,46 +73,20 @@ const processEnvs = (envs: Partial<HoppEnvs>) => {
*/ */
export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => { export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => {
const config: RequestConfig = { const config: RequestConfig = {
supported: true,
displayUrl: req.effectiveFinalDisplayURL, displayUrl: req.effectiveFinalDisplayURL,
}; };
const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest; const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest;
const reqParams = finalParams(req); const reqParams = finalParams(req);
const reqHeaders = finalHeaders(req); const reqHeaders = finalHeaders(req);
config.url = finalEndpoint(req); config.url = finalEndpoint(req);
config.method = req.method as Method; config.method = req.method as Method;
config.params = getMetaDataPairs(reqParams); config.params = getMetaDataPairs(reqParams);
config.headers = getMetaDataPairs(reqHeaders); 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.data = finalBody(req);
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;
}
}
}
return config; return config;
}; };
@@ -149,13 +121,6 @@ export const requestRunner =
duration: 0, 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 end = hrtime(start);
const duration = getDurationInSeconds(end); const duration = getDurationInSeconds(end);
runnerResponse.duration = duration; runnerResponse.duration = duration;
@@ -182,10 +147,6 @@ export const requestRunner =
runnerResponse.statusText = statusText; runnerResponse.statusText = statusText;
runnerResponse.status = status; runnerResponse.status = status;
runnerResponse.headers = headers; runnerResponse.headers = headers;
} else if ((e.config as RequestConfig).supported === false) {
status = 501;
runnerResponse.status = status;
runnerResponse.statusText = responseErrors[status];
} else if (e.request) { } else if (e.request) {
return E.left(error({ code: "REQUEST_ERROR", data: E.toError(e) })); return E.left(error({ code: "REQUEST_ERROR", data: E.toError(e) }));
} }

View File

@@ -126,6 +126,7 @@
}, },
"authorization": { "authorization": {
"generate_token": "Generate Token", "generate_token": "Generate Token",
"refresh_token": "Refresh Token",
"graphql_headers": "Authorization Headers are sent as part of the payload to connection_init", "graphql_headers": "Authorization Headers are sent as part of the payload to connection_init",
"include_in_url": "Include in URL", "include_in_url": "Include in URL",
"inherited_from": "Inherited {auth} from parent collection {collection} ", "inherited_from": "Inherited {auth} from parent collection {collection} ",
@@ -148,6 +149,9 @@
"token_fetched_successfully": "Token fetched successfully", "token_fetched_successfully": "Token fetched successfully",
"token_fetch_failed": "Failed to fetch token", "token_fetch_failed": "Failed to fetch token",
"validation_failed": "Validation Failed, please check the form fields", "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_authorization_endpoint": "Authorization Endpoint",
"label_client_id": "Client ID", "label_client_id": "Client ID",
"label_client_secret": "Client Secret", "label_client_secret": "Client Secret",

View File

@@ -255,7 +255,7 @@ onMounted(() => {
return return
} }
const { context, source, token }: PersistedOAuthConfig = const { context, source, token, refresh_token }: PersistedOAuthConfig =
JSON.parse(localOAuthTempConfig) JSON.parse(localOAuthTempConfig)
if (source === "REST") { if (source === "REST") {
@@ -279,6 +279,10 @@ onMounted(() => {
const grantTypeInfo = auth.grantTypeInfo const grantTypeInfo = auth.grantTypeInfo
grantTypeInfo && (grantTypeInfo.token = token ?? "") grantTypeInfo && (grantTypeInfo.token = token ?? "")
if (refresh_token && grantTypeInfo.grantType === "AUTHORIZATION_CODE") {
grantTypeInfo.refreshToken = refresh_token
}
} }
editingProperties.value = unsavedCollectionProperties editingProperties.value = unsavedCollectionProperties

View File

@@ -428,7 +428,7 @@ onMounted(() => {
return return
} }
const { context, source, token }: PersistedOAuthConfig = const { context, source, token, refresh_token }: PersistedOAuthConfig =
JSON.parse(localOAuthTempConfig) JSON.parse(localOAuthTempConfig)
if (source === "GraphQL") { if (source === "GraphQL") {
@@ -452,6 +452,10 @@ onMounted(() => {
const grantTypeInfo = auth.grantTypeInfo const grantTypeInfo = auth.grantTypeInfo
grantTypeInfo && (grantTypeInfo.token = token ?? "") grantTypeInfo && (grantTypeInfo.token = token ?? "")
if (refresh_token && grantTypeInfo.grantType === "AUTHORIZATION_CODE") {
grantTypeInfo.refreshToken = refresh_token
}
} }
editingProperties.value = unsavedCollectionProperties editingProperties.value = unsavedCollectionProperties

View File

@@ -165,12 +165,18 @@
</span> </span>
</div> </div>
<div class="p-2"> <div class="p-2 gap-1 flex">
<HoppButtonSecondary <HoppButtonSecondary
filled filled
:label="`${t('authorization.generate_token')}`" :label="`${t('authorization.generate_token')}`"
@click="generateOAuthToken()" @click="generateOAuthToken()"
/> />
<HoppButtonSecondary
v-if="runTokenRefresh"
filled
:label="`${t('authorization.refresh_token')}`"
@click="refreshOauthToken()"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -192,6 +198,7 @@ import { getCombinedEnvVariables } from "~/helpers/preRequest"
import { AggregateEnvironment } from "~/newstore/environments" import { AggregateEnvironment } from "~/newstore/environments"
import authCode, { import authCode, {
AuthCodeOauthFlowParams, AuthCodeOauthFlowParams,
AuthCodeOauthRefreshParams,
getDefaultAuthCodeOauthFlowParams, getDefaultAuthCodeOauthFlowParams,
} from "~/services/oauth/flows/authCode" } from "~/services/oauth/flows/authCode"
import clientCredentials, { 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 runAction = () => {
const params: AuthCodeOauthFlowParams = { const params: AuthCodeOauthFlowParams = {
authEndpoint: authEndpoint.value, authEndpoint: authEndpoint.value,
@@ -456,6 +505,7 @@ const supportedGrantTypes = [
return { return {
runAction, runAction,
refreshToken,
elements, elements,
} }
}), }),
@@ -854,13 +904,28 @@ const selectedGrantType = computed(() => {
) )
}) })
const setAccessTokenInActiveContext = (accessToken?: string) => { const setAccessTokenInActiveContext = (
accessToken?: string,
refreshToken?: string
) => {
if (props.isCollectionProperty && accessToken) { if (props.isCollectionProperty && accessToken) {
auth.value.grantTypeInfo = { auth.value.grantTypeInfo = {
...auth.value.grantTypeInfo, ...auth.value.grantTypeInfo,
token: accessToken, 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 return
} }
@@ -874,6 +939,16 @@ const setAccessTokenInActiveContext = (accessToken?: string) => {
tabService.currentActiveTab.value.document.request.auth.grantTypeInfo.token = tabService.currentActiveTab.value.document.request.auth.grantTypeInfo.token =
accessToken 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 = ( const changeSelectedGrantType = (
@@ -905,10 +980,53 @@ const runAction = computed(() => {
return selectedGrantType.value?.formElements.value?.runAction 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(() => { const currentOAuthGrantTypeFormElements = computed(() => {
return selectedGrantType.value?.formElements.value?.elements.value 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 () => { const generateOAuthToken = async () => {
if ( if (
grantTypesInvolvingRedirect.includes(auth.value.grantTypeInfo.grantType) grantTypesInvolvingRedirect.includes(auth.value.grantTypeInfo.grantType)

View File

@@ -97,6 +97,11 @@ onMounted(async () => {
...persistedOAuthConfig, ...persistedOAuthConfig,
token: tokenInfo.right.access_token, token: tokenInfo.right.access_token,
} }
if (tokenInfo.right.refresh_token) {
authConfig.refresh_token = tokenInfo.right.refresh_token
}
persistenceService.setLocalConfig( persistenceService.setLocalConfig(
"oauth_temp_config", "oauth_temp_config",
JSON.stringify(authConfig) JSON.stringify(authConfig)
@@ -118,6 +123,14 @@ onMounted(async () => {
tabService.currentActiveTab.value.document.request.auth.grantTypeInfo.token = tabService.currentActiveTab.value.document.request.auth.grantTypeInfo.token =
tokenInfo.right.access_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")) toast.success(t("authorization.oauth.token_fetched_successfully"))
} }

View File

@@ -46,6 +46,13 @@ export type AuthCodeOauthFlowParams = z.infer<
typeof AuthCodeOauthFlowParamsSchema typeof AuthCodeOauthFlowParamsSchema
> >
export type AuthCodeOauthRefreshParams = {
tokenEndpoint: string
clientID: string
clientSecret?: string
refreshToken: string
}
export const getDefaultAuthCodeOauthFlowParams = export const getDefaultAuthCodeOauthFlowParams =
(): AuthCodeOauthFlowParams => ({ (): AuthCodeOauthFlowParams => ({
authEndpoint: "", authEndpoint: "",
@@ -233,6 +240,7 @@ const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => {
const withAccessTokenSchema = z.object({ const withAccessTokenSchema = z.object({
access_token: z.string(), access_token: z.string(),
refresh_token: z.string().optional(),
}) })
const parsedTokenResponse = withAccessTokenSchema.safeParse( const parsedTokenResponse = withAccessTokenSchema.safeParse(
@@ -284,9 +292,60 @@ const encodeArrayBufferAsUrlEncodedBase64 = (buffer: ArrayBuffer) => {
return hashBase64URL 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( export default createFlowConfig(
"AUTHORIZATION_CODE" as const, "AUTHORIZATION_CODE" as const,
AuthCodeOauthFlowParamsSchema, AuthCodeOauthFlowParamsSchema,
initAuthCodeOauthFlow, initAuthCodeOauthFlow,
handleRedirectForAuthCodeOauthFlow handleRedirectForAuthCodeOauthFlow,
refreshToken
) )

View File

@@ -22,6 +22,7 @@ export type PersistedOAuthConfig = {
state: string state: string
} }
token?: string token?: string
refresh_token?: string
} }
const persistenceService = getService(PersistenceService) const persistenceService = getService(PersistenceService)
@@ -66,6 +67,7 @@ export function createFlowConfig<
Flow extends string, Flow extends string,
AuthParams extends Record<string, unknown>, AuthParams extends Record<string, unknown>,
InitFuncReturnObject extends Record<string, unknown>, InitFuncReturnObject extends Record<string, unknown>,
RefreshTokenParams extends Record<string, unknown>,
>( >(
flow: Flow, flow: Flow,
params: ZodType<AuthParams>, params: ZodType<AuthParams>,
@@ -81,8 +83,14 @@ export function createFlowConfig<
string, string,
{ {
access_token: string access_token: string
refresh_token?: string
} }
> >
>,
refreshToken?: (
params: RefreshTokenParams
) => Promise<
E.Either<string, { access_token: string; refresh_token?: string }>
> >
) { ) {
return { return {
@@ -90,6 +98,7 @@ export function createFlowConfig<
params, params,
init, init,
onRedirectReceived, onRedirectReceived,
refreshToken,
} }
} }

View File

@@ -5,11 +5,14 @@ import { GQLHeader as GQLHeaderV1 } from "../../graphql/v/1"
import { GQLHeader as GQLHeaderV2 } from "../../graphql/v/6" import { GQLHeader as GQLHeaderV2 } from "../../graphql/v/6"
import { HoppRESTHeaders as V1_HoppRESTHeaders } from "../../rest/v/1" import { HoppRESTHeaders as V1_HoppRESTHeaders } from "../../rest/v/1"
import { HoppRESTHeaders as V2_HoppRESTHeaders } from "../../rest/v/7" 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" import { v2_baseCollectionSchema, V2_SCHEMA } from "./2"
const v3_baseCollectionSchema = v2_baseCollectionSchema.extend({ const v3_baseCollectionSchema = v2_baseCollectionSchema.extend({
v: z.literal(3), v: z.literal(3),
headers: z.union([V2_HoppRESTHeaders, z.array(GQLHeaderV2)]), headers: z.union([V2_HoppRESTHeaders, z.array(GQLHeaderV2)]),
auth: z.union([HoppRESTAuth, HoppGQLAuth]),
}) })
type Input = z.input<typeof v3_baseCollectionSchema> & { type Input = z.input<typeof v3_baseCollectionSchema> & {

View File

@@ -15,7 +15,7 @@ export {
} from "./v/2" } from "./v/2"
export { GQLHeader } from "./v/6" export { GQLHeader } from "./v/6"
export { HoppGQLAuth, HoppGQLAuthOAuth2 } from "./v/5" export { HoppGQLAuth, HoppGQLAuthOAuth2 } from "./v/6"
export { HoppGQLAuthAPIKey } from "./v/4" export { HoppGQLAuthAPIKey } from "./v/4"

View File

@@ -1,7 +1,33 @@
import { defineVersion } from "verzod" import { defineVersion } from "verzod"
import { z } from "zod" import { z } from "zod"
import { V5_SCHEMA } from "./5" 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({ export const GQLHeader = z.object({
key: z.string().catch(""), key: z.string().catch(""),
@@ -15,6 +41,7 @@ export type GQLHeader = z.infer<typeof GQLHeader>
export const V6_SCHEMA = V5_SCHEMA.extend({ export const V6_SCHEMA = V5_SCHEMA.extend({
v: z.literal(6), v: z.literal(6),
headers: z.array(GQLHeader).catch([]), headers: z.array(GQLHeader).catch([]),
auth: HoppGQLAuth,
}) })
export default defineVersion({ export default defineVersion({

View File

@@ -13,12 +13,10 @@ import V3_VERSION from "./v/3"
import V4_VERSION from "./v/4" import V4_VERSION from "./v/4"
import V5_VERSION from "./v/5" import V5_VERSION from "./v/5"
import V6_VERSION, { HoppRESTReqBody } from "./v/6" import V6_VERSION, { HoppRESTReqBody } from "./v/6"
import V7_VERSION, { HoppRESTAuth } from "./v/7"
import { HoppRESTHeaders, HoppRESTParams } from "./v/7" import { HoppRESTHeaders, HoppRESTParams } from "./v/7"
import V7_VERSION from "./v/7"
import { HoppRESTRequestVariables } from "./v/2" import { HoppRESTRequestVariables } from "./v/2"
import { HoppRESTAuth } from "./v/5"
export * from "./content-types" export * from "./content-types"
@@ -37,11 +35,8 @@ export {
PasswordGrantTypeParams, PasswordGrantTypeParams,
} from "./v/3" } from "./v/3"
export { export { AuthCodeGrantTypeParams } from "./v/5"
AuthCodeGrantTypeParams, export { HoppRESTAuthOAuth2, HoppRESTAuth } from "./v/7"
HoppRESTAuth,
HoppRESTAuthOAuth2,
} from "./v/5"
export { HoppRESTAuthAPIKey } from "./v/4" export { HoppRESTAuthAPIKey } from "./v/4"
@@ -159,6 +154,7 @@ export function safelyExtractRESTRequest(
const result = HoppRESTAuth.safeParse(x.auth) const result = HoppRESTAuth.safeParse(x.auth)
if (result.success) { if (result.success) {
// @ts-ignore
req.auth = result.data req.auth = result.data
} }
} }

View File

@@ -3,6 +3,56 @@ import { z } from "zod"
import { V6_SCHEMA } from "./6" 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( export const HoppRESTParams = z.array(
z.object({ z.object({
key: z.string().catch(""), key: z.string().catch(""),
@@ -29,6 +79,7 @@ export const V7_SCHEMA = V6_SCHEMA.extend({
v: z.literal("7"), v: z.literal("7"),
params: HoppRESTParams, params: HoppRESTParams,
headers: HoppRESTHeaders, headers: HoppRESTHeaders,
auth: HoppRESTAuth,
}) })
export default defineVersion({ export default defineVersion({
@@ -54,6 +105,7 @@ export default defineVersion({
v: "7" as const, v: "7" as const,
params, params,
headers, headers,
// no need to update anything for HoppRESTAuth, because the newly added refreshToken is optional
} }
}, },
}) })