Ligang Yan颜力刚

A React Native client for NodeBB: swapping a Firebase login for an API token

NodeBB is an open-source Node.js forum whose mobile experience is mediocre. I built a React Native client for it, and the two problems worth writing down are third-party sign-in and automatically exchanging that sign-in for a NodeBB API verifyToken.

nodebbreact-nativefirebaseopen-source

中文版:给 NodeBB 写一个 React Native 客户端:用 Firebase 登录换取 API Token

Background

NodeBB is an open-source forum built on Node.js with MongoDB or Redis as the database. Its interface is Bootstrap-based, so the mobile experience is not great. I decided to build a React Native client to give it a better one.

What needed building

NodeBB was never designed for mobile, and once you read its documentation a few things clearly have to be customised:

  1. Accounts: NodeBB has its own account system with email and password registration, but on mobile, signing in with a third-party account (Google, Apple) is the norm: more convenient and more secure. I recommend Google’s React Native Firebase, which provides a complete solution.
  2. API authentication: NodeBB exposes a RESTful API split into a Read API and a Write API. Calls need a verifyToken parameter, which today can only be created by hand in the admin panel. The code has to change so that after a third-party sign-in the user automatically obtains a verifyToken for subsequent calls.

After that comes the forum functionality itself in the client. For the first version I planned the basics: browsing posts, posting, replying, upvoting, reporting, notifications.

Implementation

1. Accounts and API authentication

Start with the server-side changes. In the NodeBB project, add an endpoint at src/routes/write/index.js:54 for exchanging a NodeBB verifyToken:

Write.reload = async (params) => {
  ...
  // @alin key feature: exchange a firebase idToken for a NodeBB verifyToken
  setupApiRoute(router, 'get', '/api/v3/exchangeVerifyToken', writeControllers.utilities.exchangeVerifyToken);
  ...
};

Then add an exchangeVerifyToken method in src/controllers/write/utilities.js. FirebaseService is a new service class that holds the Firebase-related logic:

Utilities.exchangeVerifyToken = async (req, res) => {
  try {
    const idToken = req.get('idToken')
    const result = await FirebaseService.exchangeVerifyToken(idToken)
    helpers.formatApiResponse(200, res, result)
  } catch (error) {
    helpers.formatApiResponse(401, res, error.message)
  }
}

Install firebase-admin:

npm install firebase-admin
yarn add firebase-admin

Then create a project in the Firebase console and download the JSON service-account file, which contains projectId, storageBucket and so on; keep it somewhere suitable in the project.

The logic is:

  1. Use firebaseAuth.verifyIdToken to verify the idToken sent by the client. On success you get a decodedToken with uid, email, displayName and so on.
  2. Use that to find or create the user, returning a NodeBB-generated uid.
  3. Use the uid to find or create a verifyToken, then return both uid and verifyToken to the client.

That completes the first feature: after signing in with a third-party account, the client exchanges its idToken for a NodeBB verifyToken to use on later calls.

'use strict'

const firebaseAdmin = require('firebase-admin')
const winston = require('winston')
const serviceAccount = require('./{YOUR_SERVICE_ACCOUNT}.json')
const User = require('../user')
const db = require('../database')
const apiUtils = require('../api/utils')
const utils = require('../utils')

const firebaseApp = firebaseAdmin.initializeApp({
  credential: firebaseAdmin.credential.cert(serviceAccount),
  projectId: '{YOUR_PROJECT_ID}',
  storageBucket: '{YOUR_STORAGE_BUCKET}',
})
const firebaseAuth = firebaseApp.auth()

const FirebaseService = module.exports

const findOrCreateUser = async (idToken, username, email) => {
  let uid = await User.getUidByEmail(email)
  if (!uid) {
    uid = await User.create({ username: username, email: email })
    await User.setUserField(uid, 'email', email)
    await User.email.confirmByUid(uid)
    // Save google-specific information to the user
    User.setUserField(uid, 'idToken', idToken)
    db.setObjectField('idToken:uid', idToken, uid)
  }
  return uid
}

