HomeLearnHow-toOAuth & MongoDB Realm Serverless Functions

OAuth & MongoDB Realm Serverless Functions

Published: Mar 11, 2021

  • MongoDB
  • Realm
  • API

By Mark Smith

Rate this article

I recently had the opportunity to work with Lauren Schaefer and Maxime Beugnet on a stats tracker for some YouTube statistics that we were tracking manually at the time.

I knew that to access the YouTube API, we would need to authenticate using OAuth 2. I also knew that because we were building the app on MongoDB Realm Serverless functions, someone would probably need to write the implementation from scratch.

I've dealt with OAuth before, and I've even built client implementations before, so I thought I'd volunteer to take on the task of implementing this workflow. It turned out to be easier than I thought, and because it's such a common requirement, I'm documenting the process here, in case you need to do the same thing.

This post assumes that you've worked with MongoDB Realm Functions in the past, and that you're comfortable with the concepts around calling REST-ish APIs.

But first...

#What the Heck is OAuth 2?

OAuth 2 is an authorization protocol which allows unrelated servers to allow authenticated access to their services, without sharing user credentials, such as your login password. What this means in this case is that YouTube will allow my Realm application to operate as if it was logged in as a MongoDB user.

There are some extra features for added control and security, like the ability to only allow access to certain functionality. In our case, the application will only need read-only access to the YouTube data, so there's no need to give it permission to delete MongoDB's YouTube videos!

#What Does it Look Like?

Because OAuth 2 doesn't transmit the user's login credentials, there is some added complexity to make this work.

From the user's perspective, it looks like this:

  1. The user clicks on a button (or in my minimal implementation, they type in a specific URL), which redirects the browser to the authorizing service—in this case YouTube.
  2. The authorizing service asks the user to log in, if necessary.
  3. The authorizing service asks the user to approve the request to allow the Realm app to make requests to the YouTube API on their behalf.
  4. If the user approves, then the browser redirects back to the Realm application, but with an extra parameter added to the URL containing a code which can be used to obtain access tokens.

Behind the scenes, there's a Step 5, where the Realm service makes an extra HTTPS request to the YouTube API, using the code provided in Step 4, requesting an access token and a refresh token.

A sequence diagram, breaking the OAuth2 flow down into 5 steps.
The OAuth2 flow

Access tokens are only valid for an hour. When they expire, a new access token can be requested from YouTube, using the refresh token, which only expires if it hasn't been used for six months!

If this sounds complicated, that's because it is! If you look more closely at the diagram above, though, you can see that there are only actually two requests being made by the browser to the Realm app, and only one request being made by the Realm app directly to Google. As long as you implement those three things, you'll have implemented the OAuth's full authorization flow.

Once the authorization flow has been completed by the appropriate user (a user who has permission to log in as the MongoDB organization), as long as the access token is refreshed using the refresh token, API calls can be made to the YouTube API indefinitely.

#Setting Up the Necessary Accounts

You'll need to create a Realm app and an associated Google project, and link the two together. There are quite a few steps, so make sure you don't miss any!

#Create a Realm App

Go to https://cloud.mongodb.com/ and log in if necessary. I'm going to assume that you have already created a MongoDB Atlas cluster, and an associated Realm App. If not, follow the steps described in the MongoDB documentation.

#Create a Google API Project

This flow is loosely applicable to any OAuth service, but I'll be working with Google's YouTube API. The first thing to do is to create a project in the Google API Console that is analogous to your Realm app.

Go to https://console.developers.google.com/apis/dashboard. Click the projects list (at the top-left of the screen), then click the "Create Project" button, and enter a name. I entered "DREAM" because that's the funky acronym we came up with for the analytics monitor project my team was working on. Select the project, then click the radio button that says "External" to make the app available to anyone with a Google account, and click "Create" to finish creating your project.

Ignore the form that you're presented with for now. On the left-hand side of the screen, click "Library" and in the search box, enter "YouTube" to filter Google's enormous API list.

A screenshot showing the Google API list filtered to just the YouTube APIs
The YouTube APIs

Select each of the APIs you wish to use—I selected the YouTube Data API and the YouTube Analytics API—and click the "Enable" button to allow your app to make calls to these APIs.

A screenshot showing the YouTube Analytics API logo above an Enable button
Enabling the YouTube Analytics API

Now, select "OAuth consent screen" from the left-hand side of the window. Next to the name of your app, click "Edit App."

