diff --git a/packages/hoppscotch-cli/src/__tests__/unit/workspace-access.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/workspace-access.spec.ts index 3677b674..da38e914 100644 --- a/packages/hoppscotch-cli/src/__tests__/unit/workspace-access.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/unit/workspace-access.spec.ts @@ -1,4 +1,7 @@ +import { HoppCollection } from "@hoppscotch/data"; +import { entityReference } from "verzod"; import { describe, expect, test } from "vitest"; +import { z } from "zod"; import { transformWorkspaceCollections, @@ -16,6 +19,26 @@ import { import TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK from "../e2e/fixtures/collections/multiple-child-collections-auth-headers-coll.json"; +// Helper function to validate against `HoppCollection` schema and apply relevant migrations +const migrateCollections = (collections: unknown[]): HoppCollection[] => { + const collectionSchemaParsedResult = z + .array(entityReference(HoppCollection)) + .safeParse(collections); + + if (!collectionSchemaParsedResult.success) { + throw new Error( + `Incoming collections failed schema validation: ${JSON.stringify(collections, null, 2)}` + ); + } + + return collectionSchemaParsedResult.data.map((collection) => { + return { + ...collection, + folders: migrateCollections(collection.folders), + }; + }); +}; + describe("workspace-access", () => { describe("transformWorkspaceCollection", () => { test("Successfully transforms collection data with deeply nested collections and authorization/headers set at each level to the `HoppCollection` format", () => { @@ -27,13 +50,15 @@ describe("workspace-access", () => { }); test("Successfully transforms collection data with multiple child collections and authorization/headers set at each level to the `HoppCollection` format", () => { + const migratedCollections = migrateCollections([ + TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK, + ]); + expect( transformWorkspaceCollections( WORKSPACE_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK ) - ).toEqual([ - TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK, - ]); + ).toEqual(migratedCollections); }); test("Adds the default value for `auth` & `header` fields while transforming collections without authorization/headers set at certain levels", () => { diff --git a/packages/hoppscotch-common/src/components/http/authorization/OAuth2.vue b/packages/hoppscotch-common/src/components/http/authorization/OAuth2.vue index 48a76d6b..3cf92591 100644 --- a/packages/hoppscotch-common/src/components/http/authorization/OAuth2.vue +++ b/packages/hoppscotch-common/src/components/http/authorization/OAuth2.vue @@ -401,7 +401,7 @@ const supportedGrantTypes = [ return E.right(undefined) } - const runAction = () => { + const runAction = async () => { const params: AuthCodeOauthFlowParams = { authEndpoint: authEndpoint.value, tokenEndpoint: tokenEndpoint.value, @@ -420,7 +420,11 @@ const supportedGrantTypes = [ return E.left("VALIDATION_FAILED" as const) } - authCode.init(parsedArgs.data) + const res = await authCode.init(parsedArgs.data) + + if (E.isLeft(res)) { + return res + } return E.right(undefined) } @@ -1047,8 +1051,14 @@ const generateOAuthToken = async () => { VALIDATION_FAILED: t("authorization.oauth.validation_failed"), OAUTH_TOKEN_FETCH_FAILED: t("authorization.oauth.token_fetch_failed"), } + if (res.left in errorMessages) { + // @ts-expect-error - not possible to have a key that doesn't exist + toast.error(errorMessages[res.left]) + return + } + + toast.error(t("error.something_went_wrong")) - toast.error(errorMessages[res.left]) return } } diff --git a/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts b/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts index 9582b823..034bc382 100644 --- a/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts +++ b/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts @@ -24,7 +24,6 @@ const ClientCredentialsFlowParamsSchema = ClientCredentialsGrantTypeParams.pick( return ( params.authEndpoint.length >= 1 && params.clientID.length >= 1 && - params.clientSecret.length >= 1 && (!params.scopes || params.scopes.length >= 1) ) }, @@ -56,7 +55,10 @@ const initClientCredentialsOAuthFlow = async ({ const formData = new URLSearchParams() formData.append("grant_type", "client_credentials") formData.append("client_id", clientID) - formData.append("client_secret", clientSecret) + + if (clientSecret) { + formData.append("client_secret", clientSecret) + } if (scopes) { formData.append("scope", scopes) diff --git a/packages/hoppscotch-common/src/services/oauth/flows/password.ts b/packages/hoppscotch-common/src/services/oauth/flows/password.ts index d572b647..22875bc7 100644 --- a/packages/hoppscotch-common/src/services/oauth/flows/password.ts +++ b/packages/hoppscotch-common/src/services/oauth/flows/password.ts @@ -24,7 +24,6 @@ const PasswordFlowParamsSchema = PasswordGrantTypeParams.pick({ return ( params.authEndpoint.length >= 1 && params.clientID.length >= 1 && - params.clientSecret.length >= 1 && params.username.length >= 1 && params.password.length >= 1 && (!params.scopes || params.scopes.length >= 1) @@ -59,10 +58,13 @@ const initPasswordOauthFlow = async ({ const formData = new URLSearchParams() formData.append("grant_type", "password") formData.append("client_id", clientID) - formData.append("client_secret", clientSecret) formData.append("username", username) formData.append("password", password) + if (clientSecret) { + formData.append("client_secret", clientSecret) + } + if (scopes) { formData.append("scope", scopes) } diff --git a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts index a12d80cf..1e36c791 100644 --- a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts @@ -25,7 +25,7 @@ const DEFAULT_SETTINGS = getDefaultSettings() export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ { - v: 3, + v: 4, name: "Echo", folders: [], requests: [ @@ -50,12 +50,12 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ { - v: 3, + v: 4, name: "Echo", folders: [], requests: [ { - v: 6, + v: 7, name: "Echo test", url: "https://echo.hoppscotch.io/graphql", headers: [], @@ -156,7 +156,7 @@ export const GQL_HISTORY_MOCK: GQLHistoryEntry[] = [ { v: 1, request: { - v: 6, + v: 7, name: "Untitled", url: "https://echo.hoppscotch.io/graphql", query: "query Request { url }", @@ -177,7 +177,7 @@ export const GQL_TAB_STATE_MOCK: PersistableTabState = { tabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc", doc: { request: { - v: 6, + v: 7, name: "Untitled", url: "https://echo.hoppscotch.io/graphql", headers: [], diff --git a/packages/hoppscotch-data/src/collection/index.ts b/packages/hoppscotch-data/src/collection/index.ts index 47422240..0dca4cd2 100644 --- a/packages/hoppscotch-data/src/collection/index.ts +++ b/packages/hoppscotch-data/src/collection/index.ts @@ -3,6 +3,7 @@ import { InferredEntity, createVersionedEntity } from "verzod" import V1_VERSION from "./v/1" import V2_VERSION from "./v/2" import V3_VERSION from "./v/3" +import V4_VERSION from "./v/4" import { z } from "zod" import { translateToNewRequest } from "../rest" @@ -13,11 +14,12 @@ const versionedObject = z.object({ }) export const HoppCollection = createVersionedEntity({ - latestVersion: 3, + latestVersion: 4, versionMap: { 1: V1_VERSION, 2: V2_VERSION, 3: V3_VERSION, + 4: V4_VERSION, }, getVersion(data) { const versionCheck = versionedObject.safeParse(data) @@ -33,7 +35,7 @@ export const HoppCollection = createVersionedEntity({ export type HoppCollection = InferredEntity -export const CollectionSchemaVersion = 3 +export const CollectionSchemaVersion = 4 /** * Generates a Collection object. This ignores the version number object diff --git a/packages/hoppscotch-data/src/collection/v/3.ts b/packages/hoppscotch-data/src/collection/v/3.ts index 841dcb51..776abc10 100644 --- a/packages/hoppscotch-data/src/collection/v/3.ts +++ b/packages/hoppscotch-data/src/collection/v/3.ts @@ -12,7 +12,7 @@ import { import { v2_baseCollectionSchema, V2_SCHEMA } from "./2" -const v3_baseCollectionSchema = v2_baseCollectionSchema.extend({ +export const v3_baseCollectionSchema = v2_baseCollectionSchema.extend({ v: z.literal(3), // AWS Signature Authorization type addition diff --git a/packages/hoppscotch-data/src/collection/v/4.ts b/packages/hoppscotch-data/src/collection/v/4.ts new file mode 100644 index 00000000..b6607683 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/4.ts @@ -0,0 +1,37 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { HoppGQLAuth } from "../../graphql/v/7" +import { HoppRESTAuth } from "../../rest/v/8" + +import { V3_SCHEMA, v3_baseCollectionSchema } from "./3" + +const v4_baseCollectionSchema = v3_baseCollectionSchema.extend({ + v: z.literal(4), + auth: z.union([HoppRESTAuth, HoppGQLAuth]), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +const V4_SCHEMA: z.ZodType = + v4_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(V4_SCHEMA)), + }) + +export default defineVersion({ + initial: false, + schema: V4_SCHEMA, + // @ts-expect-error + up(old: z.infer) { + return { + ...old, + v: 4 as const, + } + }, +}) diff --git a/packages/hoppscotch-data/src/graphql/index.ts b/packages/hoppscotch-data/src/graphql/index.ts index 5144712c..56f2dd64 100644 --- a/packages/hoppscotch-data/src/graphql/index.ts +++ b/packages/hoppscotch-data/src/graphql/index.ts @@ -6,6 +6,7 @@ import V3_VERSION from "./v/3" import V4_VERSION from "./v/4" import V5_VERSION from "./v/5" import V6_VERSION from "./v/6" +import V7_VERSION from "./v/7" export { HoppGQLAuthBasic, @@ -16,16 +17,17 @@ export { export { HoppGQLAuthAPIKey } from "./v/4" -export { GQLHeader, HoppGQLAuth, HoppGQLAuthOAuth2 } from "./v/6" +export { GQLHeader } from "./v/6" +export { HoppGQLAuth, HoppGQLAuthOAuth2 } from "./v/7" -export const GQL_REQ_SCHEMA_VERSION = 6 +export const GQL_REQ_SCHEMA_VERSION = 7 const versionedObject = z.object({ v: z.number(), }) export const HoppGQLRequest = createVersionedEntity({ - latestVersion: 6, + latestVersion: 7, versionMap: { 1: V1_VERSION, 2: V2_VERSION, @@ -33,6 +35,7 @@ export const HoppGQLRequest = createVersionedEntity({ 4: V4_VERSION, 5: V5_VERSION, 6: V6_VERSION, + 7: V7_VERSION, }, getVersion(x) { const result = versionedObject.safeParse(x) diff --git a/packages/hoppscotch-data/src/graphql/v/3.ts b/packages/hoppscotch-data/src/graphql/v/3.ts index 60fb87ec..d1f9b02b 100644 --- a/packages/hoppscotch-data/src/graphql/v/3.ts +++ b/packages/hoppscotch-data/src/graphql/v/3.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { defineVersion } from "verzod" -import { HoppRESTAuthOAuth2 } from "../../rest" +import { HoppRESTAuthOAuth2 } from "../../rest/v/3" import { HoppGQLAuthAPIKey, HoppGQLAuthBasic, @@ -12,7 +12,7 @@ import { V2_SCHEMA, } from "./2" -export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest" +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/3" export type HoppGqlAuthOAuth2 = z.infer diff --git a/packages/hoppscotch-data/src/graphql/v/4.ts b/packages/hoppscotch-data/src/graphql/v/4.ts index 71a0933f..ca62b010 100644 --- a/packages/hoppscotch-data/src/graphql/v/4.ts +++ b/packages/hoppscotch-data/src/graphql/v/4.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { defineVersion } from "verzod" -import { HoppRESTAuthOAuth2 } from "../../rest" +import { HoppRESTAuthOAuth2 } from "../../rest/v/5" import { HoppGQLAuthAPIKey as HoppGQLAuthAPIKeyOld, HoppGQLAuthBasic, @@ -12,7 +12,7 @@ import { } from "./2" import { V3_SCHEMA } from "./3" -export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest" +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/5" export const HoppGQLAuthAPIKey = HoppGQLAuthAPIKeyOld.extend({ addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), diff --git a/packages/hoppscotch-data/src/graphql/v/6.ts b/packages/hoppscotch-data/src/graphql/v/6.ts index 66b41e26..24255eba 100644 --- a/packages/hoppscotch-data/src/graphql/v/6.ts +++ b/packages/hoppscotch-data/src/graphql/v/6.ts @@ -2,13 +2,13 @@ import { defineVersion } from "verzod" import { z } from "zod" import { HoppRESTAuthAWSSignature } from "./../../rest/v/7" import { - HoppGQLAuthAPIKey, HoppGQLAuthBasic, HoppGQLAuthBearer, HoppGQLAuthInherit, HoppGQLAuthNone, } from "./2" import { HoppGQLAuthOAuth2, V5_SCHEMA } from "./5" +import { HoppGQLAuthAPIKey } from "./4" export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/7" diff --git a/packages/hoppscotch-data/src/graphql/v/7.ts b/packages/hoppscotch-data/src/graphql/v/7.ts new file mode 100644 index 00000000..2d72008d --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/7.ts @@ -0,0 +1,49 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, +} from "./2" +import { HoppGQLAuthAPIKey } from "./4" +import { HoppGQLAuthAWSSignature, V6_SCHEMA } from "./6" +import { HoppRESTAuthOAuth2 } from "./../../rest/v/7" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest/v/7" + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthInherit, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppRESTAuthOAuth2, + HoppGQLAuthAPIKey, + HoppGQLAuthAWSSignature, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V7_SCHEMA = V6_SCHEMA.extend({ + v: z.literal(7), + auth: HoppGQLAuth, +}) + +export default defineVersion({ + schema: V7_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: 7 as const, + // no need to update anything for HoppGQLAuth, because we loosened the previous schema by making `clientSecret` optional + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/index.ts b/packages/hoppscotch-data/src/rest/index.ts index 0c26cb35..16b98415 100644 --- a/packages/hoppscotch-data/src/rest/index.ts +++ b/packages/hoppscotch-data/src/rest/index.ts @@ -13,9 +13,8 @@ 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 { HoppRESTParams, HoppRESTHeaders } from "./v/7" +import V7_VERSION, { HoppRESTHeaders, HoppRESTParams } from "./v/7" +import V8_VERSION, { HoppRESTAuth } from "./v/8" export * from "./content-types" @@ -27,32 +26,37 @@ export { HoppRESTAuthNone, HoppRESTReqBodyFormData, } from "./v/1" -export { - ClientCredentialsGrantTypeParams, - ImplicitOauthFlowParams, - PasswordGrantTypeParams, -} from "./v/3" export { HoppRESTRequestVariables } from "./v/2" + +export { ImplicitOauthFlowParams } from "./v/3" + export { HoppRESTAuthAPIKey } from "./v/4" export { AuthCodeGrantTypeParams } from "./v/5" + export { HoppRESTReqBody } from "./v/6" + export { - HoppRESTAuth, HoppRESTAuthAWSSignature, - HoppRESTAuthOAuth2, HoppRESTHeaders, HoppRESTParams, } from "./v/7" +export { + ClientCredentialsGrantTypeParams, + HoppRESTAuth, + HoppRESTAuthOAuth2, + PasswordGrantTypeParams, +} from "./v/8" + const versionedObject = z.object({ // v is a stringified number v: z.string().regex(/^\d+$/).transform(Number), }) export const HoppRESTRequest = createVersionedEntity({ - latestVersion: 7, + latestVersion: 8, versionMap: { 0: V0_VERSION, 1: V1_VERSION, @@ -62,6 +66,7 @@ export const HoppRESTRequest = createVersionedEntity({ 5: V5_VERSION, 6: V6_VERSION, 7: V7_VERSION, + 8: V8_VERSION, }, getVersion(data) { // For V1 onwards we have the v string storing the number @@ -103,7 +108,7 @@ const HoppRESTRequestEq = Eq.struct({ ), }) -export const RESTReqSchemaVersion = "7" +export const RESTReqSchemaVersion = "8" export type HoppRESTParam = HoppRESTRequest["params"][number] export type HoppRESTHeader = HoppRESTRequest["headers"][number] diff --git a/packages/hoppscotch-data/src/rest/v/8.ts b/packages/hoppscotch-data/src/rest/v/8.ts new file mode 100644 index 00000000..46fa7068 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/8.ts @@ -0,0 +1,79 @@ +import { defineVersion } from "verzod" +import { z } from "zod" + +import { + HoppRESTAuthAPIKey, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "./1" + +import { + ClientCredentialsGrantTypeParams as ClientCredentialsGrantTypeParamsOld, + ImplicitOauthFlowParams, + PasswordGrantTypeParams as PasswordGrantTypeParamsOld, +} from "./3" + +import { + AuthCodeGrantTypeParams, + HoppRESTAuthAWSSignature, + V7_SCHEMA, +} from "./7" + +export const ClientCredentialsGrantTypeParams = + ClientCredentialsGrantTypeParamsOld.extend({ + clientSecret: z.string().optional(), + }) + +export const PasswordGrantTypeParams = PasswordGrantTypeParamsOld.extend({ + clientSecret: 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, + HoppRESTAuthAWSSignature, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer + +const V8_SCHEMA = V7_SCHEMA.extend({ + v: z.literal("8"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + schema: V8_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: "8" as const, + // no need to update anything for HoppRESTAuth, because we loosened the previous schema by making `clientSecret` optional + } + }, +}) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts index 417d4219..e57a1113 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts @@ -129,7 +129,7 @@ function exportedCollectionToHoppCollection( return { id: restCollection.id, - v: 3, + v: 4, name: restCollection.name, folders: restCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -189,7 +189,7 @@ function exportedCollectionToHoppCollection( return { id: gqlCollection.id, - v: 3, + v: 4, name: gqlCollection.name, folders: gqlCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -377,7 +377,7 @@ function setupUserCollectionCreatedSubscription() { name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 3, + v: 4, auth: data.auth, headers: addDescriptionField(data.headers), }) @@ -385,7 +385,7 @@ function setupUserCollectionCreatedSubscription() { name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 3, + v: 4, auth: data.auth, headers: addDescriptionField(data.headers), })