DEV Community

Cover image for Google Firebase Authentication with AWS Lambda + Serverless Framework + Custom Authorizer
Anden Acitelli
Anden Acitelli

Posted on • Updated on • Originally published at andenacitelli.com

Google Firebase Authentication with AWS Lambda + Serverless Framework + Custom Authorizer

Overview

I just finished a v1.0 of a task scheduler app, Source, I'm gradually working on. You can find a minimally functional version of the app at https://sourceapp.dev. It's super basic right now, but I'll be gradually building on it until it's eventually something I use in my day-to-day life.

Recently, I overhauled the codebase to run entirely serverless on AWS Lambda + API Gateway. However, I had too much existing Firebase code to make switching to Cognito worth it. I really struggled to find up-to-date resources on how to set up Firebase on Lambda, so I figured I outline my approach to authentication here for others to use as a baseline.

I work for Akkio, where I'm building out a no-code predictive AI platform. If you're looking to harness the power of AI without needing a data scientist, give us a try!

Table of Contents

  1. Tech Stack
  2. The Frontend
  3. The Backend

Tech Stack

Frontend:

  • React: Once you get used to writing declarative JavaScript, you can never go back. I chose React because it was commonly used in the industry and its "pure functions" approach helped make behavior much more predictable.
  • TypeScript: Once you start to really scale up an application and store state, a stronger type system really helps you out. It increases time to write a bit, but when you're refactoring or adding features, it makes it incredibly quick to figure out where you'll need to change things when adding new features or changing things to fix bugs. Overall, it cut my development time way down and was 100% worth it.
  • Redux: Once you need certain pieces of state everywhere, and you need to keep things like API calls organized, Redux is an excellent way to go. It does require a bit of boilerplate to get set up, but after that it keeps your state incredibly organized.
  • Firebase: I initially chose this because authentication was free and easy to integrate.

Backend:

  • AWS Lambda: With Lambda, you can basically assign your API routes each their own Lambda function that gets fired up whenever someone makes a request on that route.
  • Serverless Framework: Makes deploying AWS Lambda code dramatically easier. It's a declarative way to make everything happen, and saves a bunch of time. I'd recommend the plugins serverless-offline, serverless-dynamodb-local, and serverless-python-requirements if you're going for quick prototyping.
  • API Gateway: Serverless framework handles this all for you if you set it up right. This is what routes API requests to their respective functions.
  • Python: Lambda supports many languages; I simply chose this because I like Python as a language and wanted more experience with it.

Database:

  • DynamoDB: An extremely quick and scalable key-value database that fit my use case well. Generous free tier, as well.

The Frontend

First, we need to trigger the actual Firebase prompt:

import StyledFirebaseAuth from "react-firebaseui/StyledFirebaseAuth";
import firebase from "firebase/compat/app";
import { auth } from "../../firebase/auth/auth";
import "firebaseui/dist/firebaseui.css";

const uiConfig = {
  signInFlow: "popup",
  signInSuccessUrl: "/",
  signInOptions: [
    firebase.auth.GoogleAuthProvider.PROVIDER_ID,
    firebase.auth.GithubAuthProvider.PROVIDER_ID,
    firebase.auth.EmailAuthProvider.PROVIDER_ID,
  ],
};

const Login = () => {
  return (
    <>
      <h3>Source</h3>
      <p>Please Sign In</p>
      <StyledFirebaseAuth uiConfig={uiConfig} firebaseAuth={auth} />
    </>
  );
};
export default Login;
Enter fullscreen mode Exit fullscreen mode

Then, we need somewhere to store that state in Redux. Here's the reducer, which is pretty barebones:

