auth.ts 11 KB

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