const findOrCreateVerifyToken = async (uid) => {
  let verifyToken = await apiUtils.tokens.getTokenByUid(uid)
  if (!verifyToken) {
    verifyToken = await apiUtils.tokens.generate({ uid: uid, description: 'api access token' })
  }
  return verifyToken
}

FirebaseService.exchangeVerifyToken = async (idToken) => {
  if (!idToken) {
    throw new Error('idToken is required')
  }
  const decodedToken = await firebaseAuth.verifyIdToken(idToken)
  if (!decodedToken) {
    throw new Error('no decodedToken')
  }
  if (!decodedToken.email) {
    throw new Error('no email in decodedToken')
  }
  // split the email to get the username
  const username = decodedToken.email.split('@')[0]
  const uid = await findOrCreateUser(decodedToken.uid, username, decodedToken.email)
  const verifyToken = await findOrCreateVerifyToken(uid)
  return {
    uid: uid,
    verifyToken: verifyToken,
  }
}

Now the client. Third-party sign-in uses React Native Firebase. Install it first:

# Using npm
npm install --save @react-native-firebase/app

# Using Yarn
yarn add @react-native-firebase/app

There is some environment setup as well; see the official docs.

Add an axios interceptor that attaches the idToken and verifyToken to request headers:

import axios from 'axios'
import auth from '@react-native-firebase/auth'
import { MMKV } from 'react-native-mmkv'
const storage = new MMKV()

const axiosInstance = axios.create({
  baseURL: process.env.NODEBB_API_URL,
  timeout: 30000,
})

// Request interceptor: add idToken and verifyToken to the headers
axiosInstance.interceptors.request.use(
  async function (config) {
    if (auth().currentUser != null) {
      const idToken = await auth().currentUser?.getIdToken()
      config.headers.idToken = idToken
    }
    const verifyToken = storage.getString('user.verifyToken')
    if (verifyToken) {
      config.headers.Authorization = `Bearer ${verifyToken}`
    }
    return config
  },
  function (error) {
    return Promise.reject(error)
  }
)
export default axiosInstance

Create an AuthContext.tsx to manage sign-in state. The Google sign-in part:

export function AuthProvider({ children }: { children: any }) {
  const googleSignIn = async () => {
    try {
      await GoogleSignin.hasPlayServices({
        showPlayServicesUpdateDialog: true,
      })
      // 1. get the user's idToken
      const { idToken } = await GoogleSignin.signIn()
      const googleCredential = auth.GoogleAuthProvider.credential(idToken)
      await auth().signInWithCredential(googleCredential)
      // 2. refresh the verifyToken and user profile
      await refreshVerifyTokenAndUser()
    } catch (e) {
      console.error(e)
    }
  }
}

Then call /api/v3/exchangeVerifyToken, sending the idToken to the server. The server returns uid and verifyToken; save the verifyToken locally for later calls. That settles both the account and the authentication problems.

export function AuthProvider({ children }: { children: any }) {
  const [verifyToken, setVerifyToken] = useMMKVString('user.verifyToken')
  const [user, setUser] = useMMKVObject < User > 'user'

  const refreshVerifyTokenAndUser = async () => {
    try {
      // 1. exchange for the verifyToken
      const res = await UserAPI.exchangeVerifyToken()
      setVerifyToken(res.response.verifyToken)

      // 2. fetch the user profile and save the device token
      const [resUser, resDeviceToken] = await Promise.all([
        UserAPI.getUserByUid(res.response.uid),
        UserAPI.saveDeviceToken(deviceToken),
      ])
      setUser(resUser)
    } catch (e) {
      console.error(e)
    }
  }
}

2. Forum features

With accounts and authentication in place, the rest is ordinary forum functionality: browsing categories and topics, posting, replying, upvoting, reporting, notifications. These are direct calls to the NodeBB Read and Write APIs with no particular traps, so I won’t go through them one by one.

The code

It’s on GitHub: allenyan513/NodeBB-React-Native. It integrates the Firebase Auth SDK, implements Apple and Google sign-in, and covers the basic forum features. The project is no longer maintained, but the token-exchange idea on the server and the interceptor pattern on the client still apply to any “give an old system a mobile client” situation.

中文版.