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.
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:
- 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.
- 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:
- Use
firebaseAuth.verifyIdTokento verify the idToken sent by the client. On success you get a decodedToken with uid, email, displayName and so on. - Use that to find or create the user, returning a NodeBB-generated uid.
- 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.
中文版.