Firebase Auth with a non-supported provider (Dropbox)
Tags: firebase, javascript
Published: 2021-04-12 16:30:21

So I'm writing this post since I haven't found any recent reasonable working example to what I needed.

My requirements were not very complex. I wanted to build a firebase web application, that authenticates against Dropbox since the application needed to access files inside of the users Dropbox account.

But looking at the docs of firebase, trying to wrap my head against the `Custom Authentication` ([Link](https://firebase.google.com/docs/auth/web/custom-auth)) didn't really provide for what I needed, which is an explanation of how to use the custom OAuth process described in the [Dropbox Developer Docs](https://developers.dropbox.com/oauth-guide) (which defiantly needs some updates). Putting the 2 together was defiantly not straightforward (to me, at least).

So, here I'll describe the solution I came up with which serves me well. I currently haven't created a minimal working example in github, but if there will be enough interest, will do so.

A couple of things regarding the solution provided:
1. I'm using React, as this is what I'm using for my project.
2. It is important (for security) not to expose your dropbox app credentials in the client side - so all communication with the dropbox sdk should be done on the sever-side, and in firebase case, firebase-functions.
3. In the code example that follows I've skipped a lot of the spinning rims (loading spinners, error handling) which you defiantly should do.

## Step 1: Direct to dropbox login
I've created a page for login - it has nothing special, other than a big **Login with Dropbox** button. The important part is that on click, it will get a login URL from a firebase https function:

```
export default function Login() {
  
  const handleLogin = async () => {
    const urlResponse = await fetch("http://localhost:5000/your-api-endpoint/get-dropbox-login-url");
    const url = await urlResponse.json();
    window.location(url);
  }

  return (
    <button onClick={handleLogin}>Login with Dropbox</button>
  );
}
```

The corresponding function looks something like this:
```
import * as functions from "firebase-functions";
import * as Dropbox from "dropbox";
import fetch from "node-fetch";

const dropboxConfig = {
    clientId: 'YOUR_CLIENT_ID', // I've used functions.config() for this
    clientSecret: 'YOUR_CLIENT_SECRET', // I've used functions.config() for this
    fetch: fetch,
};

exports.getAuthenticationUrl = functions.https.onRequest () {
    const dropboxAuth = new Dropbox.DropboxAuth(dropboxConfig);
// notice the other arguments to the getAuthenticationUrl are required for the login process to work as expected. This is very Dropbox specific. The defaults will not work for our setup.
    return dropboxAuth.getAuthenticationUrl(
        `YOUR_REDIRECT_URL`, // as being setup in the dropbox-app
        undefined,
        "code",
        "offline",
        undefined,
        "user"
    );
}


```

With these 2 parts, you can display a page that will re-direct to the dropbox login page... after logging in, dropbox will redirect the user back (make sure to configure the URL to the webapp to something like `http://localhost:3000/dropbox-callback` where the user will be met by a react page described in the next step.

## Step 2: Capture the verification code, and send to the backend for verification

The OAuth process requires that you verify the code (which is time-limited) with the app credentials, and basically exchange the temporary code (which doesn't give you anything) with the actual access token (and user information) from dropbox systems.

So a react component needs to be loaded, and it will capture the code (passed through URL query param) and send that back to another function that will handle the exchange.

The backend function will not only just handle the exchange, it will create your application token that will be used to login

React dropbox-callback component:
```
import React, {useEffect, useState} from "react";
import {useFirebase} from "../../contexts/Firebase"; // custom wrapper to expose the firebase object

export default function DropboxCallbackView() {
    const firebase = useFirebase();
    useEffect(() => {
        async function extractTokenAndSend(): Promise<null> {
            const url = new URL(window.location.href);
            const body= {};
            
            // capture all url search params (after the '?')
            for (let key of url.searchParams.keys()) {
                if (url.searchParams.getAll(key).length > 1) {
                    body[key] = url.searchParams.getAll(key);
                } else {
                    body[key] = url.searchParams.get(key);
                }
            }

            // remove the code part from the URL - we don't want for the user to see it
            window.history.replaceState && window.history.replaceState(
                null, '', window.location.pathname +
                window.location.search
                      .replace(/[?&]code=[^&]+/, '')
                      .replace(/^&/, '?') +
                window.location.hash
            );
            
            const response = await fetch("http://localhost:5000/your-functions-endpoint/exchange-dropbox-code", {method: "POST", body: JSON.stringify(body), headers: {"Content-Type": "application/json"}});
            const data = await response.json();
            // data.token is the custom token, prepared by our functions backend, using the firebase-admin sdk
            await firebase.auth().signInWithCustomToken(data.token);
            // The user is now logged in!! do some navigation
        }

        extractTokenAndSend();
    }, [firebase, navigate]);

    return (
        <>
            Loading....
        </>
    );
}
```

While the exchange of code against Dropbox might look something like:
```
import * as Dropbox from "dropbox";
import {auth} from "firebase-admin";
import * as functions from "firebase-functions";

exports.exchangeDropboxCode = function.https.onRquest(async (req, res) => {
    const {code} = req.body;
    const dropboxAuth = new Dropbox.DropboxAuth(dropboxConfig);
    const dbx = new Dropbox.Dropbox({auth: dropboxAuth});
    const stringDropboxToken = await dropboxAuth.getAccessTokenFromCode('THE_ORIGINAL_REDIRECT_URI', code);
    const claims = stringDropboxToken.result;

    // converts the existing dropbox instance to one that is pre-authenticated to work with this user.
    dropboxAuth.setRefreshToken(claims.refresh_token);
    dropboxAuth.setAccessToken(claims.access_token);
    dropboxAuth.setAccessTokenExpiresAt(claims.expires_in);

    // get the user profile
    const getUserAccount = await dbx.usersGetCurrentAccount();

    // Be A Good Programmer - use some encryption before persisting the access_token and refresh_token to the DB
    const encryptedAccessToken = encrypt(claims.access_token);
    const encryptedRefreshToken = encrypt(claims.refresh_token);

    // this code will check if this is a new user or a returning one.
    let firstLogin = false, userUid = "";
    try {
        const existingUser = await auth().getUserByEmail(getUserAccount.result.email);
        userUid = existingUser.uid;
        firstLogin = false;
    } catch (e) {
        if (e["code"] && e.code === "auth/user-not-found") {
            // we will handle this exception gracefully... this is a new user.
            const newUser = await auth().createUser({
                                                        disabled: false,
                                                        displayName: getUserAccount.result.name.display_name,
                                                        email: getUserAccount.result.email,
                                                        emailVerified: getUserAccount.result.email_verified,
                                                    });
            userUid = newUser.uid;
            firstLogin = true;
        } else {
            // for any other exception, throw it
            throw e;
        }
    }

    // good idea to save a document for that user in your own DB to add information about the user (that is also editable)
    const userData = {
        displayName: getUserAccount.result.name.display_name,
        firstName: getUserAccount.result.name.given_name,
        lastName: getUserAccount.result.name.surname,
        email: getUserAccount.result.email,
        emailVerified: getUserAccount.result.email_verified,
        dropboxAccount: {
            access_token: encryptedAccessToken,
            refresh_token: encryptedRefreshToken,
            ...getUserAccount.result,
        },
    };
    await admin.firestore().collection("users").doc(userUid).set(userData);

    // this will create the custom token, used to logging in on the frontend
    const token = await auth().createCustomToken(userUid);
    return res.send({token, userData, firstLogin});
});
```


That's about it. This setup is what I used (after removing a lot of spinning rims and other stuff, not relating to the dropbox login. That means this code was not tested, and probably has some issues in it, but it should describe the solution I came up with for the problem at hand...

If you have any questions or need to help (or any other feedback, really) just reach out.