You'll be taken to a form that will allow you to specify how your OAuth consent screens will look. Enter a sensible app name, your email address, and if you want to, upload a logo for your project. You can ignore the "App domain" fields for now. You'll need to enter an Authorized domain by clicking "Add Domain" and enter "mongodb-realm.com" (without the quotes!). Enter your email address under "Developer contact information" and click "Save and Continue."

In the table of scopes, check the boxes next to the scopes that end with "youtube.readonly" and "yt-analytics.readonly." Then click "Update." On the next screen, click "Save and Continue" to go to the "Test users" page. Because your app will be in "testing" mode while you're developing it, you'll need to add the email addresses of each account that will be allowed to authenticate with it, so I added my email address along with those of my team.

Click "Save and Continue" for a final time and you're done configuring the OAuth consent screen!

A final step is to generate some credentials your Realm app can use to prove to the Google API that the requests come from where they say they do. Click on "Credentials" on the left-hand side of the screen, click "Create Credentials" at the top, and select "OAuth Client ID."

A screenshot of the "Create Credentials" drop-down menu, showing the "OAuth Client ID" option
Create an OAuth client ID

The "Application Type" is "Web application." Enter a "Name" of "Realm App" (or another useful identifier, if you prefer), and then click "Create." You'll be shown your client ID and secret values. Leave them up on the screen, and in a different tab, go to your Realm app and select "Values" from the left side. Click the "Create New Value" button, give it a name of "GOOGLE_CLIENT_ID," select "Value," and paste the client ID into the content text box.

A screenshot showing the Realm UI interface for setting a value
Create a new GOOGLE_CLIENT_ID value

Repeat with the client secret, but select "Secret," and give it the name "GOOGLE_CLIENT_SECRET." You'll then be able to access these values with code like context.values.get("GOOGLE_CLIENT_ID") in your Realm function.

Once you've got the values safely stored in your Realm App, you've now got everything you need to authorize a user with the YouTube Analytics API.

#Let's Write Some Code!

To create an HTTP endpoint, you'll need to create an HTTP service in your Realm App. Go to your Realm App, select "3rd Party Services" on the left side, and then click the "Add a Service" button. Select HTTP and give it a "Service Name." I chose "google_oauth."

A screenshot showing how to create an HTTP 3rd-party service
Create an HTTP service

A webhook function is automatically created for you, and you'll be taken to its settings page.

Give the webhook a name, like "authorizor," and set the "HTTP Method" to "GET." While you're here, you should copy the "Webhook URL." Go back to your Google API project, "Credentials," and then click on the Edit (pencil) button next to your Realm app OAuth client ID.

A screenshot showing the location of the edit button for an OAuth client ID
Note the pencil/edit button on the right

Under "Authorized redirect URIs," click "Add URI," paste the URI into the text box, and click "Save."

Add an authorized redirect URI
Add an authorized redirect URI

Go back to your Realm Webhook settings, and click "Save" at the bottom of the page. You'll be taken to the function editor, and you'll see that some sample code has been inserted for you. Replace it with the following skeleton:

1exports = async function (payload, response) {
2 const querystring = require('querystring');
3};

Because the function will be making outgoing HTTP calls that will need to be awaited, I've made it an async function. Inside the function, I've required the querystring library because the function will also need to generate query strings for redirecting to Google.

After the require line, paste in the following constants, which will be required for authorizing users with Google:

1// https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps#httprest
2const GOOGLE_OAUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
3const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
4const SCOPES = [
5 "https://www.googleapis.com/auth/yt-analytics.readonly",
6 "https://www.googleapis.com/auth/youtube.readonly",
7];

Add the following lines, which will obtain values for the Google credentials client ID and secret, and also obtain the URL for the current webhook call:

1// Following obtained from:
2https://console.developers.google.com/apis/credentials
3
4const CLIENT_ID = context.values.get("GOOGLE_CLIENT_ID");
5const CLIENT_SECRET = context.values.get("GOOGLE_CLIENT_SECRET");
6const OAUTH2_CALLBACK = context.request.webhookUrl;

Once this is done, the code should check to see if it's being called via a Google redirect due to an error. This is the case if it's called with an error parameter. If that's the case, a good option is to log the error and display it to the user. Add the following code which does this:

1const error = payload.query.error;
2if (typeof error !== 'undefined') {
3 // Google says there's a problem:
4 console.error("Error code returned from Google:", error);
5
6 response.setHeader('Content-Type', 'text/plain');
7 response.setBody(error);
8 return response;
9}

