From 4156551b24cf858a5d56d6e8f03863a16980a437 Mon Sep 17 00:00:00 2001 From: Gusram Date: Thu, 30 May 2024 22:53:59 +0800 Subject: [PATCH] feat(desktop): implement backend wrapper auth --- .env.example | 1 + .../hoppscotch-selfhost-desktop/README.md | 17 ++ .../hoppscotch-selfhost-desktop/package.json | 13 +- .../src-tauri/Cargo.toml | 1 + .../src-tauri/src/main.rs | 1 + .../src-tauri/tauri.conf.json | 2 +- .../src/helpers/GQLClient.ts | 168 ++++++++++++++++++ .../src/helpers/ws_wrapper.ts | 154 ++++++++++++++++ .../src/platform/auth.ts | 50 +++++- 9 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 packages/hoppscotch-selfhost-desktop/src/helpers/GQLClient.ts create mode 100644 packages/hoppscotch-selfhost-desktop/src/helpers/ws_wrapper.ts diff --git a/.env.example b/.env.example index d8547a701..b1d0140ad 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,7 @@ RATE_LIMIT_MAX=100 # Max requests per IP # Base URLs +VITE_BACKEND_LOGIN_API_URL=http://localhost:5444 VITE_BASE_URL=http://localhost:3000 VITE_SHORTCODE_BASE_URL=http://localhost:3000 VITE_ADMIN_URL=http://localhost:3100 diff --git a/packages/hoppscotch-selfhost-desktop/README.md b/packages/hoppscotch-selfhost-desktop/README.md index e6b0bd5e8..f8d96297e 100644 --- a/packages/hoppscotch-selfhost-desktop/README.md +++ b/packages/hoppscotch-selfhost-desktop/README.md @@ -14,3 +14,20 @@ Since TypeScript cannot handle type information for `.vue` imports, they are shi 2. Reload the VS Code window by running `Developer: Reload Window` from the command palette. You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471). + +## Building + +### Prequisites +- Install Rust: `curl https://sh.rustup.rs -sSf | sh` +- libsoup2.4-dev installed if Linux: `sudo apt install libsoup2.4-dev` +- Node v18.20 installed and currently active if you're using nvm + +### Build Instruction + +1. Install latest pnpm `curl -fsSL https://get.pnpm.io/install.sh | sh -` or upgrade `pnpm add -g pnpm` +2. Setup the .env of the root project folder, you should deploy the self hosted backend first +3. Run `pnpm install` on root project folder +4. Run `pnpm dev:gql-codegen` on this folder +5. Run `pnpm tauri dev` to run debug mode (optional) +6. Run `pnpm tauri build` to build release mode + - `pnpm tauri build --target aarch64-apple-darwin` for Apple Sillicon \ No newline at end of file diff --git a/packages/hoppscotch-selfhost-desktop/package.json b/packages/hoppscotch-selfhost-desktop/package.json index 852cd569e..e15f92240 100644 --- a/packages/hoppscotch-selfhost-desktop/package.json +++ b/packages/hoppscotch-selfhost-desktop/package.json @@ -17,6 +17,7 @@ "@fontsource-variable/material-symbols-rounded": "5.0.16", "@fontsource-variable/roboto-mono": "5.0.16", "@hoppscotch/common": "workspace:^", + "@hoppscotch/data": "workspace:^", "@platform/auth": "0.1.106", "@tauri-apps/api": "1.5.1", "@tauri-apps/cli": "1.5.6", @@ -36,7 +37,13 @@ "tauri-plugin-store-api": "0.0.0", "util": "0.12.5", "vue": "3.3.9", - "workbox-window": "6.6.0" + "workbox-window": "6.6.0", + "zod": "3.22.4", + "@urql/core": "^4.1.1", + "cookie": "^0.5.0", + "subscriptions-transport-ws": "^0.11.0", + "tauri-plugin-websocket-api": "github:tauri-apps/tauri-plugin-websocket#v1", + "wonka": "^6.3.4" }, "devDependencies": { "@graphql-codegen/add": "5.0.0", @@ -76,6 +83,8 @@ "vite-plugin-pwa": "0.13.1", "vite-plugin-static-copy": "0.12.0", "vite-plugin-vue-layouts": "0.7.0", - "vue-tsc": "1.8.8" + "vue-tsc": "1.8.8", + "@types/cookie": "^0.5.1" + } } diff --git a/packages/hoppscotch-selfhost-desktop/src-tauri/Cargo.toml b/packages/hoppscotch-selfhost-desktop/src-tauri/Cargo.toml index 1ec9c3b17..a6bd3c0b9 100644 --- a/packages/hoppscotch-selfhost-desktop/src-tauri/Cargo.toml +++ b/packages/hoppscotch-selfhost-desktop/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ tauri = { version = "1.5.3", features = [ ] } tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } tauri-plugin-deep-link = { git = "https://github.com/FabianLars/tauri-plugin-deep-link", branch = "main" } +tauri-plugin-websocket = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } tauri-plugin-window-state = "0.1.0" reqwest = "0.11.22" serde_json = "1.0.108" diff --git a/packages/hoppscotch-selfhost-desktop/src-tauri/src/main.rs b/packages/hoppscotch-selfhost-desktop/src-tauri/src/main.rs index 34171068b..46c8ea17c 100644 --- a/packages/hoppscotch-selfhost-desktop/src-tauri/src/main.rs +++ b/packages/hoppscotch-selfhost-desktop/src-tauri/src/main.rs @@ -21,6 +21,7 @@ fn main() { tauri_plugin_deep_link::prepare("io.hoppscotch.desktop"); tauri::Builder::default() + .plugin(tauri_plugin_websocket::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_store::Builder::default().build()) .setup(|app| { diff --git a/packages/hoppscotch-selfhost-desktop/src-tauri/tauri.conf.json b/packages/hoppscotch-selfhost-desktop/src-tauri/tauri.conf.json index c5cfa3540..2e8e2ae67 100644 --- a/packages/hoppscotch-selfhost-desktop/src-tauri/tauri.conf.json +++ b/packages/hoppscotch-selfhost-desktop/src-tauri/tauri.conf.json @@ -49,7 +49,7 @@ "targets": "all" }, "security": { - "csp": "none" + "csp": null }, "updater": { "active": false diff --git a/packages/hoppscotch-selfhost-desktop/src/helpers/GQLClient.ts b/packages/hoppscotch-selfhost-desktop/src/helpers/GQLClient.ts new file mode 100644 index 000000000..2ef04365e --- /dev/null +++ b/packages/hoppscotch-selfhost-desktop/src/helpers/GQLClient.ts @@ -0,0 +1,168 @@ +import { ref } from "vue" +import { makeResult, makeErrorResult, Exchange, Operation } from "@urql/core" +import { makeFetchBody, makeFetchOptions } from "@urql/core/internal" +import { filter, make, merge, mergeMap, pipe, takeUntil, map } from "wonka" +import { gqlClientError$ } from "@hoppscotch/common/helpers/backend/GQLClient" +import { Store } from "tauri-plugin-store-api" +import { Body, getClient } from "@tauri-apps/api/http" +import { parse, serialize } from "cookie" +import { SubscriptionClient } from "subscriptions-transport-ws" +import { platform } from "@hoppscotch/common/platform" +import WSWrapper from "./ws_wrapper" + +const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat" + +export async function addCookieToFetchHeaders( + store: Store, + headers: HeadersInit = {} +) { + try { + const accessToken = await store.get<{ value: string }>("access_token") + const refreshToken = await store.get<{ value: string }>("refresh_token") + + if (accessToken?.value && refreshToken?.value) { + // Assert headers as an indexable type + const headersIndexable = headers as { [key: string]: string } + const existingCookies = parse(headersIndexable["Cookie"] || "") + + if (!existingCookies.access_token) { + existingCookies.access_token = accessToken.value + } + if (!existingCookies.refresh_token) { + existingCookies.refresh_token = refreshToken.value + } + + // Serialize the cookies back into the headers + const serializedCookies = Object.entries(existingCookies) + .map(([name, value]) => serialize(name, value)) + .join("; ") + headersIndexable["Cookie"] = serializedCookies + } + + return headers + } catch (error) { + console.error("error while injecting cookie") + } +} + +function createHttpSource(operation: Operation, store: Store) { + return make(({ next, complete }) => { + getClient().then(async (httpClient) => { + const fetchOptions = makeFetchOptions(operation) + let headers = fetchOptions.headers + headers = await addCookieToFetchHeaders(store, headers) + + const fetchBody = makeFetchBody(operation) + httpClient + .post(operation.context.url, Body.json(fetchBody), { + headers, + }) + .then((result) => { + next(result.data) + complete() + }) + .catch((error) => { + next(makeErrorResult(operation, error)) + complete() + }) + }) + return () => {} + }) +} + +export const tauriGQLFetchExchange = + (store: Store): Exchange => + ({ forward }) => + (ops$) => { + const subscriptionResults$ = pipe( + ops$, + filter((op) => op.kind === "query" || op.kind === "mutation"), + mergeMap((operation) => { + const { key, context } = operation + + const teardown$ = pipe( + ops$, + filter((op: Operation) => op.kind === "teardown" && op.key === key) + ) + + const source = createHttpSource(operation, store) + + return pipe( + source, + takeUntil(teardown$), + map((result) => makeResult(operation, result as any)) + ) + }) + ) + + const forward$ = pipe( + ops$, + filter( + (op: Operation) => op.kind === "teardown" || op.kind != "subscription" + ), + forward + ) + + return merge([subscriptionResults$, forward$]) + } + +const createSubscriptionClient = () => { + return new SubscriptionClient( + import.meta.env.VITE_BACKEND_WS_URL, + { + reconnect: true, + connectionParams: () => platform.auth.getBackendHeaders(), + connectionCallback(error) { + if (error?.length > 0) { + gqlClientError$.next({ + type: "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT", + errors: error, + }) + } + }, + wsOptionArguments: [ + { + store: new Store(APP_DATA_PATH), + }, + ], + }, + WSWrapper + ) +} + +let subscriptionClient: SubscriptionClient | null + +const isBackendGQLEventAdded = ref(false) + +const resetSubscriptionClient = () => { + if (subscriptionClient) { + subscriptionClient.close() + } + subscriptionClient = createSubscriptionClient() + if (!isBackendGQLEventAdded.value) { + subscriptionClient.onConnected(() => { + platform.auth.onBackendGQLClientShouldReconnect(() => { + const currentUser = platform.auth.getCurrentUser() + + if (currentUser && subscriptionClient) { + subscriptionClient?.client?.close() + } + + if (currentUser && !subscriptionClient) { + resetSubscriptionClient() + } + + if (!currentUser && subscriptionClient) { + subscriptionClient.close() + resetSubscriptionClient() + } + }) + }) + isBackendGQLEventAdded.value = true + } +} + +export const getSubscriptionClient = () => { + if (!subscriptionClient) resetSubscriptionClient() + return subscriptionClient +} \ No newline at end of file diff --git a/packages/hoppscotch-selfhost-desktop/src/helpers/ws_wrapper.ts b/packages/hoppscotch-selfhost-desktop/src/helpers/ws_wrapper.ts new file mode 100644 index 000000000..110701f2a --- /dev/null +++ b/packages/hoppscotch-selfhost-desktop/src/helpers/ws_wrapper.ts @@ -0,0 +1,154 @@ +import { Store } from "tauri-plugin-store-api" +import TauriWebSocket, { + Message, + ConnectionConfig, +} from "tauri-plugin-websocket-api" +import { addCookieToFetchHeaders } from "./GQLClient" + +/** + * This is a wrapper around tauri-plugin-websocket-api with cookie injection support. This is required because + * subscriptions-transport-ws client expects a custom websocket implementation in the shape of native browser WebSocket. + */ + +export default class WebSocketWrapper extends EventTarget implements WebSocket { + public client: TauriWebSocket | undefined + private tauriWebSocketConfig: + | (ConnectionConfig & { store: Store }) + | undefined + private isConnected: boolean = false + binaryType: BinaryType = "blob" + extensions = "" + onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null + onerror: ((this: WebSocket, ev: Event) => any) | null = null + onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null = null + onopen: ((this: WebSocket, ev: Event) => any) | null = null + protocol = "" + url: string + + public static readonly CONNECTING = 0 + public static readonly OPEN = 1 + public static readonly CLOSING = 2 + public static readonly CLOSED = 3 + + readonly CONNECTING = 0 + readonly OPEN = 1 + readonly CLOSING = 2 + readonly CLOSED = 3 + + constructor( + url: string, + protocols?: string | string[], + config?: ConnectionConfig & { store: Store } + ) { + super() + this.url = url + this.tauriWebSocketConfig = config + this.setup() + } + + private async setup() { + if (this.tauriWebSocketConfig?.store) { + const headersStringified = + this.tauriWebSocketConfig.headers || ("{}" as any) + let headers = JSON.parse(headersStringified) + headers = await addCookieToFetchHeaders( + this.tauriWebSocketConfig.store, + headers + ) + this.tauriWebSocketConfig = { + ...this.tauriWebSocketConfig, + headers, + } + } + this.client = await TauriWebSocket.connect(this.url, { + headers: { + "sec-websocket-protocol": "graphql-ws", + ...this.tauriWebSocketConfig?.headers, + }, + }).catch((error) => { + this.isConnected = false + if (this.onerror) { + this.onerror(new Event("error")) + } + throw new Error(error) + }) + + this.isConnected = true + + this.client.addListener(this.handleMessage.bind(this)) + if (this.onopen) { + this.onopen(new Event("open")) + } + } + + get readyState(): number { + return this.client + ? this.isConnected + ? this.OPEN + : this.CLOSED + : this.CONNECTING + } + + get bufferedAmount(): number { + // TODO implement + return 0 + } + + close(code?: number, reason?: string): void { + this.client?.disconnect().then(() => { + if (this.onclose) { + this.onclose(new CloseEvent("close")) + } + }) + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { + if ( + typeof data === "string" || + data instanceof ArrayBuffer || + data instanceof Blob + ) { + this.client?.send(data as string).catch((error) => { + console.error("error while sending data", data) + if (this.onerror) { + this.onerror(new Event("error")) + } + }) + } else { + // TODO implement, drop the record for now + console.warn( + "WebSocketWrapper.send() not implemented for non-string data" + ) + } + } + + private handleMessage(message: Message): void { + switch (message.type) { + case "Close": { + if (this.onclose) { + this.onclose(new CloseEvent("close")) + } + return + } + case "Ping": { + this.client?.send("Pong").catch((error) => { + console.error("error while sending Pong data", message) + if (this.onerror) { + this.onerror(new Event("error")) + } + }) + return + } + default: { + if (this.onmessage) { + this.onmessage( + new MessageEvent("message", { + data: message.data, + origin: this.url, + }) + ) + } + } + } + } +} \ No newline at end of file diff --git a/packages/hoppscotch-selfhost-desktop/src/platform/auth.ts b/packages/hoppscotch-selfhost-desktop/src/platform/auth.ts index 0bbea986e..6511872fe 100644 --- a/packages/hoppscotch-selfhost-desktop/src/platform/auth.ts +++ b/packages/hoppscotch-selfhost-desktop/src/platform/auth.ts @@ -1,4 +1,5 @@ import { getService } from "@hoppscotch/common/modules/dioc" +import axios from "axios" import { AuthEvent, AuthPlatformDef, @@ -13,15 +14,50 @@ import { open } from '@tauri-apps/api/shell' import { BehaviorSubject, Subject } from "rxjs" import { Store } from "tauri-plugin-store-api" import { Ref, ref, watch } from "vue" +import { z } from "zod" +import * as E from "fp-ts/Either" +import { subscriptionExchange } from "@urql/core" +import { + getSubscriptionClient, + tauriGQLFetchExchange, +} from "../helpers/GQLClient" export const authEvents$ = new Subject() const currentUser$ = new BehaviorSubject(null) export const probableUser$ = new BehaviorSubject(null) -const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat" +export const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat" const persistenceService = getService(PersistenceService) +const expectedAllowedProvidersSchema = z.object({ + // currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML" + // keeping it as string to avoid backend accidentally breaking frontend when adding new providers + providers: z.array(z.string()), +}) + + +export const getAllowedAuthProviders = async () => { + try { + const res = await axios.get( + `${import.meta.env.VITE_BACKEND_API_URL}/auth/providers`, + { + withCredentials: true, + } + ) + + const parseResult = expectedAllowedProvidersSchema.safeParse(res.data) + + if (!parseResult.success) { + return E.left("SOMETHING_WENT_WRONG") + } + + return E.right(parseResult.data.providers) + } catch (_) { + return E.left("SOMETHING_WENT_WRONG") + } +} + async function logout() { let client = await getClient(); await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`) @@ -37,7 +73,7 @@ async function signInUserWithGithubFB() { } async function signInUserWithGoogleFB() { - await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/google?redirect_uri=desktop`); + await open(`${import.meta.env.VITE_BACKEND_LOGIN_API_URL}/authenticate`); } async function signInUserWithMicrosoftFB() { @@ -224,6 +260,15 @@ export const def: AuthPlatformDef = { }, getGQLClientOptions() { return { + exchanges: [ + subscriptionExchange({ + forwardSubscription(fetchBody) { + const subscriptionClient = getSubscriptionClient() + return subscriptionClient!.request(fetchBody) + }, + }), + tauriGQLFetchExchange(new Store(APP_DATA_PATH)), + ], fetchOptions: { credentials: "include", }, @@ -371,4 +416,5 @@ export const def: AuthPlatformDef = { event: "logout", }) }, + getAllowedAuthProviders, }