import { AnyAction } from "redux";
export default function userReducer(state = null, action: AnyAction) {
  switch (action.type) {
    case "SIGN_IN":
      return {
        uid: action.uid,
      };
    case "SIGN_OUT":
      return null;
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

As a reminder, this is TypeScript. I'd recommend anyone reading this learn TypeScript, but to convert to JavaScript, just remove the type annotations.

To actually update the Redux state, we fire off these actions:

import firebase from "firebase/compat/app";

export function signIn(user: firebase.User) {
  return {
    type: "SIGN_IN",
    uid: user.uid,
  };
}

export function signOut() {
  return {
    type: "SIGN_OUT",
  };
}
Enter fullscreen mode Exit fullscreen mode

We trigger those actions by listening to Firebase state changes:

import "firebase/compat/auth";
import axios from "axios";
import firebase from "firebase/compat/app";
import store from "../../store";
import { signIn, signOut } from "./actions";

const firebaseConfig = {
  apiKey: "REPLACE_ME",
  authDomain: "PROJECT_ID.firebaseapp.com",
  projectId: "PROJECT_ID",
  storageBucket: "PROJECT_ID.appspot.com",
  messagingSenderId: "REPLACE_ME",
  appId: "REPLACE_ME",
  measurementId: "REPLACE_ME",
};

firebase.initializeApp(firebaseConfig);

firebase.auth().onAuthStateChanged(async (user) => {
  if (user) {
    const token = await user.getIdToken();
    axios.defaults.headers.common["Authorization"] = token;
    store.dispatch(signIn(user));
  } else {
    delete axios.defaults.headers.common["Authorization"];
    store.dispatch(signOut());
  }
});

export const auth = firebase.auth();
Enter fullscreen mode Exit fullscreen mode

Note how we attach the new authorization header to every Axios request, as our backend is going to need that to authenticate requests.

Then, we switch what we display based on this state:

const user = useSelector((state: State) => state.user);
  if (user) {
    return (
        // Display components accessible when logged in
    )
  }

  else {
    const Login = React.lazy(() => import("./_components/Pages/Login"));
    return (
      <React.Suspense fallback={<div>Loading...</div>}>
        <Login />
      </React.Suspense>
    )
  }
Enter fullscreen mode Exit fullscreen mode

The Backend

Here's the relevant bits of my serverless.yaml:

service: INSERT_SERVICE_NAME
app: INSERT_APP_NAME
org: INSERT_ORG_NAME

# Pin service to specific version
frameworkVersion: "3"

provider:
  name: aws
  runtime: python3.8
  stage: dev
  region: us-east-2
  httpApi:
    cors: true
    authorizers:
      authorizer:
        identitySource: $request.header.Authorization
        issuerUrl: https://securetoken.google.com/INSERT_FIREBASE_APPID_HERE
        audience:
          - INSERT_FIREBASE_APPID_HERE
  role: INSERT_ROLE_ARN_HERE

package:
  exclude:
    - node_modules/**
    - venv/**
    - .dynamodb/**

custom:
  pythonRequirements:
    dockerizePip: non-linux
functions:
  authorizer:
    handler: api/auth.auth
    memorySize: 128
  events-list:
    handler: api/events.list_events
    memorySize: 128
    events:
      - http:
          path: /api/events
          method: get
          authorizer: authorizer
          cors: true
resources:
  Resources:
    GatewayResponseDefault4XX:
      Type: "AWS::ApiGateway::GatewayResponse"
      Properties:
        ResponseParameters:
          gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
          gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
        ResponseType: DEFAULT_4XX
        RestApiId:
          Ref: "ApiGatewayRestApi"
Enter fullscreen mode Exit fullscreen mode

Important notes about the above:

  • You'll need to slot in your own project details at many points.
  • The provider role was necessary for me because I needed the Lambda function executions to have read/write access to DynamoDB. I set up the role manually and pasted the corresponding ARN in there.
  • The package/exclude section makes it so things like node_modules and anything else not required by the functions isn't bundled with it. This cut my file size from like 100MB to about 30MB. 250MB is the max that Lambda supports.
  • The sample events-list function is there to shows how to assign your authorizer to "protect" a given function. I have like 15 actual functions in the deployed version of the app, for all my CRUD functionality.
  • The resources section is necessary for the custom authorizer's CORS to work properly.

Here's the necessary requirements.txt file, which the serverless-python-requirements plugin automatically bundles with your functions:

boto3
firebase_admin
Enter fullscreen mode Exit fullscreen mode

Here's the custom authorizer that worked for me:

import firebase_admin.auth
from firebase_admin import credentials
import firebase_admin

cred = credentials.Certificate("credentials.json")
firebase_admin.initialize_app(cred)


def auth(event, context):
    try:
        token = firebase_admin.auth.verify_id_token(
            event["authorizationToken"])
        policy = generate_policy(token["uid"])
        return policy
    except Exception as err:
        print("Error verifying token: ", err)
        raise Exception("Unauthorized")


def generate_policy(uid, effect):
    return {
        'principalId': uid,
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [
                {
                    "Action": "execute-api:Invoke",
                    "Effect": effect,
                    "Resource": '*'
                }
            ]
        },
        "context": {
            "uid": uid  # Lets us easily access in "downstream" Lambda functions
        }
    }
Enter fullscreen mode Exit fullscreen mode

The "context" bit at the bottom of the policy essentially appends this uid to the request, making it possible for us to do database operations in the actual handlers that come after this downstream. I use the main uid that Firebase provides as my main user key in my database.

For example, here's how I make an authenticated call to list all my events from the actual Lambda function:

from boto3.dynamodb.conditions import Key
import boto3
import json
from api.commonfuncs import replace_decimals
import decimal
from api.headers import HEADERS

dynamodb = boto3.resource("dynamodb")
events_table = dynamodb.Table("events")


def list_events(event, _):
    '''List all events for a user.'''
    uid = event["requestContext"]["authorizer"]["uid"]
    print(f"Getting events for user {uid}")
    try:
        response = events_table.query(
            KeyConditionExpression=Key("uid").eq(uid)
        )
        return {
            "statusCode": 200,
            'headers': HEADERS,
            "body": json.dumps(replace_decimals(response["Items"]))
        }
    except Exception as err:
        print(err)
        return {
            "statusCode": 500,
            'headers': HEADERS,
            "body": "Error getting events for user " + uid
        }
Enter fullscreen mode Exit fullscreen mode

You'll need this supporting function I found on Stack Overflow somewhere, as DynamoDB returns Decimals and Numbers in a custom type that you need to parse into Python types:

import decimal
def replace_decimals(obj):
    if isinstance(obj, list):
        for i in range(len(obj)):
            obj[i] = replace_decimals(obj[i])
        return obj
    elif isinstance(obj, dict):
        for k in obj:
            obj[k] = replace_decimals(obj[k])
        return obj
    elif isinstance(obj, decimal.Decimal):
        if obj % 1 == 0:
            return int(obj)
        else:
            return float(obj)
    else:
        return obj
Enter fullscreen mode Exit fullscreen mode

Conclusion

Hope this helped you some! This took me a few days of work to figure out, so I figure I document what I did to help anyone in a similar boat.

About Me

I'm Anden, a Software Engineer at JPMorgan Chase & Co. I mess around with full-stack web development and the cloud when I get the time, and blog about my experiences as a way to give back to the community that helped me get to where I am today.

Feel free to connect with me at any of these with questions, comments, and concerns:

If this article saved you some pain and you'd like to support my work, please consider buying me a box of coffee (they’re K-Cups from ALDI —please don’t judge me) on Paypal here.

Top comments (0)