558 lines
14 KiB
TypeScript
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
|
|
},
|
|
}
|