DEV Community

Cover image for OAuth with Serverless using SST
Julien Bras
Julien Bras

Posted on • Originally published at sidoine.org

OAuth with Serverless using SST

Why we Need Authentication in a Serverless Application?

Once you have built your first serverless application, you may need to quickly invest effort in a authentication layer. For example on Why and How Migrate From Firebase to Serverless Stack? we choose to use an authentication based on Google in order to save some data relative to each user of our application.

Why it's a Challenge?

It's a very classic use-case to implement an authentication system based on third-part actors, like Google or Facebook. Instead of managing locally a users and passwords system, it's generally more secure to allow a login via Google, and then Google servers is validating to our systems that the login is a success. There is multiple advantage here:

  • the end-user have just to use his own Google login to connect to your application, and there is no need to create and store a new password
  • your application doesn't have to manage and secure a password system

This classic scenario is using generally a standard named OAuth, built to allow access delegation without sharing private credentials. As an example here is an abstract flow of the OAuth2 protocol found on Wikipedia:

OAuth Abstract Flow

There is a series of steps to achieve (about 5 or 6) and it's pretty tricky to implement this flow in a serverless stack, because such functions are by definition stateless: how to store temporary the authorization grant for example? That's why we generally rely on an authentication framework!

What are the Options?

If you want to stick to serverless deployment, there is multiple options to implement an Authentication layer:

  • SST is providing multiple constructs (Cognito, Auth) for this precise request
  • Next.js, which can be considered as a full-stack platform thanks to the backend capabilities, comes with NextAuth.js which is a promising solution to implement authentication in the platform.
  • It's also possible to build a custom layer for the authentication, but we will not expend here on this option as it's the most time-consuming one 😅

[!NOTE] NextAuth vs SST Auth
I plan to post an article to compare the 2 options. Stay tuned!

Let's Focus on SST Auth

This article will be focused on SST Auth construct only: The Cognito construct (was named Auth before SST 1.0 ) is based on AWS Cognito User Pool and Cognito Identity Pool, and provide less flexibility to the application owner at the end of the day. Read here why the SST team decided to create the new Auth construct.

The idea behind SST Auth is to provide to the application developer a simple way to implement an authentication system based. It's shipped with adapters for common use cases, like Google, Facebook adapters but you can extend it for your own use case.

Here we will use it to develop a small application with authentication based on the SmugMug provider. It's specific because SmugMug API is supporting only OAuth 1.0a today, so we will need to build our own custom adapter.

Build Our Application (step-by-step)

[!WARNING]
This tutorial will use yarn and TypeScript as default language (as a classic convention) but feel free to explore the other option offered by SST.

You can follow the extensive documentation, but the first step is to bootstrap our application:

yarn create sst --template=examples/api-sst-auth-google smugmug-auth
Enter fullscreen mode Exit fullscreen mode

This will use the examples/api-sst-auth-google starter to step up the coding time. The goal then will be to replace the Google authentication by SmugMug.

Navigate to your project:

cd smugmug-auth/
Enter fullscreen mode Exit fullscreen mode

First let's add a package to handle OAuth 1.0a authentication. My best bet is today oauth which is not new but seems widely used. Unfortunately it doesn't support promises out-of-the-box.

cd services/
yarn add oauth
yarn add @types/oauth --dev
yarn add @serverless-stack/node
Enter fullscreen mode Exit fullscreen mode

We can also populate the SmugMug API key and API key secret in a .env.local file (not pushed to git):

SMUGMUG_CLIENT_ID=your API key
SMUGMUG_CLIENT_SECRET=your API key secret
Enter fullscreen mode Exit fullscreen mode

In order to generate such keys, you need to create a dedicated application in SmugMug Developer section.

Then let's start the SST application from the root folder. For this step it's required to be authenticated on an AWS account.

yarn start
Enter fullscreen mode Exit fullscreen mode

SST is asking for a stage name. I use generally dev because I am the only one working on the application, and I deploy the production version on a dedicated prod stage later on. You can use the stage name you want.

Once the stack is up you can also start the react app:

cd web/
yarn dev
Enter fullscreen mode Exit fullscreen mode

And you can try to login on Google:

Login on Google

It doesn't work because of the mis-configuration of the application for the deployed backend but basically it's just a configuration point. Let's set up what we need for SmugMug authentication. Here is the flow we want:

  • the user is accessing our application
  • the user click on login
  • the user is redirected to SmugMug in order to login and accept to share some informations (email, full name) with our application
  • then the user is redirected to our application and authenticated.

Backend

Let's start by tweaking the backend. The function services/functions/auth.js is the heart of the authentication system. It's actually using the GoogleAdapter inside the AuthHandler provided by SST. Unfortunately there is no SmugMugAdapter yet, neither a generic OAuth1.0aAdapter. So let's build it!

Create a new folder services/functions/smugmug and a first api.ts file inside:

