auth.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import {
  2. User,
  3. getAuth,
  4. onAuthStateChanged,
  5. onIdTokenChanged,
  6. signInWithPopup,
  7. GoogleAuthProvider,
  8. GithubAuthProvider,
  9. signInWithEmailAndPassword as signInWithEmailAndPass,
  10. isSignInWithEmailLink as isSignInWithEmailLinkFB,
  11. fetchSignInMethodsForEmail,
  12. sendSignInLinkToEmail,
  13. signInWithEmailLink as signInWithEmailLinkFB,
  14. ActionCodeSettings,
  15. signOut,
  16. linkWithCredential,
  17. AuthCredential,
  18. UserCredential,
  19. updateProfile,
  20. updateEmail,
  21. sendEmailVerification,
  22. reauthenticateWithCredential,
  23. } from "firebase/auth"
  24. import {
  25. onSnapshot,
  26. getFirestore,
  27. setDoc,
  28. doc,
  29. updateDoc,
  30. } from "firebase/firestore"
  31. import {
  32. BehaviorSubject,
  33. distinctUntilChanged,
  34. filter,
  35. map,
  36. Subject,
  37. Subscription,
  38. } from "rxjs"
  39. import { onBeforeUnmount, onMounted } from "@nuxtjs/composition-api"
  40. import {
  41. setLocalConfig,
  42. getLocalConfig,
  43. removeLocalConfig,
  44. } from "~/newstore/localpersistence"
  45. export type HoppUser = User & {
  46. provider?: string
  47. accessToken?: string
  48. }
  49. type AuthEvents =
  50. | { event: "probable_login"; user: HoppUser } // We have previous login state, but the app is waiting for authentication
  51. | { event: "login"; user: HoppUser } // We are authenticated
  52. | { event: "logout" } // No authentication and we have no previous state
  53. | { event: "authTokenUpdate"; user: HoppUser; newToken: string | null } // Token has been updated
  54. /**
  55. * A BehaviorSubject emitting the currently logged in user (or null if not logged in)
  56. */
  57. export const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
  58. /**
  59. * A BehaviorSubject emitting the current idToken
  60. */
  61. export const authIdToken$ = new BehaviorSubject<string | null>(null)
  62. /**
  63. * A subject that emits events related to authentication flows
  64. */
  65. export const authEvents$ = new Subject<AuthEvents>()
  66. /**
  67. * Like currentUser$ but also gives probable user value
  68. */
  69. export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
  70. /**
  71. * Resolves when the probable login resolves into proper login
  72. */
  73. export const waitProbableLoginToConfirm = () =>
  74. new Promise<void>((resolve, reject) => {
  75. if (authIdToken$.value) resolve()
  76. if (!probableUser$.value) reject(new Error("no_probable_user"))
  77. const sub = authIdToken$.pipe(filter((token) => !!token)).subscribe(() => {
  78. sub?.unsubscribe()
  79. resolve()
  80. })
  81. })
  82. /**
  83. * Initializes the firebase authentication related subjects
  84. */
  85. export function initAuth() {
  86. const auth = getAuth()
  87. const firestore = getFirestore()
  88. let extraSnapshotStop: (() => void) | null = null
  89. probableUser$.next(JSON.parse(getLocalConfig("login_state") ?? "null"))
  90. onAuthStateChanged(auth, (user) => {
  91. /** Whether the user was logged in before */
  92. const wasLoggedIn = currentUser$.value !== null
  93. if (user) {
  94. probableUser$.next(user)
  95. } else {
  96. probableUser$.next(null)
  97. removeLocalConfig("login_state")
  98. }
  99. if (!user && extraSnapshotStop) {
  100. extraSnapshotStop()
  101. extraSnapshotStop = null
  102. } else if (user) {
  103. // Merge all the user info from all the authenticated providers
  104. user.providerData.forEach((profile) => {
  105. if (!profile) return
  106. const us = {
  107. updatedOn: new Date(),
  108. provider: profile.providerId,
  109. name: profile.displayName,
  110. email: profile.email,
  111. photoUrl: profile.photoURL,
  112. uid: profile.uid,
  113. }
  114. setDoc(doc(firestore, "users", user.uid), us, { merge: true }).catch(
  115. (e) => console.error("error updating", us, e)
  116. )
  117. })
  118. extraSnapshotStop = onSnapshot(
  119. doc(firestore, "users", user.uid),
  120. (doc) => {
  121. const data = doc.data()
  122. const userUpdate: HoppUser = user
  123. if (data) {
  124. // Write extra provider data
  125. userUpdate.provider = data.provider
  126. userUpdate.accessToken = data.accessToken
  127. }
  128. currentUser$.next(userUpdate)
  129. }
  130. )
  131. }
  132. currentUser$.next(user)
  133. // User wasn't found before, but now is there (login happened)
  134. if (!wasLoggedIn && user) {
  135. authEvents$.next({
  136. event: "login",
  137. user: currentUser$.value!!,
  138. })
  139. } else if (wasLoggedIn && !user) {
  140. // User was found before, but now is not there (logout happened)
  141. authEvents$.next({
  142. event: "logout",
  143. })
  144. }
  145. })
  146. onIdTokenChanged(auth, async (user) => {
  147. if (user) {
  148. authIdToken$.next(await user.getIdToken())
  149. authEvents$.next({
  150. event: "authTokenUpdate",
  151. newToken: authIdToken$.value,
  152. user: currentUser$.value!!, // Force not-null because user is defined
  153. })
  154. setLocalConfig("login_state", JSON.stringify(user))
  155. } else {
  156. authIdToken$.next(null)
  157. }
  158. })
  159. }
  160. export function getAuthIDToken(): string | null {
  161. return authIdToken$.getValue()
  162. }
  163. /**
  164. * Sign user in with a popup using Google
  165. */
  166. export async function signInUserWithGoogle() {
  167. return await signInWithPopup(getAuth(), new GoogleAuthProvider())
  168. }
  169. /**
  170. * Sign user in with a popup using Github
  171. */
  172. export async function signInUserWithGithub() {
  173. return await signInWithPopup(
  174. getAuth(),
  175. new GithubAuthProvider().addScope("gist")
  176. )
  177. }
  178. /**
  179. * Sign user in with email and password
  180. */
  181. export async function signInWithEmailAndPassword(
  182. email: string,
  183. password: string
  184. ) {
  185. return await signInWithEmailAndPass(getAuth(), email, password)
  186. }
  187. /**
  188. * Gets the sign in methods for a given email address
  189. *
  190. * @param email - Email to get the methods of
  191. *
  192. * @returns Promise for string array of the auth provider methods accessible
  193. */
  194. export async function getSignInMethodsForEmail(email: string) {
  195. return await fetchSignInMethodsForEmail(getAuth(), email)
  196. }
  197. export async function linkWithFBCredential(
  198. user: User,
  199. credential: AuthCredential
  200. ) {
  201. return await linkWithCredential(user, credential)
  202. }
  203. /**
  204. * Sends an email with the signin link to the user
  205. *
  206. * @param email - Email to send the email to
  207. * @param actionCodeSettings - The settings to apply to the link
  208. */
  209. export async function signInWithEmail(
  210. email: string,
  211. actionCodeSettings: ActionCodeSettings
  212. ) {
  213. return await sendSignInLinkToEmail(getAuth(), email, actionCodeSettings)
  214. }
  215. /**
  216. * Checks and returns whether the sign in link is an email link
  217. *
  218. * @param url - The URL to look in
  219. */
  220. export function isSignInWithEmailLink(url: string) {
  221. return isSignInWithEmailLinkFB(getAuth(), url)
  222. }
  223. /**
  224. * Sends an email with sign in with email link
  225. *
  226. * @param email - Email to log in to
  227. * @param url - The action URL which is used to validate login
  228. */
  229. export async function signInWithEmailLink(email: string, url: string) {
  230. return await signInWithEmailLinkFB(getAuth(), email, url)
  231. }
  232. /**
  233. * Signs out the user
  234. */
  235. export async function signOutUser() {
  236. if (!currentUser$.value) throw new Error("No user has logged in")
  237. await signOut(getAuth())
  238. }
  239. /**
  240. * Sets the provider id and relevant provider auth token
  241. * as user metadata
  242. *
  243. * @param id - The provider ID
  244. * @param token - The relevant auth token for the given provider
  245. */
  246. export async function setProviderInfo(id: string, token: string) {
  247. if (!currentUser$.value) throw new Error("No user has logged in")
  248. const us = {
  249. updatedOn: new Date(),
  250. provider: id,
  251. accessToken: token,
  252. }
  253. try {
  254. await updateDoc(
  255. doc(getFirestore(), "users", currentUser$.value.uid),
  256. us
  257. ).catch((e) => console.error("error updating", us, e))
  258. } catch (e) {
  259. console.error("error updating", e)
  260. throw e
  261. }
  262. }
  263. /**
  264. * Sets the user's display name
  265. *
  266. * @param name - The new display name
  267. */
  268. export async function setDisplayName(name: string) {
  269. if (!currentUser$.value) throw new Error("No user has logged in")
  270. const us = {
  271. displayName: name,
  272. }
  273. try {
  274. await updateProfile(currentUser$.value, us)
  275. } catch (e) {
  276. console.error("error updating", e)
  277. throw e
  278. }
  279. }
  280. /**
  281. * Send user's email address verification mail
  282. */
  283. export async function verifyEmailAddress() {
  284. if (!currentUser$.value) throw new Error("No user has logged in")
  285. try {
  286. await sendEmailVerification(currentUser$.value)
  287. } catch (e) {
  288. console.error("error updating", e)
  289. throw e
  290. }
  291. }
  292. /**
  293. * Sets the user's email address
  294. *
  295. * @param email - The new email address
  296. */
  297. export async function setEmailAddress(email: string) {
  298. if (!currentUser$.value) throw new Error("No user has logged in")
  299. try {
  300. await updateEmail(currentUser$.value, email)
  301. } catch (e) {
  302. await reauthenticateUser()
  303. console.error("error updating", e)
  304. throw e
  305. }
  306. }
  307. /**
  308. * Reauthenticate the user with the given credential
  309. */
  310. async function reauthenticateUser() {
  311. if (!currentUser$.value) throw new Error("No user has logged in")
  312. const currentAuthMethod = currentUser$.value.provider
  313. let credential
  314. if (currentAuthMethod === "google.com") {
  315. const result = await signInUserWithGithub()
  316. credential = GithubAuthProvider.credentialFromResult(result)
  317. } else if (currentAuthMethod === "github.com") {
  318. const result = await signInUserWithGoogle()
  319. credential = GoogleAuthProvider.credentialFromResult(result)
  320. } else if (currentAuthMethod === "password") {
  321. const email = prompt(
  322. "Reauthenticate your account using your current email:"
  323. )
  324. const actionCodeSettings = {
  325. url: `${process.env.BASE_URL}/enter`,
  326. handleCodeInApp: true,
  327. }
  328. await signInWithEmail(email as string, actionCodeSettings)
  329. .then(() =>
  330. alert(
  331. `Check your inbox - we sent an email to ${email}. It contains a magic link that will reauthenticate your account.`
  332. )
  333. )
  334. .catch((e) => {
  335. alert(`Error: ${e.message}`)
  336. console.error(e)
  337. })
  338. return
  339. }
  340. try {
  341. await reauthenticateWithCredential(
  342. currentUser$.value,
  343. credential as AuthCredential
  344. )
  345. } catch (e) {
  346. console.error("error updating", e)
  347. throw e
  348. }
  349. }
  350. export function getGithubCredentialFromResult(result: UserCredential) {
  351. return GithubAuthProvider.credentialFromResult(result)
  352. }
  353. /**
  354. * A Vue composable function that is called when the auth status
  355. * is being updated to being logged in (fired multiple times),
  356. * this is also called on component mount if the login
  357. * was already resolved before mount.
  358. */
  359. export function onLoggedIn(exec: (user: HoppUser) => void) {
  360. let sub: Subscription | null = null
  361. onMounted(() => {
  362. sub = currentUser$
  363. .pipe(
  364. map((user) => !!user), // Get a logged in status (true or false)
  365. distinctUntilChanged(), // Don't propagate unless the status updates
  366. filter((x) => x) // Don't propagate unless it is logged in
  367. )
  368. .subscribe(() => {
  369. exec(currentUser$.value!)
  370. })
  371. })
  372. onBeforeUnmount(() => {
  373. sub?.unsubscribe()
  374. })
  375. }