DEV Community

Brandon Benefield
Brandon Benefield

Posted on

Create your own Serverless OAuth Portal for Netlify CMS

Contents

Acknowledgement

Before starting this post, I need to give a big shout out to Mark Steele whos Serverless solution is actually the basis of this post and you'll even be using some of the code from his repository, Netlify Serverless OAuth2 Backend.

Prerequisites

Get your frontend running

Before we can worry about authenticating users to allow them to create content for our site, we first need a site to begin with. Head on over to the Netlify CMS one-click solutions page and choose a starter template. For the purposes of this post, we're going to use the One Click Hugo CMS template for no other reason than that is the template I am most familiar with. Choose your template and follow the instructions. In just a moment, you should land on your new websites' dashboard page.

Congrats, in just a few simple clicks you now have a website that you can use to start creating blog posts, pages, etc.

Dashboard for your new website

Create the GitHub OAuth App

Our next step is to create a GitHub OAuth Application. Alternatively, you can follow along with on the GitHub website or you can follow the next bit of instructions.

On GitHub, click on your profile picture in the top right corner of GitHub and at the bottom of the dropdown click on "Settings". On this page go ahead and click on "Developer Settings" on the bottom left of the navigation menu on the left hand side of the page. On the next page choose "OAuth Apps" and then click on the "New OAuth App" button on the top right of the page. Go ahead and fill in the form and click on the "Register application" button on the bottom left.

GitHub Register OAuth App

Save GitHub OAuth App credentials somewhere secure

Now that we have our OAuth application we need to store the sensitive information that was generated with it, Client ID and Client Secret. You need to treat these values as if they were your very own credentials into your bank account meaning do not share these with anyone.

Leave this browser tab open as we'll need those values in just a moment. Open a new tab and navigate to https://aws.amazon.com/ and click on the "Sign In to the Console" button at the top right of the page.

Sign into AWS console

After you login use the "Find Services" search bar and search for "Secrets Manager" and click on the resulting search.

AWS Search SecretsManager

On the next page you need to click the "Store a new secret" button in the top right corner.

AWS store new secret

Fill in the form adding two new "Secret key/value" pairs as shown in the image below and click "Next" at the bottom right.

AWS add secret

Fill in the next form as well and click "Next" at the bottom right of the page.

AWS store secrets

Leave this next page on its default settings and click "Next".

AWS SecretsManager default settings

Finally, just scroll to the very bottom and click the "Store" button on the bottom right.

Create your OAuth Lambdas

This portion might sound daunting, especially if you've never had to handle anything cloud or authentication related but honestly, this part is pretty simple. There is a bit of confusing code but we'll go over it to get a better understanding of what's going on.

Head over to your AWS Lambda page and click on Create Function in the top right corner.

AWS Lambda Create Function button in the top right corner of the page

On the next screen go ahead and fill in a few of the options just like mine:

  • Author from Scratch
  • Function Name: CreateYourOwnServerlessOauthPortalForNetlifyCms__redirect (feel free to rename this)
  • Runtime: Node.js 12.x

There's no need to create a special role or give this role any special permissions. The default permissions that AWS attaches will be enough for this Lambda.

Fill in the details for your Lambda

Now let's create a second Lambda with all the same parameters but this time replace __redirect with __callback and click on the "Choose or create an execution role" dropdown at the bottom left of the page, choose "Use an existing role" and select the role AWS created for the __redirect Lambda. If you followed my naming conventions it should be something along the lines of service-role/CreateYourOwnServerlessOauthPortalForNetlifyCms__r-role-abc123. We're reusing the same role because both Lambdas need permission to the same resource (Secrets Manager) so we can just reuse the same role and permissions. If needed in the future, you may change the roles or even add policy permissions to them as you see fit.

Great, you now have two Lambdas. From now on we'll refer to the first one as the __redirect Lambda and the second as the __callback Lambda.

Before we give our Lambdas permission I think it would be a good idea to see a common but easily fixed error. Open up your __redirect lambda and replace the code inside with the following:

const AWS = require('aws-sdk')
const secretsManager = new AWS.SecretsManager({ region: 'us-east-1' })

exports.handler = async () => {
    const secrets = await secretsManager.getSecretValue({ SecretId: 'GH_TOKENS' }).promise()
    return {
        statusCode: 200,
        body: JSON.stringify(secrets)
    }
}

Hit the "Save" and then the "Test" button at the top and you should receive an error saying:

{
  "errorType": "AccessDeniedException",
  "errorMessage": "User: arn:aws:sts::123123:assumed-role/CreateYourOwnServerlessOauthPortalForNetlifyCms__r-role-abc123/CreateYourOwnServerlessOauthPortalForNetlifyCms__redirect is not authorized to perform: secretsmanager:GetSecretValue on resource: arn:aws:secretsmanager:us-east-1:123123:secret:GH_TOKENS-abc123"
  ... More error message ....
}

This error is pretty self explanatory but can be confusing when you receive this in the middle of the stress that is learning AWS. Like I said, the fix is simple and the first step is to choose the "Permissions" tab right above your Lambda code.

Click Lambda permissions tab

Click on the dropdown arrow for the policy already created in the table and choose the "Edit policy" button.

Edit Lambda policy

Click on the "(+) Add addutuibak permissions" button on the right hand side of this next page.

Add additional permissions

Click on "Service" and search for "Secrets Manager" and choose the only option available.

Search for SecretManager service

Clck on "Actions", "Access level", and finally choose the "GetSecretValue" check box.

Set access level to read and retrieve secret values

Next click on "Resources" and choose the "Specific" radial option then proceed to click "Add ARN" a little to the right of the radial options.

Add resource-specific ARN

Go back to your SecretsManager, find stored secret and copy its ARN and paste it into the input opened from the "Add ARN" link.

Copy secret ARN

Specific ARN for secret

Now click "Review policy" and then "Save changes" and you should be good to go. You can always double check by going back to view the policy, click the policy dropdown arrow and making sure it has the "Secrets Manager" policy attached to it.

SecretManager policy added

Go back to your __redirect Lambda and click the "Test" button and you should now be greeted with a green success card, statusCode 200 and some JSON as the body.

Triggering Lambdas

Lambda functions are fun on their own but we'll need a way to trigger the code inside to run under certain conditions. For our use case, we just need an endpoint and have it run whenever someone hits that endpoint. Luckily enough, creating API Endpoints through the Lambda UI is really straight forward.

I'm going to explain how to do this for the __redirect Lambda but the steps are near identical for both. The only difference is the __callback URL will use the API Gateway created from the __redirect URL instead of creating a new API Gateway.

Navigate to your __redirect Lambda and click on the "Add trigger" button on the left side of the page.

Click on the Add Trigger button on the left side of the page

On the next page just follow along with the image:

  • API Gateway
  • Create an API
  • HTTP API
  • Security: Open

Steps on creating an API Gateway

Go ahead and navigate to your __callback Lambda and create a second trigger, this time choose your previously created API Gateway as the API choice in the second dropdown input.

Steps to create your second Lambda trigger

You should now have two API endpoints that you can send data to or receive data from.

Write some OAuth code

Open up your terminal and navigate to where you would like to store your CMS repo. From there I want you to clone your repo and navigate inside. In the root of the repo create a new directory named "OAuthLambdas" and go inside.

mkdir OAuthLambdas
cd OAuthLambdas

Once inside, we need to initialize this directory as a Node project and install the node-fetch package using npm:

npm init -y
npm i node-fetch

Last, we need to create some new files and directories with the following commands:

mkdir handlers utils
touch handlers/redirect.js handlers/callback.js utils/authenticateGitHubUser.js utils/callbackHtmlPage.js

If done correctly, your OAuthLambdas directory should have the following structure:

OAuthLambdas/
---- handlers/
    ---- redirect.js
    ---- callback.js

---- node_modules/

---- utils/
    ---- authenticateGitHubUser.js
    ---- callbackHtmlPage.js

---- package.json
  • Open redirect.js and place the following code inside
const AWS = require('aws-sdk')

/**
 * Redirects users to our NetlifyCms GitHub OAuth2.0 page
 */
