Easy website authentication (using Firebase)

Even for simple static website, authentication can be bebificial. Per user Google Analytics, Google Drive and similar cloud APIs, ability to personalize content and send notifications are just a few possibilities. Though authentication is notoriously difficult to do right, luckily, nowadays, it could be added quite easily. In this post we consider how to add simple user authentication using React, Ant Design and Firebase backend.

React Hooks and UserContext

We use React Hooks to use UserContext which will contain user details. To start working with Firebase authentication, only a few clicks are necessary to register and crete web application, install firebase package (npm install --save firebase), enable authentication methods. Copy config from Firebase console, and you are good to go. To implement UserContext, UserProvider just needs to subscribe to firebase authentication changes in useEffect:

// user-provider.js

import React, { useEffect, useState} from 'react'

import firebase from 'firebase/app'
import 'firebase/auth'

import UserContext from "./user-context"
import getLogger from "./get-logger"

function registerFirebase(firebaseConfig){
    if (!firebase.apps.length) {
        return firebase.initializeApp(firebaseConfig) 
    } 
}

function signInAnonymously(){
    if (firebase.auth().currentUser){
        return
    }
    firebase.auth().signInAnonymously().catch(error=>{
        console.error(error)
        setTimeout(signInAnonymously, 5000)
    })    
}

export default function UserProvider({firebaseConfig, children, anonymous=true}){
    try{
        registerFirebase(firebaseConfig)
    }catch(error){
        console.error("during app registration", error)
    }
 
    const [user, setUser] = useState(false)
    const [properties, setProperties] = useState()
    const debug = getLogger("auth") // use "debug" library to nicely log events 

    useEffect(()=>{
        return firebase.auth().onAuthStateChanged(function(user) {
            debug("auth state change user: %o", user)
            setUser(user)
            if (!user && anonymous){
                signInAnonymously()
            }
        })
    }, [])

    const addProperties = v => setProperties(Object.assign({}, properties, v))    

    const value = Object.freeze(Object.assign({user, addProperties}, properties))
    return (
        <UserContext.Provider value={value}>
            {children}
        </UserContext.Provider>
    )
}

User tracking could be useful even on public pages (to persist shopping cart, track user behavior in Google Analytics, etc), so it makes sense to add UserProvider somewhere near the root of components hierarchy. To update UserContext with additional information (e.g., API tokens), addProperties function is provided. To create anonymous users, anonymous authentication should be enabled in Firebase console.

Password protected sections

Ant Design comes with excellent form validation components, after augmenting login form example with Firebase specific authentication handler, functional login form is at our disposal. To restrict access to certain application functionality, PasswordProtected component could be used, with Login parameter for Login form (defaults to simple Ant Design form), Placeholder (to show something when Firebase is initializing), and buttons (auxillary controls like Google or Facebook sign in buttons). Below is you can see a rather contrived example of login protected page with signout, delete account links, Google Sign In button, Google Drive API authentication, and component showing list of 10 first Google Drive files (implementation details of some components could be found bellow, source code being packaged as firelib).

// password-protected-page-section.js
<PasswordProtected
    buttons={[GoogleSignInButton]}
>
    <Signout>Signout</Signout>
    <DeleteUser>Delete account</DeleteUser>
    <GoogleSignInButton style={googleLoginStyle}/>
    <GoogleSignInButton
    style = {googleLoginStyle}
    scopes = {["https://www.googleapis.com/auth/drive.readonly"]}
    >
        Google Drive
    </GoogleSignInButton>
    <GoogleDriveFilesList/>
</PasswordProtected>  

These components already allow to handle quite complicated workflows. An anonymous user is linked with newly created one, and upon login the old anonymous user is deleted. Code also takes care of authentication persistence. This all slightly complicates code of a PasswordProtected component, which, nonetheless, remains manageable:

// password-protected.js
import React, { useContext } from 'react'
import firebase from 'firebase/app'
import { notification } from 'antd'

import errorNotification from "./error-notification"
import UserContext from "./user-context"
import getLogger from "./get-logger"
import SimpleLogin from "./login"

function successfullySentReminderNotification(){
    notification.success({
        message:"Password reminder was successfully sent to your E-mail address",
        description: "Check your mail box!", 
        duration: 10
    }); 
}

async function signInWithEmailAndPassword(action, email, password, remember){
    const auth = firebase.auth(), user = auth.currentUser
    const Persistence = firebase.auth.Auth.Persistence

    await auth.setPersistence(remember ? Persistence.LOCAL : Persistence.NONE)
    
    if (action === "login"){
        await auth.signInWithEmailAndPassword(email, password)
        if (user && user.isAnonymous){
            user.delete()
        }
        return
    }

    if (user){
        const credential = firebase.auth.EmailAuthProvider.credential(email, password)
        await user.linkWithCredential(credential)
        return
    }
    
    await auth.createUserWithEmailAndPassword(email, password)     
}