import OAuth from "oauth";
const domain = "https://api.smugmug.com";
const baseUrl = `${domain}/api/v2`;

const requestUrl = `${domain}/services/oauth/1.0a/getRequestToken`;
export const authUrl = `${domain}/services/oauth/1.0a/authorize`;
const accessUrl = `${domain}/services/oauth/1.0a/getAccessToken`;
const signatureMethod = "HMAC-SHA1";

export interface OAuthToken {
  token: string;
  tokenSecret: string;
}
export const SmugMugOAuth = class {
  oauth: OAuth.OAuth;

  constructor(consumerKey: string, consumerSecret: string) {
    this.oauth = new OAuth.OAuth(
      requestUrl,
      accessUrl,
      consumerKey,
      consumerSecret,
      "1.0A",
      null,
      signatureMethod,
      undefined,
      // mandatory to get JSON result on GET and POST method (LIVE API Browser instead!)
      { Accept: "application/json" }
    );
  }

  getOAuthRequestToken = async (callback: string): Promise<OAuthToken> => {
    return new Promise((resolve, reject) => {
      this.oauth.getOAuthRequestToken(
        {
          oauth_callback: callback,
        },
        (error, oAuthToken, oAuthTokenSecret, results) => {
          if (error) {
            reject(error);
          }
          resolve({ token: oAuthToken, tokenSecret: oAuthTokenSecret });
        }
      );
    });
  };

  getOAuthAccessToken = async (
    requestToken: OAuthToken,
    oAuthVerifier: string
  ): Promise<OAuthToken> => {
    return new Promise((resolve, reject) => {
      this.oauth.getOAuthAccessToken(
        requestToken.token,
        requestToken.tokenSecret,
        oAuthVerifier,
        (error, oAuthAccessToken, oAuthAccessTokenSecret, results) => {
          if (error) {
            reject(error);
          }
          resolve({
            token: oAuthAccessToken,
            tokenSecret: oAuthAccessTokenSecret,
          });
        }
      );
    });
  };

  get = async (url: string, accessToken: OAuthToken) => {
    return new Promise((resolve, reject) => {
      this.oauth.get(
        `${domain}${url}`,
        accessToken.token,
        accessToken.tokenSecret,
        (error, responseData, result) => {
          if (error) {
            console.log(error);
            reject(error);
          }
          if (typeof responseData == "string") {
            resolve(JSON.parse(responseData).Response);
          } else {
            const err = Error("not a valid answer");
            console.log(err.message);
            reject(err);
          }
        }
      );
    });
  };
};
Enter fullscreen mode Exit fullscreen mode

This file is creating a SmugMugOAuth class that is wrapping the oauth lib for us. It will then be easier to call for the various function provided by oauth, with promises support! It defines the following methods:

  • getOAuthRequestToken to get a Request Token (first part of the OAuth exchange)
  • getOAuthAccessToken to get an Access Token (second part of the OAuth exchange)
  • get to query the SmugMug API with a provided access token

Then in the same folder let's create an adapter.ts to build our custom SmugMugAdapter:

import {
  useCookie,
  useDomainName,
  usePath,
  useQueryParams,
} from "@serverless-stack/node/api";
import { createAdapter } from "@serverless-stack/node/auth";
import { APIGatewayProxyStructuredResultV2 } from "aws-lambda";
import { authUrl, OAuthToken, SmugMugOAuth } from "./api";

export interface SmugMugUser {
  userId: string;
  fullName: string;
  uri: string;
  webUri: string;
  accessToken: OAuthToken;
}

export interface SmugMugConfig {
  /**
   * The clientId provided by SmugMug
   */
  clientId: string;
  /**
   * The clientSecret provided by SmugMug
   */
  clientSecret: string;

  onSuccess: (user: SmugMugUser) => Promise<APIGatewayProxyStructuredResultV2>;
}

export const SmugMugAdapter = createAdapter((config: SmugMugConfig) => {
  return async function () {
    const oauth = new SmugMugOAuth(config.clientId, config.clientSecret);

    const [step] = usePath().slice(-1);

    if (step === "authorize") {
      // Step 1: Obtain a request token

      const callback =
        "https://" +
        [useDomainName(), ...usePath().slice(0, -1), "callback"].join("/");

      const requestToken = await oauth.getOAuthRequestToken(callback);

      // Step 2: Redirect the user to the authorization URL
      const expires = new Date(Date.now() + 1000 * 30).toUTCString();

      return {
        statusCode: 302,
        cookies: [
          `req-token=${JSON.stringify(
            requestToken
          )}; HttpOnly; expires=${expires}`,
        ],
        headers: {
          location: `${authUrl}?oauth_token=${requestToken.token}&Access=Full&Permissions=Modify`,
        },
      };
    }

    // Step 3: The user logs in to SmugMug (on SmugMug side)
    // The user is presented with a request to authorize your app

    // Step 4: If the user accepts, they will be redirected back to your app, with a verification code embedded in the request
    // Use the verification code to obtain an access token

    if (step === "callback") {
      const params = useQueryParams();
      const reqToken: OAuthToken = JSON.parse(useCookie("req-token"));
      const accessToken = await oauth.getOAuthAccessToken(
        reqToken,
        params.oauth_verifier!
      );
      const response: any = await oauth.get(`/api/v2!authuser`, accessToken);
      const user: any = response.User;

      return config.onSuccess({
        userId: user.NickName,
        fullName: user.Name,
        uri: user.Uri,
        webUri: user.WebUri,
        accessToken,
      });
    }

    throw new Error("Invalid auth request");
  };
});