exports.handler = async () => {
    const region = "us-east-1"  // the Region we saved OAuth App Client Id into the AWS SecretsManager
    const secretsManager = new AWS.SecretsManager({ region })  // SecretsManager API
    const SecretId = "GH_TOKENS"  // The Secret container we want to access (Not the values but this holds the values)
    const { SecretString } = await secretsManager.getSecretValue({ SecretId }).promise()  // This gives us all of the values from the Secrets Container
    const { CLIENT_ID } = JSON.parse(SecretString)  // SecretString stores our values as a string so we need to transform it into an object to make it easier to work with
    const Location = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&scope=repo%20user`  // Standard GitHub OAuth URL learn more here: https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/#1-request-a-users-github-identity
    return {
        statusCode: 302,  // "302" required for AWS Lambda to permit redirects
        headers: { Location }  // "Location" header sets redirect location
    }
}

  • Open callback.js and place the following code inside
const { authenticateGitHubUser } = require('../utils/authenticateGitHubUser')

exports.handler = async (e, _ctx, cb) => {
    try {
        return await authenticateGitHubUser(e.queryStringParameters.code, cb)
    }
    catch (e) {
        return {
            statusCode: 500,
            body: JSON.stringify(e.message)
        }
    }
}
  • Open authenticateGitHubUser.js and place the following code inside
const AWS = require('aws-sdk')
const fetch = require('node-fetch')

const { getScript } = require('./getScript')

async function authenticateGitHubUser(gitHubAuthCode, cb) {
    const region = "us-east-1"
    const client = new AWS.SecretsManager({ region })
    const SecretId = "GH_TOKENS"
    const { SecretString } = await client.getSecretValue({ SecretId }).promise()
    const { CLIENT_ID, CLIENT_SECRET } = JSON.parse(SecretString)
    const postOptions = {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
        body: JSON.stringify({
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
            code: gitHubAuthCode
        })
    }
    const data = await fetch('https://github.com/login/oauth/access_token', postOptions)
    const response = await data.json()

    cb(
        null,
        {
            statusCode: 200,
            headers: {
                'Content-Type': 'text/html',
            },
            body: getScript('success', {
                token: response.access_token,
                provider: 'github',
            }),
        },
    )
}

exports.authenticateGitHubUser = authenticateGitHubUser
  • Open callbackHtmlPage.js and place the following code inside
function getScript(mess, content) {
    return `<html><body><script>
    (function() {
        function receiveMessage(e) {
        console.log('authorization:github:${mess}:${JSON.stringify(content)}')
        window.opener.postMessage(
            'authorization:github:${mess}:${JSON.stringify(content)}',
            '*'
        )
        window.removeEventListener("message", receiveMessage, false);
        }
        window.addEventListener("message", receiveMessage, false)
        window.opener.postMessage("authorizing:github", "*")
        })()
    </script></body></html>`;
}

exports.getScript = getScript

From local to remote

We have our Lambdas but only locally. We need an easy way to move that code from our machine to AWS Lambda so we can finally run this code. Finally, this is where the AWS CLI comes in handy.

With your terminal open make sure you're in the OAuthLambdas directory. From there you need to run the following commands replacing the --function-name values with whatever you've named your Lambdas over on AWS.

user@group:~$ zip -r ../foo.zip .

zip -r ../OAuthLambdas.zip .

aws lambda update-function-code \
--function-name CreateYourOwnServerlessOauthPortalForNetlifyCms__redirect \
--zip-file fileb://$PWD/../OAuthLambdas.zip

aws lambda update-function-code \
--function-name CreateYourOwnServerlessOauthPortalForNetlifyCms__callback \
--zip-file fileb://$PWD/../OAuthLambdas.zip

rm -rf ../OAuthLambdas.zip

On a successful update you should receive some JSON in your terminal similar to the following

{
    "FunctionName": "CreateYourOwnServerlessOauthPortalForNetlifyCms__callback",
    "FunctionArn": "arn:aws:lambda:us-east-1:abc123:function:CreateYourOwnServerlessOauthPortalForNetlifyCms__callback",
    "Runtime": "nodejs12.x",
    "Role": "arn:aws:iam::abc123:role/service-role/CreateYourOwnServerlessOauthPortalForNetlifyCms__c-role-0pttkkqs",
    "Handler": "index.handler",
    "CodeSize": 51768,
    "Description": "",
    "Timeout": 3,
    "MemorySize": 128,
    "LastModified": "2020-04-01T00:36:58.395+0000",
    "CodeSha256": "abc123=",
    "Version": "$LATEST",
    "TracingConfig": {
        "Mode": "PassThrough"
    },
    "RevisionId": "abc123",
    "State": "Active",
    "LastUpdateStatus": "Successful"
}

Go to AWS Lambda in your browser and manually check that both Lambdas have been updated

Test your OAuth Lambdas

  • Open your __redirect Lambda
  • Change "Handler" input found above the code on the right side to handlers/redirect.handler
  • Click "Save" in top right corner
  • Click "Test" button in top right corner
  • Click "Configure test events" from dropdown
  • Name the test "RedirectTest"
  • Insert following:

Go back to your browser and navigate to your __redirect Lambda in AWS. First thing you need to do is change the Handler input to match your Lambda. For __redirect this value will be handlers/redirect.handler. Make sure to click "Save" at the top right of the page.

Before testing this Lambda we need to set the data that will be passed to it. This Lambda is pretty simple and isn't expecting any data. Click on the dropdown input left of the "Test" button and choose "Configure test events" and replace the data inside with an empty object.

Configure redirect Lambda test

Now we need to click "Test" at the top right corner of the page and you should be greeted with a nice success message similar to the following:

{
  "statusCode": 302,
  "headers": {
    "Location": "https://github.com/login/oauth/authorize?client_id=abc123&scope=repo%20user"
  }
}

Now that we know our __redirect Lambda is working as expected lets open up our __callback Lambda. Again, we need to change the Handler input to match what we're exporting. This time, the value will be handlers/callback.handler and click "Save".

Just like in our __redirect Lambda, we need to set our test data. Follow the same steps as above only this time we need to pass data to our Lambda. Put the following JSON inside and click "Save".

{
  "queryStringParameters": {
    "code": "abc123"
  }
}

Go ahead and click "Test" and if everything was set up correctly you should receive the following success message.

{
  "statusCode": 200,
  "headers": {
    "Content-Type": "text/html"
  },
  "body": "<html><body><script>\n    (function() {\n      function receiveMessage(e) {\n        console.log('authorization:github:success:{\"provider\":\"github\"}')\n        window.opener.postMessage(\n          'authorization:github:success:{\"provider\":\"github\"}',\n          '*'\n        )\n        window.removeEventListener(\"message\", receiveMessage, false);\n      }\n      window.addEventListener(\"message\", receiveMessage, false)\n      window.opener.postMessage(\"authorizing:github\", \"*\")\n      })()\n    </script></body></html>"
}

This looks confusing but it means everything is working. If you look at the body property you'll notice that it's the same code in our callbackHtmlPage.js file.

Start up your local frontend

  • In terminal navigate to root of your project
  • In terminal run command yarn or npm i
  • In terminal run yarn start or npm start
  • You will know your project is up and running if your terminal looks similar to the following

We're almost there! I can see the finish line. Last thing to do is to run our CMS locally and successfull authenticate.

Back to your terminal, make sure you're in the root of your project and run the following commands.

yarn
yarn start

Let your dependencies download and let Hugo and Webpack finish its tasks. When that's complete you should see the following in your terminal.

                   | EN  
-------------------+-----
  Pages            | 10  
  Paginator pages  |  0  
  Non-page files   |  0  
  Static files     | 43  
  Processed images |  0  
  Aliases          |  1  
  Sitemaps         |  1  
  Cleaned          |  0  

Watching for changes in ~/dev/one-click-hugo-cms-dev.to-post/site/{content,data,layouts,static}
Press Ctrl+C to stop
Watching for config changes in site/config.toml
ℹ 「wds」: Project is running at http://localhost:3000/
ℹ 「wds」: webpack output is served from /
ℹ 「wds」: Content not from webpack is served from ~/dev/one-click-hugo-cms-dev.to-post/dist
ℹ 「wds」: 404s will fallback to /index.html
ℹ 「wdm」: wait until bundle finished: /
ℹ 「wdm」: Hash: c80db40b3737e7b46070
Version: webpack 4.42.0

Good! From here just open your browser, navigate to http://localhost:3000, and make sure your coffee website loads.

Website running locally at http://localhost:3000

Login into your local CMS backend

The last step, I promise. Navigate to your CMS login page, http://localhost:3000/admin/, click on the "Login with GitHub" button.

CMS Login page

This should open up a separate window asking you to give your GitHub OAuth app the required permissions.

Just follow the steps and after a few clicks the window should close and you are now authenticated into your CMS and ready to write some new content.

CMS Admin page

Conclusion

Alright, you've done it! Grab a drink, sit back, and relax with confidence that you authentication system is working and secure, backed by GitHub.

I'm only human so if you see any mistakes please do not hesitate to leave a comment correcting me! I'd really appeciate the help.

If you run into any errors make sure to double check your work. If you can't figure it out then leave a comment with your situation and any relevant errors.

Top comments (2)

Collapse
 
lookea profile image
Lookea Dev Support

wait a second, I don't see were are you saying your frontend how to contact or use your lambda function in aws.

Collapse
 
r063r profile image
RogerBlasco • Edited

In case anyone else finds this - in your /static/ folder of a typical hugo install, put the netlify CMS config and index file. (config.yml and index.html)
Get the lambda API address.
In the netlify config:
base_url: Everything in the Lambda API address up to .com
auth_endpoint: every after the .com (/default/name of lambda)