api-client/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts
James George 27b817f627
feat: migrate ESLint to v9 across packages (#5773)
Co-authored-by: curiouscorrelation <curiouscorrelation@gmail.com>
2026-01-20 14:48:55 +05:30

558 lines
14 KiB
TypeScript

import * as E from "fp-ts/Either"
import { BehaviorSubject, Subject } from "rxjs"
import { Ref, ref, watch } from "vue"
import { content } from "@hoppscotch/kernel"
import { Io } from "@hoppscotch/common/kernel/io"
import { listen } from "@tauri-apps/api/event"
import { getService } from "@hoppscotch/common/modules/dioc"
import { parseBodyAsJSON } from "@hoppscotch/common/helpers/functional/json"
import { AuthEvent, AuthPlatformDef } from "@hoppscotch/common/platform/auth"
import { PersistenceService } from "@hoppscotch/common/services/persistence"
import { KernelInterceptorService } from "@hoppscotch/common/services/kernel-interceptor.service"
import Login from "@app/components/Login.vue"
import { getAllowedAuthProviders, updateUserDisplayName } from "./api"
export type HoppUserWithAuthDetail = {
uid: string
displayName: string | null
email: string | null
photoURL: string | null
provider?: string
accessToken?: string
refreshToken?: string
emailVerified: boolean
}
interface GQLResponse {
data?: {
me: HoppUserWithAuthDetail
}
errors?: Array<{ message: string }>
}
export const authEvents$ = new Subject<AuthEvent>()
export const currentUser$ = new BehaviorSubject<HoppUserWithAuthDetail | null>(
null
)
export const probableUser$ = new BehaviorSubject<HoppUserWithAuthDetail | null>(
null
)
const isGettingInitialUser: Ref<null | boolean> = ref(null)
const persistenceService = getService(PersistenceService)
const interceptorService = getService(KernelInterceptorService)
async function logout() {
const { response } = interceptorService.execute({
id: Date.now(),
url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`,
version: "HTTP/1.1",
method: "GET",
})
await response
await persistenceService.removeLocalConfig("refresh_token")
await persistenceService.removeLocalConfig("access_token")
}
async function signInUserWithGithubFB() {
await Io.openExternalLink({
url: `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/github?redirect_uri=desktop`,
})
}
async function signInUserWithGoogleFB() {
await Io.openExternalLink({
url: `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/google?redirect_uri=desktop`,
})
}
async function signInUserWithMicrosoftFB() {
await Io.openExternalLink({
url: `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/microsoft?redirect_uri=desktop`,
})
}
async function getInitialUserDetails(): Promise<
GQLResponse | { error: string }
> {
try {
const accessToken = await persistenceService.getLocalConfig("access_token")
const refreshToken =
await persistenceService.getLocalConfig("refresh_token")
if (!accessToken || !refreshToken) {
return { error: "auth/cookies_not_found" }
}
const { response } = interceptorService.execute({
id: Date.now(),
url: `${import.meta.env.VITE_BACKEND_GQL_URL}`,
method: "POST",
version: "HTTP/1.1",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
content: content.json({
query: `query Me {
me {
uid
displayName
email
photoURL
isAdmin
createdOn
}
}`,
}),
})
const responseBytes = await response
if (E.isLeft(responseBytes)) {
return { error: "auth/cookies_not_found" }
}
const res = parseBodyAsJSON<GQLResponse>(responseBytes.right.body)
if (res._tag == "Some" && res.value.data?.me) {
return {
data: {
me: {
...res.value.data.me,
refreshToken,
accessToken,
emailVerified: true,
},
},
}
}
return { error: "auth/cookies_not_found" }
} catch (_error) {
return { error: "auth/cookies_not_found" }
}
}
async function setUser(user: HoppUserWithAuthDetail | null) {
const accessToken = await persistenceService.getLocalConfig("access_token")
const refreshToken = await persistenceService.getLocalConfig("refresh_token")
if (!accessToken || !refreshToken) return null
const userWithToken =
user && accessToken && refreshToken
? {
...user,
accessToken,
refreshToken,
}
: null
currentUser$.next(userWithToken)
probableUser$.next(userWithToken)
await persistenceService.setLocalConfig(
"login_state",
JSON.stringify(userWithToken)
)
}
export async function setInitialUser() {
isGettingInitialUser.value = true
const res = await getInitialUserDetails()
if ("error" in res) {
await setUser(null)
isGettingInitialUser.value = false
return
}
if (res.errors?.[0]?.message === "Unauthorized") {
const isRefreshSuccess = await refreshToken()
if (isRefreshSuccess) {
await setInitialUser()
} else {
await setUser(null)
isGettingInitialUser.value = false
}
return
}
if (res.data?.me) {
const hoppBackendUser = res.data.me
const accessToken = await persistenceService.getLocalConfig("access_token")
if (!accessToken) return null
if (!accessToken) {
await setUser(null)
isGettingInitialUser.value = false
return
}
const HoppUserWithAuthDetail: HoppUserWithAuthDetail = {
uid: hoppBackendUser.uid,
displayName: hoppBackendUser.displayName,
email: hoppBackendUser.email,
photoURL: hoppBackendUser.photoURL,
emailVerified: true,
accessToken,
}
await setUser(HoppUserWithAuthDetail)
isGettingInitialUser.value = false
authEvents$.next({
event: "login",
user: HoppUserWithAuthDetail,
})
}
}
async function refreshToken() {
try {
const refreshToken =
await persistenceService.getLocalConfig("refresh_token")
if (!refreshToken) return null
const { response } = interceptorService.execute({
id: Date.now(),
url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`,
method: "GET",
version: "HTTP/1.1",
headers: {
Authorization: `Bearer ${refreshToken}`,
},
})
const res = await response
if (E.isLeft(res)) return false
await setAuthCookies(res.right.headers)
const isSuccessful = res.right.status === 200
if (isSuccessful && currentUser$.value) {
authEvents$.next({
event: "login",
user: {
uid: currentUser$.value.uid,
displayName: currentUser$.value.displayName,
email: currentUser$.value.email,
photoURL: currentUser$.value.photoURL,
emailVerified: currentUser$.value.emailVerified,
},
})
}
return isSuccessful
} catch (_err) {
return false
}
}
async function sendMagicLink(email: string) {
const { response } = interceptorService.execute({
id: Date.now(),
url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/signin?origin=desktop`,
version: "HTTP/1.1",
method: "POST",
headers: {
"Content-Type": "application/json",
},
content: content.json({ email }),
})
const res = await response
if (E.isLeft(res)) throw new Error("Failed to send magic link")
if (res.right.data && res.right.data.deviceIdentifier) {
await persistenceService.setLocalConfig(
"deviceIdentifier",
res.right.data.deviceIdentifier
)
} else {
throw new Error("Does not get device identifier")
}
return res.right.data
}
async function setAuthCookies(headers: Headers) {
const cookieHeader = headers.get("set-cookie")
if (!cookieHeader) return
const accessTMatch = cookieHeader.match(/access_token=([^;,\s]+)/)
const refreshTMatch = cookieHeader.match(/refresh_token=([^;,\s]+)/)
if (accessTMatch) {
await persistenceService.setLocalConfig("access_token", accessTMatch[1])
}
if (refreshTMatch) {
await persistenceService.setLocalConfig("refresh_token", refreshTMatch[1])
}
}
export const def: AuthPlatformDef = {
customLoginSelectorUI: Login,
getCurrentUserStream: () => currentUser$,
getAuthEventsStream: () => authEvents$,
getProbableUserStream: () => probableUser$,
getCurrentUser: () => currentUser$.value,
getProbableUser: () => probableUser$.value,
async getAllowedAuthProviders() {
return await getAllowedAuthProviders()
},
getBackendHeaders() {
const accessToken = currentUser$.value?.accessToken
return accessToken
? {
Authorization: `Bearer ${accessToken}`,
}
: ({} as Record<string, string>)
},
getGQLClientOptions() {
const accessToken = currentUser$.value?.accessToken
return {
// For GraphQL subscriptions via WebSocket
connectionParams: accessToken
? {
Authorization: `Bearer ${accessToken}`,
}
: undefined,
// For regular HTTP queries
fetchOptions: {
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
},
}
},
axiosPlatformConfig() {
const accessToken = currentUser$.value?.accessToken
return {
headers: accessToken
? {
Authorization: `Bearer ${accessToken}`,
}
: {},
}
},
willBackendHaveAuthError() {
return !currentUser$.value
},
onBackendGQLClientShouldReconnect(func) {
authEvents$.subscribe((event) => {
if (event.event === "login" || event.event === "logout") {
func()
}
})
},
isSignInWithEmailLink(url: string) {
const urlObject = new URL(url)
const searchParams = new URLSearchParams(urlObject.search)
return searchParams.has("token")
},
async processMagicLink(): Promise<void> {
return Promise.resolve()
},
getDevOptsBackendIDToken() {
return null
},
async performAuthInit() {
const loginState = await persistenceService.getLocalConfig("login_state")
const probableUser = JSON.parse(loginState ?? "null")
probableUser$.next(probableUser)
await setInitialUser()
await listen<string>(
"scheme-request-received",
async (event: { payload: string }) => {
const deepLink = event.payload
const params = new URLSearchParams(deepLink.split("?")[1])
const accessToken = params.get("access_token")
const refreshToken = params.get("refresh_token")
const token = params.get("token")
if (accessToken && refreshToken) {
await persistenceService.setLocalConfig("access_token", accessToken)
await persistenceService.setLocalConfig("refresh_token", refreshToken)
return
}
if (token) {
await persistenceService.setLocalConfig("verifyToken", token)
await this.signInWithEmailLink("", "")
await setInitialUser()
}
}
)
},
waitProbableLoginToConfirm() {
return new Promise<void>((resolve, reject) => {
if (this.getCurrentUser()) {
resolve()
}
if (!probableUser$.value) reject(new Error("no_probable_user"))
const unwatch = watch(isGettingInitialUser, (val) => {
if (val === true || val === false) {
resolve()
unwatch()
}
})
})
},
async signInWithEmail(email: string) {
await sendMagicLink(email)
},
async verifyEmailAddress() {
return
},
async signInUserWithGoogle() {
await signInUserWithGoogleFB()
},
async signInUserWithGithub() {
await signInUserWithGithubFB()
return undefined
},
async signInUserWithMicrosoft() {
await signInUserWithMicrosoftFB()
},
async signInWithEmailLink(_email: string, url: string) {
const deviceIdentifier =
await persistenceService.getLocalConfig("deviceIdentifier")
if (!deviceIdentifier) {
throw new Error(
"Device Identifier not found, you can only signin from the browser you generated the magic link"
)
}
const urlObject = new URL(url)
const searchParams = new URLSearchParams(urlObject.search)
const token = searchParams.get("token")
const verifyToken =
token || (await persistenceService.getLocalConfig("verifyToken"))
const { response } = interceptorService.execute({
id: Date.now(),
url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`,
version: "HTTP/1.1",
method: "POST",
headers: {
"Content-Type": "application/json",
},
content: content.json({
token: verifyToken,
deviceIdentifier,
}),
})
const res = await response
if (E.isLeft(res)) throw new Error("Failed to verify email link")
await setAuthCookies(res.right.headers)
await persistenceService.removeLocalConfig("deviceIdentifier")
await persistenceService.removeLocalConfig("verifyToken")
},
// Removed parameter from here because we do not use it
async setEmailAddress() {
return
},
async setDisplayName(name: string) {
if (!name) return E.left("USER_NAME_CANNOT_BE_EMPTY")
if (!currentUser$.value) return E.left("NO_USER_LOGGED_IN")
const res = await updateUserDisplayName(name)
if (E.isRight(res)) {
const user = currentUser$.value
if (user) {
await setUser({
...user,
displayName: res.right.updateDisplayName.displayName ?? null,
})
}
return E.right(undefined)
}
return E.left(res.left)
},
async signOutUser() {
await logout()
probableUser$.next(null)
currentUser$.next(null)
await persistenceService.removeLocalConfig("login_state")
authEvents$.next({
event: "logout",
})
},
async refreshAuthToken() {
const refreshed = await refreshToken()
return refreshed ?? false
},
/**
* Verifies if the current user's authentication tokens are valid
* @returns True if tokens are valid, false otherwise
*/
async verifyAuthTokens() {
const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL
const { response } = interceptorService.execute({
id: Date.now(),
url: `${BACKEND_API_URL}/auth/verify-token`,
method: "GET",
version: "HTTP/1.1",
headers: {
"Content-Type": "application/json",
...this.getBackendHeaders(),
},
})
const res = await response
if (E.isLeft(res)) return false
const parsed = parseBodyAsJSON<{ isValid: boolean }>(res.right.body)
if (parsed._tag === "Some" && parsed.value.isValid) {
return true
}
const refreshed = await refreshToken()
return refreshed ?? false
},
}