Enter fullscreen mode Exit fullscreen mode

The class SmugMugAdapter is a custom adapter created by the createAdapter method described in the Auth documentation.

It describe all the steps for the OAuth 1.0a process:

  • when the endpoint authorize is called, it call the method getOAuthRequestToken
  • then we send the user to the authorization URL on SmugMug with the request token
  • SmugMug will redirect the user to the application with a verification code
  • With the verification code we can request an access token!
  • Finally we are calling the /api/v2!authuser to retrieve the autenticated user information.

Then we need to adapt the services/functions/auth.ts file to use our custom adapter:

import {
  Session,
  AuthHandler,
} from "@serverless-stack/node/auth";
import { Table } from "@serverless-stack/node/table";
import { ViteStaticSite } from "@serverless-stack/node/site";
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";
import { SmugMugAdapter, SmugMugUser } from "./smugmug/adapter";

declare module "@serverless-stack/node/auth" {
  export interface SessionTypes {
    user: {
      userID: string;
    };
  }
}

export const handler = AuthHandler({
  providers: {
    smugmug: SmugMugAdapter({
      clientId: process.env.SMUGMUG_CLIENT_ID!,
      clientSecret: process.env.SMUGMUG_CLIENT_SECRET!,
      onSuccess: async (user: SmugMugUser) => {
        const ddb = new DynamoDBClient({});
        await ddb.send(
          new PutItemCommand({
            TableName: Table.users.tableName,
            Item: marshall(user),
          })
        );

        return Session.parameter({
          redirect: process.env.IS_LOCAL
            ? "http://127.0.0.1:3000"
            : ViteStaticSite.site.url,
          type: "user",
          properties: {
            userID: user.userId,
          },
        });
      },
    }),
  },
});

Enter fullscreen mode Exit fullscreen mode

Basically we just remove the GoogleAdapter and put instead our SmugMugAdapter! We can also see that we are storing our user in a DynamoDB table (which was already created in the original template). It can be a nice place to store data related to our users.

Finally pass the environment variables to the auth Lambda in stacks/MyStack.ts:

  // Create Auth provider
  const auth = new Auth(stack, "auth", {
    authenticator: {
      handler: "functions/auth.handler",
      bind: [site],
      environment: {
        SMUGMUG_CLIENT_ID: process.env.SMUGMUG_CLIENT_ID!,
        SMUGMUG_CLIENT_SECRET: process.env.SMUGMUG_CLIENT_SECRET!,
      },
    },
  });
Enter fullscreen mode Exit fullscreen mode

Frontend

In the frontend we just have to modify the url to redirect the user. Instead of auth/google/authorize/, it will be auth/smugmug/authorize/:

In web/src/App.jsx, line 70:

<div>
      <a
        href={`${import.meta.env.VITE_APP_API_URL}/auth/smugmug/authorize`}
        rel="noreferrer"
>   
        <button>Sign in with SmugMug</button>
      </a>
        </div>
Enter fullscreen mode Exit fullscreen mode

And that's it ; you made it!

First login on SmugMug

The session page is a bit buggy here because we didn't adapt the Google data to the SmugMug data. But you can check that we can retrieve user information in the developer tools:

Developer tools

Here is the GitHub repository of the final project:

GitHub logo julbrs / sst-smugmug-auth

How to use SmugMug authentication in a SST project

How to add SmugMug authentication to a serverless API

An example serverless app created with SST, with a custom adapter (SmugMug, OAuth10a support)

Getting Started

Read the tutorial

Commands

npm run start

Starts the Live Lambda Development environment.

npm run build

Build your app and synthesize your stacks.

npm run deploy [stack]

Deploy all your stacks to AWS. Or optionally deploy, a specific stack.

npm run remove [stack]

Remove all your stacks and all of their resources from AWS. Or optionally removes, a specific stack.

npm run test

Runs your tests using Jest. Takes all the Jest CLI options.

Documentation

Learn more about the SST.

What we Learned Here?

Starting from a bootstrapped project from SST, we have easily adapted it to support the third-party authentication provider we need with the help of the easily extendable construct provided.

You can continue the journey by reading the extensive documentation about Auth. It's also possible to rely on the OAuth adapter that is supporting out of the box any OAuth2 compatible service.

Happy authentication!

Latest comments (0)