function loginProcessor(addProperties){
    return async ({action, values})=>{
        const debug = getLogger("auth")    
        debug('%s, form: %o', action, values)
        const {email, password, remember} = values      

        try{
            if (action === "login" || action === "register"){
                await signInWithEmailAndPassword(action, email, password, remember)
            } else if (action === "remind") {
                await firebase.auth().sendPasswordResetEmail(email)
                successfullySentReminderNotification()
            } else{
                throw new Error(`not implemented login action: ${action}`)               
            }
        }catch(error){   
            errorNotification({operation:"during authentication", error})
        }
        addProperties()  
    }
}

export default function PasswordProtected({Login, Placeholder, buttons, handler, children}){
    const {user, addProperties} = useContext(UserContext)    
    if (!Login){
        Login = SimpleLogin
    }

    if (user === false){
        return Placeholder ? <Placeholder/> : null 
    }

    if (user && !user.isAnonymous){
        return <>{children}</>
    }

    const processor = loginProcessor(addProperties)
    const action = handler? ()=> handler(processor, addProperties): processor

    return <Login action={action} buttons={buttons}/>
}

As anyone can see, in such a way, login form with email/password, password reminders, Google Sign In, anonymous users, and authentication persistence could be implemented with relatively little code, thanks to Firebase and Ant Design libraries. You can play with the form below:

Integrate React application with Google API

Firebase comes with a support of many various authentication workflows. The components above may answer the basic authentication needs of an average web application, otherwise you may wish directly refer to Firebase documentation. Here only one more aspect of authentication is considered in more details: integration with various Google API. These APIs allow read/save data from Google Docs, Google Spreadsheet, Google Drive, etc, and could be useful in many React applications. For implementation, lets look at the code for <GoogleSignInButton/> component:

import React, { useContext} from 'react'
import firebase from 'firebase/app'
import {Button} from "antd"

import errorNotification from "./error-notification"
import UserContext from "./user-context"

async function signInWithPopup(user, provider){
    if (user && !user.isAnonymous){
        const linked = user.providerData.some(v=>v.providerId === provider.providerId)
        if (linked){
            return firebase.auth().signInWithPopup(provider)
        } 

        return user.linkWithPopup(provider)
    }
    const result = await firebase.auth().signInWithPopup(provider)
    if (user && user.isAnonymous){
        user.delete() 
    }

    return result
}

export default function GoogleSignInButton(props){
    const {user, addProperties} = useContext(UserContext)
    const {scopes, children, className, ...other} = props    
    const onClick = async ()=>{
        const provider = new firebase.auth.GoogleAuthProvider()
        if (scopes){
            scopes.forEach(scope=>provider.addScope(scope))
        }
        try {
            const result = await signInWithPopup(user, provider)
            const accessToken = result.credential.accessToken
            addProperties({accessToken})
        }catch(error) {
            errorNotification({operation:"during google sign in", error})
        }     
    }
    return (
        <Button
        className={"login-form__button" + (className? className:"")}
        icon="google" onClick={onClick} {...other}>
            { children || "Sign in with Google!"}
        </Button>
    )
}

To authenticate using Google account, basically the same set of functions as before is used with a provider object argument, configuring particular authentication method. In case of Google, access to APIs could be configured using scopes. As a result accessToken is returned to be used in future requests, and refreshToken is added to user object to request new accessTokens in server side functions after accessToken expiration. When accessToken becomes available it could be used in "Authentication" header in Google API requests, as in a simple example component, listing first 10 files from Google Drive:

function listFiles({accessToken}){
    return fetch("https://www.googleapis.com/drive/v3/files?pageSize=10", {
        method: 'GET',
        headers: new Headers({ 'Authorization': 'Bearer ' + accessToken }),
    }).then((res) => {
        return res.json();
    });
}

function GoogleDriveFilesList(){
    const {accessToken} = useContext(UserContext)

    const files = useAsync(async ()=>{
        if (!accessToken){
            return null
        }

        return listFiles({accessToken})
    },[accessToken])

    if(files.error){
        errorNotification({operation:"when connecting to Google drive", error:files.error})        
    }

    if (files.value && files.value.error){
        const error = files.value.error
        const message = error.message
        return (
        <Alert 
            message="Google Drive files list error"
            description={message}
            type="warning"
            showIcon 
        />
        )
    }

    if (!accessToken){
        return (
            <Alert 
                message="Google Drive API is not available"
                description="Login, using Google Drive button!"
                type="warning"
                showIcon 
            />
        )        
    }

    return (<List
        style={{margin:10}}
        loading={files.loading}
        dataSource={(files.value && files.value.files) || []}
        renderItem={file=> <List.Item>{file.name}</List.Item>}
    />
    )
}

Even if you are not going to use Firebase for anything else, you may wish to use it for easy of authentication and integration with with other Google offerings. Here we only could scratch the surface of the authentication. For more details, refer directly to Firebase documentation, and firelib source code on Github repository.