CurateBot Devlog 4: Adding Firestore profile storage and autologin
Tags: devjournal, typescript, vue, serverless
Published: 2021-01-05 17:58:15

In the previous post I set up Firebase to allow logging in via OAuth. It represents the minimum code needed to do so, but there's still quite a few different steps needed still, the most important of which is for every user that logs in, we receive Twitter API tokens from the login process, but those aren't automatically stored. We need to store them so that later when the bot fires off tweets, it can use those API tokens provided to it.

You can follow along, the commit that matches this post is [here](https://github.com/meseta/curatebot/tree/35f5f4597914f3aa3a6da28c50934b37e1e7f35c)

# Firestore profile storage
Since API keys are only available in value passed by the signup, this is where we must store it. The code is at [`store/auth/index.ts`](https://github.com/meseta/curatebot/blob/35f5f4597914f3aa3a6da28c50934b37e1e7f35c/web/src/store/auth/index.ts), and the relevant chunk reproduced below:

```ts
  login({commit}) {
    const provider = new firebase.auth.TwitterAuthProvider();
    console.log("start login");
    firebaseAuth.signInWithPopup(provider)
    .then((firebaseUserCredential: firebase.auth.UserCredential) => {
      
      const uid = firebaseUserCredential?.user?.uid;
      const profile: any = firebaseUserCredential?.additionalUserInfo?.profile; // eslint-disable-line @typescript-eslint/no-explicit-any
      const credential: any = firebaseUserCredential?.credential; // eslint-disable-line @typescript-eslint/no-explicit-any

      if(!uid || !profile || !credential) {
        throw new Error();
      }

      const userData: UserData = {
        profileImage: profile.profile_image_url_https,
        name: profile.name,
        handle: profile.screen_name,
        id: profile.id_str,
        accessToken: credential.accessToken,
        secret: credential.secret
      }
      commit('setUid', uid);
      commit('setUserData', userData);

      return firestore.collection("users").doc(uid).set(userData, { merge: true });
    }).catch((error) => {
      console.error(error);
    });
  },
```
  
What's happening here is once the signup completes (if it doesn't, it drops into the `.catch()`), we pull out the profile data and credential data from it, and insert it into firestore. We also commit the uid and the profile to the vuex state, so the app can use it.

We can view the data in Firstore's UI console:

![Firestore console](https://dev-to-uploads.s3.amazonaws.com/i/jwzv9ck7ow1ouxoaumda.png)

# Autologin
To achieve Autologin, we need to do a slightly weird thing (and I'm not sure this is the best practice at this time, there may be better methods now, I will need to investigate later). The Firebase library will automatically log us in once it loads. However, by that time, the Vue instance would have finished loading in an not-logged-in-state. So we need to let Firebase load first, and then load Vue. A slightly weird but convenient way to do this is to change the Vue instantiation in [`main.ts`](https://github.com/meseta/curatebot/blob/35f5f4597914f3aa3a6da28c50934b37e1e7f35c/web/src/main.ts), the main entrypoint to the code from:

```ts
new Vue({
  router,
  store,
  vuetify,
  render: h => h(App),
});
```

To...

```ts
const unsubscribe = auth.onAuthStateChanged(() => {
  new Vue({
    router,
    store,
    vuetify,
    render: h => h(App),
    created () {
      auth.onAuthStateChanged((firebaseUser: firebase.User | null) => {
        if (firebaseUser && !store.getters['auth/isAuthenticated']) {
          store.dispatch('auth/autoLogin', firebaseUser)
        }
      })
    }
  }).$mount('#app');
  unsubscribe()
})
```

What this is doing is registering a new function to Firebase's Auth module to run when onAuthStateChanged, which it will do so once Firebase completes loading. That function then does three things:
1. Instantiates the Vue object, which...
1. Internally also registers it's own Auth state listener in the `created()` [lifecycle hook](https://vuejs.org/v2/guide/instance.html#Lifecycle-Diagram), so that Vue can run the autologin
1. Unsubscribes this first callback that we just registered. It took a while for me to understand, but `auth.onAuthStateChanged()` returns a function that you can call to unregister it. We're saving this callback in the `const unsubscribe` which we're then calling from inside the callback. This weird seemingly back-reference threw me through a loop when I first saw it.

Once the real autologin fires, it will dispatch our vuex action called `auth/autoLogin`, which looks like this:

```ts
  autoLogin({commit}, firebaseUser: firebase.User) {
    const uid = firebaseUser.uid;

    return firestore.collection("users").doc(uid).get()
    .then(doc => {
      if (doc.exists) {
        commit('setUserData', doc.data());
        commit('setUid', uid);
      }
    })
  },

```

Firebase's `onAuthStateChanged()` function fires the callback with the firebase User profile. We can then use the UID to fetch the Firestore profile data, and load the state.

That's it! That's all we need for basic autologin.

# Firestore rules
As you may have noticed, throughout this process at no poitn did we set up a database (Firestore, as a NoSQL database, doesn't need schemas to be set up), and we're accessing the data from our frontend app directly via it's libraries. But... what's stopping any user from randomly messing with another user's profile?

This is a key feature of serverless databases: access rules. While regular old SQL servers are usually secured using some combination of username/password that grants a user access to whole tables, and usually require an additional backend app to enforce access rules. Firestore integrates with Firebase Authentication (whose logged-in-ness data is automatically included with database requests), and uses fine-grained access rules to only allow access to the data for logged-in users who should be able to see the data.

The access rules are kept in [`firestore/firestore.rules`](https://github.com/meseta/curatebot/blob/35f5f4597914f3aa3a6da28c50934b37e1e7f35c/firestore/firestore.rules). I've set up the rules like this:

```
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function isSignedIn() {
      return request.auth != null && request.auth.uid != null && request.auth.uid != "";
    }
    
    function isUser(uid) {
      return isSignedIn() && request.auth.uid == uid;
    }
    
    match /users/{uid} {
      allow read, write: if isUser(uid);
    }

    match /{document=**} {
      allow read, write: if false;
    }
  }
}
```

What this is saying is that the database path of `/users/{uid}`, I will allow both read and write permissions (which include delete) if `isUser(uid)` returns True. That function checks that the request contains a valid signed-in user, and that that user's `uid` matches the logged-in-user's uid (It can check that there is a valid signed in user by looking at the request metadata, and validating some JWTs).

This means that only a valid user can look at their own data in the database. Other users (and invalid ones) cannot see or manipulate other people's data.

The rules can be deployed using `firebase deploy --only firestore`.

These features together make up the profile storage and login systems!