Files
hoppscotch/packages/hoppscotch-selfhost-desktop/src/platform/auth.ts

375 lines
9.6 KiB
TypeScript

import { getService } from "@hoppscotch/common/modules/dioc"
import {
AuthEvent,
AuthPlatformDef,
HoppUser,
} from "@hoppscotch/common/platform/auth"
import {
PersistenceService
} from "@hoppscotch/common/services/persistence"
import { listen } from '@tauri-apps/api/event'
import { Body, getClient } from '@tauri-apps/api/http'
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"
export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
const persistenceService = getService(PersistenceService)
async function logout() {
let client = await getClient();
await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`)
const store = new Store(APP_DATA_PATH)
await store.set("refresh_token", {})
await store.set("access_token", {})
await store.save()
}
async function signInUserWithGithubFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/github?redirect_uri=desktop`);
}
async function signInUserWithGoogleFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/google?redirect_uri=desktop`);
}
async function signInUserWithMicrosoftFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/microsoft?redirect_uri=desktop`);
}
async function getInitialUserDetails() {
const store = new Store(APP_DATA_PATH);
try {
const accessToken = await store.get("access_token")
let client = await getClient()
let body = {
query: `query Me {
me {
uid
displayName
email
photoURL
isAdmin
createdOn
}
}`}
let res = await client.post(`${import.meta.env.VITE_BACKEND_GQL_URL}`,
Body.json(body), {
headers: {
"Cookie": `access_token=${accessToken.value}`,
}
}
)
return res.data
} catch (error) {
let res = {
error: "auth/cookies_not_found"
}
return res
}
}
const isGettingInitialUser: Ref<null | boolean> = ref(null)
function setUser(user: HoppUser | null) {
currentUser$.next(user)
probableUser$.next(user)
persistenceService.setLocalConfig("login_state", JSON.stringify(user))
}
async function setInitialUser() {
isGettingInitialUser.value = true
const res = await getInitialUserDetails()
const error = res.errors && res.errors[0]
// no cookies sent. so the user is not logged in
if (error && error.message === "auth/cookies_not_found") {
setUser(null)
isGettingInitialUser.value = false
return
}
if (error && error.message === "user/not_found") {
setUser(null)
isGettingInitialUser.value = false
return
}
// cookies sent, but it is expired, we need to refresh the token
if (error && error.message === "Unauthorized") {
const isRefreshSuccess = await refreshToken()
if (isRefreshSuccess) {
setInitialUser()
} else {
setUser(null)
isGettingInitialUser.value = false
}
return
}
// no errors, we have a valid user
if (res.data && res.data.me) {
const hoppBackendUser = res.data.me
const hoppUser: HoppUser = {
uid: hoppBackendUser.uid,
displayName: hoppBackendUser.displayName,
email: hoppBackendUser.email,
photoURL: hoppBackendUser.photoURL,
// all our signin methods currently guarantees the email is verified
emailVerified: true,
}
setUser(hoppUser)
isGettingInitialUser.value = false
authEvents$.next({
event: "login",
user: hoppUser,
})
return
}
}
async function refreshToken() {
const store = new Store(APP_DATA_PATH);
try {
const refreshToken = await store.get("refresh_token")
let client = await getClient()
let res = await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`, {
headers: { "Cookie": `refresh_token=${refreshToken.value}` }
})
setAuthCookies(res.rawHeaders)
const isSuccessful = res.status === 200
if (isSuccessful) {
authEvents$.next({
event: "token_refresh",
})
}
return isSuccessful
} catch (err) {
return false
}
}
async function sendMagicLink(email: string) {
const client = await getClient();
let url = `${import.meta.env.VITE_BACKEND_API_URL}/auth/signin?origin=desktop`;
const res = await client.post(url, Body.json({ email }));
if (res.data && res.data.deviceIdentifier) {
persistenceService.setLocalConfig("deviceIdentifier", res.data.deviceIdentifier)
} else {
throw new Error("test: does not get device identifier")
}
return res.data
}
async function setAuthCookies(rawHeaders: Array<String>) {
let cookies = rawHeaders['set-cookie'].join("|")
const accessTokenMatch = cookies.match(/access_token=([^;]+)/);
const refreshTokenMatch = cookies.match(/refresh_token=([^;]+)/);
const store = new Store(APP_DATA_PATH)
if (accessTokenMatch) {
const accessToken = accessTokenMatch[1];
await store.set("access_token", { value: accessToken })
}
if (refreshTokenMatch) {
const refreshToken = refreshTokenMatch[1];
await store.set("refresh_token", { value: refreshToken })
}
await store.save()
}
export const def: AuthPlatformDef = {
getCurrentUserStream: () => currentUser$,
getAuthEventsStream: () => authEvents$,
getProbableUserStream: () => probableUser$,
getCurrentUser: () => currentUser$.value,
getProbableUser: () => probableUser$.value,
getBackendHeaders() {
return {}
},
getGQLClientOptions() {
return {
fetchOptions: {
credentials: "include",
},
}
},
/**
* it is not possible for us to know if the current cookie is expired because we cannot access http-only cookies from js
* hence just returning if the currentUser$ has a value associated with it
*/
willBackendHaveAuthError() {
return !currentUser$.value
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onBackendGQLClientShouldReconnect(func) {
authEvents$.subscribe((event) => {
if (
event.event == "login" ||
event.event == "logout" ||
event.event == "token_refresh"
) {
func()
}
})
},
/**
* we cannot access our auth cookies from javascript, so leaving this as null
*/
getDevOptsBackendIDToken() {
return null
},
async performAuthInit() {
const probableUser = JSON.parse(persistenceService.getLocalConfig("login_state") ?? "null")
probableUser$.next(probableUser)
await setInitialUser()
await listen('scheme-request-received', async (event: any) => {
let deep_link = event.payload as string;
const params = new URLSearchParams(deep_link.split('?')[1]);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const token = params.get('token');
function isNotNullOrUndefined(x: any) {
return x !== null && x !== undefined;
}
if (isNotNullOrUndefined(accessToken) && isNotNullOrUndefined(refreshToken)) {
const store = new Store(APP_DATA_PATH)
await store.set("access_token", { value: accessToken });
await store.set("refresh_token", { value: refreshToken } );
await store.save()
window.location.href = "/"
return;
}
if (isNotNullOrUndefined(token)) {
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, _url) {
const deviceIdentifier = persistenceService.getLocalConfig("deviceIdentifier")
if (!deviceIdentifier) {
throw new Error(
"Device Identifier not found, you can only signin from the browser you generated the magic link"
)
}
let verifyToken = persistenceService.getLocalConfig("verifyToken")
const client = await getClient();
let res = await client.post(`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`, Body.json({
token: verifyToken,
deviceIdentifier
}));
setAuthCookies(res.rawHeaders)
persistenceService.removeLocalConfig("deviceIdentifier")
persistenceService.removeLocalConfig("verifyToken")
window.location.href = "/"
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async setEmailAddress(_email: string) {
return
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async setDisplayName(name: string) {
return
},
async signOutUser() {
// if (!currentUser$.value) throw new Error("No user has logged in")
await logout()
probableUser$.next(null)
currentUser$.next(null)
persistenceService.removeLocalConfig("login_state")
authEvents$.next({
event: "logout",
})
},
}