Now to implement Step 1 of the authorization flow illustrated at the start of this post! When the user requests this webhook URL, they won't provide any parameters, whereas when Google redirects to it, the URL will include a code parameter. So, by checking if the code parameter is absent, you can ensure that we're this is the Step 1 call. Add the following code:

1const oauthCode = payload.query.code;
2
3if (typeof oauthCode === 'undefined') {
4 // No code provided, so let's request one from Google:
5 const oauthURL = new URL(GOOGLE_OAUTH_ENDPOINT);
6 oauthURL.search = querystring.stringify({
7 'client_id': CLIENT_ID,
8 'redirect_uri': OAUTH2_CALLBACK,
9 'response_type': 'code',
10 'scope': SCOPES.join(' '),
11 'access_type': "offline",
12 });
13
14 response.setStatusCode(302);
15 response.setHeader('Location', oauthURL.href);
16} else {
17 // This empty else block will be filled in below.
18}

The code above adds the appropriate parameters to the Google OAuth endpoint described in their OAuth flow documentation, and then redirects the browser to this endpoint, which will display a consent page to the user. When Steps 2 and 3 are complete, the browser will be redirected to this webhook (because that's the URL contained in OAUTH2_CALLBACK) with an added code parameter.

Add the following code inside the empty else block you added above, to handle the case where a code parameter is provided:

1// We have a code, so we've redirected successfully from Google's consent page.
2// Let's post to Google, requesting an access:
3let res = await context.http.post({
4 url: GOOGLE_TOKEN_ENDPOINT,
5 body: {
6 client_id: CLIENT_ID,
7 client_secret: CLIENT_SECRET,
8 code: oauthCode,
9 grant_type: 'authorization_code',
10 redirect_uri: OAUTH2_CALLBACK,
11 },
12 encodeBodyAsJSON: true,
13});
14
15let tokens = JSON.parse(res.body.text());
16if (typeof tokens.expires_in === "undefined") {
17 throw new Error("Error response from Google: " + JSON.stringify(tokens))
18}
19if (typeof tokens.refresh_token === "undefined") {
20 return {
21 "message": \`You appear to have already linked to Google. You may need to revoke your OAuth token (${tokens.access_token}) and delete your auth token document. https://developers.google.com/identity/protocols/oauth2/web-server#tokenrevoke\` }; } tokens._id = "youtube"; tokens.updated = new Date(); tokens.expires_at = new Date(); tokens.expires_at.setTime(Date.now() + (tokens.expires_in \* 1000)); const tokens_collection = context.services.get("mongodb-atlas").db("auth").collection("auth_tokens"); if (await tokens_collection.findOne({ \_id: "youtube" })) { await tokens_collection.updateOne( { \_id: "youtube" }, { '$set': tokens } ); } else { await tokens_collection.insertOne(tokens); } return {"message": "ok"};

There's quite a lot of code here to implement Step 5, but it's not too complicated. It makes a request to the Google token endpoint, providing the code from the URL, to obtain both an access token and a refresh token for when the access token expires (which it does after an hour). It then checks for errors, modifies the JavaScript object a little to make it suitable for storing in MongoDB, and then it saves it to the tokens_collection. You can find all the code for this webhook function on GitHub.

#Authorizing the Realm App

Go to the webhook's "Settings" tab, copy the webhook's URL, and paste it into a new browser tab. You should see the following scary warning page! This is because the app has not been checked out by Google, which would be the case if it was fully published. You can ignore it for now—it's safe because it's your app. Click "Continue" to progress to the consent page.

A scary screenshot, saying "Google hasn't verified this app"
You can "continue" to the consent page.

The consent page should look something like the screenshot below. Click "Allow" and you should be presented with a very plain page that says {"status": "okay" }, which means that you've completed all of the authorization steps!

A screenshot of the YouTube OAuth2 consent page.
Click "allow" to complete the authorization

If you load up the auth_tokens collection in MongoDB Atlas, you should see that it contains a single document containing the access and refresh tokens provided by Google.

A screenshot of the authorization tokens in MongoDB Atlas
Check the document has been added to your auth_tokens collection.

#Using the Tokens to Make a Call

To make a test call, create a new HTTP service webhook, and paste in the following code:

1exports = async function(payload, response) {
2const querystring = require('querystring');
3
4// START OF TEMPORARY BLOCK -----------------------------
5// Get the current token:
6const tokens_collection =
7context.services.get("mongodb-atlas").db("auth").collection("auth_tokens");
8const tokens = await tokens_collection.findOne({_id: "youtube"});
9// If this code is executed one hour after authorization, the token will be invalid:
10const accessToken = tokens.access_token;
11// END OF TEMPORARY BLOCK -------------------------------
12
13// Get the channels owned by this user:
14const url = new URL("https://www.googleapis.com/youtube/v3/playlists");
15url.search = querystring.stringify({
16 "mine": "true",
17 "part": "snippet,id",
18});
19
20// Make an authenticated call:
21const result = await context.http.get({
22 url: url.href,
23 headers: {
24 'Authorization': [\`Bearer ${accessToken}\`], 'Accept': ['application/json'], }, }); response.setHeader('Content-Type', 'text/plain'); response.setBody(result.body.text()); };

The summary of this code is that it looks up an access token in the auth_tokens collection, and then makes an authenticated request to YouTube's playlists endpoint. Authentication is proven by providing the access token as a bearer token in the 'Authorization' header.

Test out this function by calling the webhook in a browser tab. It should display some JSON, listing details about your YouTube playlists. The problem with this code is that if you run it over an hour after authorizing with YouTube, then the access token will have expired, and you'll get an error message! To account for this, I created a function called get_token, which will refresh the access token if it's expired.

#Token Refreshing

The get_token function is a standard MongoDB Realm serverless function, not a webhook. Click "Functions" on the left side of the page in MongoDB Realm, click "Create New Function," and name your function "get_token." In the function editor, paste in the following code:

1exports = async function(){
2
3 const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
4 const CLIENT_ID = context.values.get("GOOGLE_CLIENT_ID");
5 const CLIENT_SECRET = context.values.get("GOOGLE_CLIENT_SECRET");
6
7 const tokens_collection = context.services.get("mongodb-atlas").db("auth").collection("auth_tokens");
8
9 // Look up tokens:
10 let tokens = await tokens_collection.findOne({_id: "youtube"});
11
12 if (new Date() >= tokens.expires_at) {
13 // access_token has expired. Get a new one.
14 let res = await context.http.post({
15 url: GOOGLE_TOKEN_ENDPOINT,
16 body: {
17 client_id: CLIENT_ID,
18 client_secret: CLIENT_SECRET,
19 grant_type: 'refresh_token',
20 refresh_token: tokens.refresh_token,
21 },
22 encodeBodyAsJSON: true,
23 });
24
25 tokens = JSON.parse(res.body.text());
26 tokens.updated = new Date();
27 tokens.expires_at = new Date();
28 tokens.expires_at.setTime(Date.now() + (tokens.expires_in \* 1000));
29
30 await tokens_collection.updateOne(
31 {
32 \_id: "youtube"
33 },
34 {
35 $set: {
36 access_token: tokens.access_token,
37 expires_at: tokens.expires_at,
38 expires_in: tokens.expires_in,
39 updated: tokens.updated,
40 },
41 },
42 );
43 }
44 return tokens.access_token
45};

The start of this function does the same thing as the temporary block in the webhook—it looks up the currently stored access token in MongoDB Atlas. It then checks to see if the token has expired, and if it has, it makes a call to Google with the refresh_token, requesting a new access token, which it then uses to update the MongoDB document.

Save this function and then return to your test webhook. You can replace the code between the TEMPORARY BLOCK comments with the following line of code:

1// Get a token (it'll be refreshed if necessary):
2const accessToken = await context.functions.execute("get_token");

From now on, this should be all you need to do to make an authorized request against the Google API—obtain the access token with get_token and add it to your HTTP request as a bearer token in the Authorization header.

#Conclusion

I hope you found this useful! The OAuth 2 protocol can seem a little overwhelming, and the incompatibility of various client libraries, such as Google's, with MongoDB Realm can make life a bit more difficult, but this post should demonstrate how, with a webhook and a utility function, much of OAuth's complexity can be hidden away in a well designed MongoDB Realm app.

If you have questions, please head to our developer community website where the MongoDB engineers and the MongoDB community will help you build your next big idea with MongoDB.

Rate this article
© 2021 MongoDB, Inc.

About

  • Careers
  • Legal Notices
  • Privacy Notices
  • Security Information
  • Trust Center
© 2021 MongoDB, Inc.