Kickstart your SaaS with Grafbase

Kickstart your SaaS with Grafbase

#Grafbase #GrafbaseHackathon

If you're a serial entrepreneur or just addicted to starting new projects then you've likely considered making or buying a starter kit for all your repos. Well instead of spending a dime or several hours/days coding you could instead try out this free SaaS starter kit. This starter kit features the power of Grafbase with the tooling of Turborepo and many more goodies.

Grafbase is a service for providing managed instant GraphQL endpoints using only a schema to get started! When I started working with Grafbase I immediately wanted to add it to my SaaS template. I have done contract work for companies using Appsync and Cognito and I love GraphQL but keeping user data in sync was always a challenge. I wanted a template that would bootstrap user creation, payments and Cognito integration seamlessly.

Another challenging aspect was that users should be able to sign in via multiple providers such as Google, Github or credentials and still have access to the same content regardless of the auth mechanism. Let's take a look at how this might be possible with Grafbase in this Hashnode guide.

Defining our Schema

Before we get into the schema you'll want to create a Grafbase account. I recommend you link a forked version of the starter kit using their Github integration.

// Grafbase/grafbase.config.ts
import { auth, config, connector, g } from '@grafbase/sdk';
import { Type } from '@grafbase/sdk/dist/src/type';

const stripe = connector.OpenAPI({
  schema:
    'https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json',
  url: 'https://api.stripe.com',
  headers: (headers) => {
    headers.set('Authorization', `Bearer ${g.env('STRIPE_SECRET_KEY')}`);
  },
});

const provider = auth.JWT({
  issuer: g.env('ISSUER_URL'),
  secret: g.env('NEXTAUTH_SECRET'),
});

g.datasource(stripe, { namespace: 'Stripe' });

const identityType = g.enum('IdentityType', ['CREDENTIALS', 'GOOGLE']);
const cognitoUser = g.type('CognitoUser', {
  sub: g.id(),
  stripeId: g.string().optional(),
  userId: g.string().optional(),
  email: g.email(),
  confirmationStatus: g.string(),
});

const user = g.model('User', {
  id: g.id(),
  name: g.string().optional(),
  email: g.email().unique(),
  identities: g
    .relation(() => identity)
    .list()
    .optional(),
  customer: g
    .ref(new Type('StripeCustomer'))
    .optional()
    .resolver('stripe/customer/byEmail'),
  cognitoUser: g.ref(cognitoUser).optional().resolver('cognito/user/byEmail'),
});

const identity = g.model('Identity', {
  id: g.id(),
  sub: g.string().unique(),
  type: g.enumRef(identityType),
  user: g.relation(user),
});

export default config({
  schema: g,
  auth: {
    providers: [provider],
    rules: (rules) => {
      rules.private();
    },
  },
});

This code takes advantage of the Typescript SDK provided by Grafbase. It might look intimidating but it will generate a Graphql schema for us that looks something like this.

extend schema
  @auth(
    providers: [
      { type: jwt, issuer: "https://grafbase.com", secret: "${Auth Secret}" }
    ]
    rules: [
      { allow: private }
    ]
  )extend schema
  @openapi(
    namespace: "Stripe"
    url: "https://api.stripe.com"
    schema: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"
    headers: [
      { name: "Authorization", value: "Bearer ${Token}" }
    ]
  )

enum IdentityType {
  CREDENTIALS,
  GOOGLE
}

type CognitoUser {
  sub: ID!
  stripeId: String
  userId: String
  email: Email!
  confirmationStatus: String!
}

type User @model {
  id: ID!
  name: String
  email: Email! @unique
  identities: [Identity!]
  customer: StripeCustomer @resolver(name: "stripe/customer/byEmail")
  cognitoUser: CognitoUser @resolver(name: "cognito/user/byEmail")
}

type Identity @model {
  id: ID!
  sub: String! @unique
  type: IdentityType!
  user: User!
}

Setting up the environment

Note: The template uses Stripe, Cognito and Google all of which will need to be configured before full use of the template. You can find documentation on setting up Stripe for development here for Cognito here and Google here.

Amazon Cognito has its quirks with Auth.js but you can follow this guide to get the correct callback URL etc. Assuming you've set up a Cognito user pool correctly, configured Google Oauth and created a stripe developer account the next step is to fill in the .env files for the different packages. You can use the .env.template files in the different packages for examples of what variables need to be included. Starting in the grafbase folder you will need at minimum the following set. The NEXTAUTH_SECRET can be any secret key so feel free to generate one online with a tool such as this.

grafbase/.env

AWS_COGNITO_ACCESS_KEY=${AWS Key}
AWS_COGNITO_SECRET_ACCESS_KEY=${AWS Secret}
AWS_COGNITO_REGION=${AWS Region}
COGNITO_USER_POOL_ID=${Userpool ID}
NEXTAUTH_SECRET=${Next Auth Secret}
ISSUER_URL=https://grafbase.com
STRIPE_SECRET_KEY=${Stripe Secret}

apps/web/.env

ISSUER_URL=https://grafbase.com
NEXTAUTH_SECRET={Next Auth Secret}
NEXTAUTH_URL=http://localhost:3000
NEXT_PUBLIC_ROOT_URL=http://localhost:3000
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=%{Stripe Publishable Key}

packages/amplify/.env

AWS_COGNITO_REGION=${AWS Region}
COGNITO_DOMAIN=${https://${app-name}.auth.${region}.amazoncognito.com}
COGNITO_USER_POOL_ID=${Userpool ID}
COGNITO_USER_POOL_CLIENT_ID=${Userpool Client ID}

packages/auth/.env

API_KEY=${Grafbase API key}
COGNITO_CLIENT_ID=${Cognito Client}
COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/${userpool ID}
GOOGLE_CLIENT_ID=${Google Client}
GOOGLE_CLIENT_SECRET=${Google Secret}
ISSUER_URL=https://grafbase.com
NEXTAUTH_SECRET={Next Auth Secret}
NEXTAUTH_URL=http://localhost:3000
STRIPE_TRIAL_PRODUCT_NAME=${Free trail product name}

packages/client/.env

API_ENDPOINT=${Grafbase API endpoint}

packages/payments-server/.env

STRIPE_SECRET_KEY=${Stripe Secret Key}

Once that setup is complete you should be able to run the full development experience using yarn dev or if you want to run the GraphQL playground you can do so with yarn dev:gql. With the Grafbase Pathfinder playground running open another terminal in the repo and run yarn codegen to generate the types for the project. Now let's go over some of what's defined in the GraphQL schema.

Syncing user data

The user model will be the primary glue for keeping track of user information from multiple sources.

type User @model {
  id: ID!
  name: String
  email: Email! @unique
  identities: [Identity!]
  customer: StripeCustomer! @resolver(name: "stripe/customer/byEmail")
  cognitoUser: CognitoUser! @resolver(name: "cognito/user/byEmail")
}

We will be using the email field to tie account information together which is why it will need to be a unique field. The customer and cognitoUser fields will contain custom resolvers which we'll go over in a bit. First, let's take a look at the identities field.

type Identity @model {
  id: ID!
  sub: String! @unique
  type: IdentityType!
  user: User!
}

Identity is another modal that contains a unique sub, identityType and user. The sub will correspond to the account ID which exists on the identityType. You can think of the type as an authentication provider and users can log in via multiple providers. We want to be able to see the different identities that each user might log in to whether it is credentials, Google, Github etc. So when a user logs in we will attempt to create a new user inside our Grafbase database if one doesn't exist and then link an identity to their account depending on the provider that was used.

// packages/auth/src/index.ts
export const callbacks: Partial<CallbacksOptions<Profile, Account>> = {
  async signIn({ account, profile, user }) {
    try {
      const email = user.email;
      const name = user.name ?? '';

      if (!email) {
        return false;
      }

      switch (account?.provider) {
        case 'credentials':
          await credentialsSigninHandler(user, email, name);
          break;
        case 'google': {
          const sub = profile?.sub ?? `GSTUB_${user.id}`;
          await googleSigninHandler(sub, email, name);
          break;
        }
        default:
          await credentialsSigninHandler(user, email, name);
      }

      return true;
    } catch (err: any) {
      console.error(err.message);
      return false;
    }
  },
// ...

We're using different handlers based on the sign-in provider that was used with Auth.js formally known as Next Auth. The auth package is set up to be easy to integrate with any number of NextJs apps that we might want to configure for authentication. For now, we're only focusing on the web application but Auth.js could easily be set up in any app as well.

The handlers will do the backend work we need to lookup user accounts and create identities but the real magic of allowing JWT-based authentication is defined inside our schema.

@auth(
    providers: [
      { type: jwt, issuer: "https://grafbase.com", secret: "${Auth Secret}" }
    ]
    rules: [
      { allow: private }
    ]
  )

I won't go into full detail on how to set this up since it's already been done in the template but if you want to learn more about how this works you can follow this excellent guide by Grafbase. So once our user is authenticated they can use their access key to make requests to our Grafbase endpoint but what about Stripe and Cognito? Let's take a look at those custom resolvers again.

// grafbase/resolvers/cognito/user/byEmail.ts
import type { User } from 'client';
import {
  AdminGetUserCommand,
  CognitoIdentityProviderClient,
} from '@aws-sdk/client-cognito-identity-provider';

export default async function Resolver(user: User) {
  try {
    const { email } = user;

    if (!email) return null;

    const cognito = new CognitoIdentityProviderClient({
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY as string,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
      },
      region: process.env.AWS_REGION || 'us-east-1',
    });

    const command = new AdminGetUserCommand({
      UserPoolId: process.env.COGNITO_USER_POOL_ID,
      Username: email,
    });
    const result = await cognito.send(command);

    return {
      sub: result.Username,
      confirmationStatus: result.UserStatus,
      email: result.UserAttributes?.find(({ Name }) => Name === 'email')?.Value,
      stripeId: result.UserAttributes?.find(
        ({ Name }) => Name === 'custom:stripeId',
      )?.Value,
      userId: result.UserAttributes?.find(
        ({ Name }) => Name === 'custom:userId',
      )?.Value,
    };
  } catch (ex: any) {
    if (ex.code === 'UserNotFoundException') {
      return null;
    }
    console.error(ex);
  }
}

This resolver will handle requests from the user model to get the cognitoUser field and request Cognito to return the user via the email attribute. I've also added custom attributes to my Cognito user pool to track the stripe ID and user ID from Grafbase in case those need to be looked up from the Cognito side. Make sure to also add those attributes to your user pool.

// grafbase/resolvers/stripe/customer/byEmail.ts
import type { User } from 'client';
import Stripe from 'stripe';

export default async function Resolver(user: User) {
  try {
    const { email } = user;

    if (!email) return null;
    const stripe = new Stripe(
      process.env.STRIPE_SECRET_KEY_LIVE ?? process.env.STRIPE_SECRET_KEY ?? '',
      {
        apiVersion: '2022-11-15',
      },
    );

    const [existingStripeUser = null] = (
      await stripe.customers.list({
        email,
        limit: 1,
      })
    ).data;

    if (!existingStripeUser) {
      return null;
    }

    const rawSubscriptions = (
      await stripe.subscriptions.list({
        customer: existingStripeUser.id,
        status: 'all',
      })
    ).data;

    const subscriptions = await Promise.all(
      rawSubscriptions.map(async (sub) => {
        const items = (
          await stripe.subscriptionItems.list({
            subscription: sub.id,
            expand: ['data.price.product'],
          })
        ).data;
        return { ...sub, items: { data: items } };
      }) ?? [],
    );

    return {
      ...existingStripeUser,
      subscriptions: {
        data: subscriptions,
      },
    };
  } catch (err) {
    console.error(err);
  }
}

The Stripe resolver works much the same as the Cognito one. We use the Stripe node library to look up customer information via the user's email. We're doing some data manipulation to get all subscriptions including canceled ones and return the full product price object. This setup will make it easier to query for the user subscription which lets us know which pricing plan they're on.

By default, we will start them on a free trial to a base product so one final step will be to create a product and price in the Stripe dashboard that we can subscribe new users to. Once that's complete you can set the name of that product as the variable STRIPE_TRIAL_PRODUCT_NAME in the auth .env file.

The sign-in event defined in the auth package will attempt to automatically create a stripe customer for the user if they don't have one yet and subscribe them to our free trial product.

// packages/auth/src/index.ts
// ...
signIn: async ({ account, user }) => {
    let stripeId = '';
    const stripe = constructStripe();

    const { email = undefined, name = undefined } = user;

    if (!email) {
      return;
    }

    const { user: userRecord } = await graphQLClient({
      'x-api-key': process.env.API_KEY as string,
    }).request<UserByEmailQuery, UserByEmailQueryVariables>(
      gql`
        query GetUserByEmail($email: Email!) {
          user(by: { email: $email }) {
            id
            customer {
              id
            }
          }
        }
      `,
      {
        email,
      },
    );

    if (!userRecord?.customer) {
      const cognitoUser = isCognitoUser(user);
      const sub = cognitoUser ? user.id : account?.providerAccountId ?? '';

      const newStripeCustomer = await stripe.customers.create({
        name: name ?? '',
        email,
        metadata: {
          sub,
          userId: userRecord?.id ?? '',
        },
      });
      stripeId = newStripeCustomer.id;

      const [trialProduct] = (
        await stripe.products.search({
          query: `name~'${process.env.STRIPE_TRIAL_PRODUCT_NAME ?? ''}'`,
        })
      ).data;
      const price =
        typeof trialProduct.default_price === 'string'
          ? trialProduct.default_price
          : trialProduct.default_price?.unit_amount_decimal ?? '0';

      await stripe.subscriptions.create({
        customer: newStripeCustomer.id,
        items: [{ price, quantity: 1 }],
        trial_period_days: 7,
      });
    } else {
      stripeId = userRecord.customer.id;
    }

    if (isCognitoUser(user) && userRecord) {
      await Auth.updateUserAttributes(user, {
        'custom:userId': userRecord.id,
        'custom:stripeId': stripeId,
      });
    }
  },
// ...

Refreshing the JWT

With Cognito password and Google oath2 working there's one more step we should cover. At the time of writing this article, Auth.js doesn't handle token refresh out of the box, there is some good documentation on a DIY setup here. I've taken the liberty of trying to implement something like this for Cognito and Google users.

// packages/auth/src/index.ts
// ...
 async jwt({ account, user, token, trigger }) {
    const currentTimestampInSeconds = Math.floor(Date.now() / 1000);
    const defaultExpiration = currentTimestampInSeconds + 3600;
    let currentUser = user as unknown;

    if (trigger === 'update' || trigger === undefined) {
      const existingToken = token as any;

      if (currentTimestampInSeconds < existingToken.accessTokenExpires) {
        if (currentUser && isCognitoUser(currentUser)) {
          // Refresh the token for Cognito users.
          Auth.currentAuthenticatedUser();
        }

        return token;
      }
    }

    if (currentUser) {
      if (isCognitoUser(currentUser) && account?.type === 'credentials') {
        const session = currentUser.getSignInUserSession();

        return {
          ...token,
          sub: currentUser.getUsername(),
          email: currentUser.email,
          accessToken: session?.getAccessToken().getJwtToken(),
          accessTokenExpires:
            session?.getAccessToken().getExpiration() ?? defaultExpiration,
          refreshToken: session?.getRefreshToken().getToken(),
        };
      } else if (account && currentUser) {
        const expiration = account.expires_at ?? defaultExpiration;

        return {
          ...token,
          accessToken: account.access_token,
          accessTokenExpires: Math.floor(expiration),
          refreshToken: account.refresh_token,
        };
      }
    }

    return refreshAccessToken(token);
  },
// ...

In the case of the trigger from Auth.js being "update" or undefined, we will check if the user is a Cognito user and if they are we call Auth.currentAuthenticatedUser() which should automatically refresh our access token. If the token is still valid we just return it. If there is a user and we're signing in or signing up then we should get a new token and when none of these conditions are met we try to refresh the token using the following method.

// packages/auth/src/utils/refreshAccessToken.ts
import { JWT } from 'next-auth/jwt';

export async function refreshAccessToken(token: JWT) {
  try {
    const url =
      'https://oauth2.googleapis.com/token?' +
      new URLSearchParams({
        client_id: process.env.GOOGLE_CLIENT_ID as string,
        client_secret: process.env.GOOGLE_CLIENT_SECRET as string,
        grant_type: 'refresh_token',
        refresh_token: token.refreshToken as string,
      });

    const response = await fetch(url, {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      method: 'POST',
    });

    const refreshedTokens = await response.json();

    if (!response.ok) {
      throw refreshedTokens;
    }

    return {
      ...token,
      accessToken: refreshedTokens.access_token,
      accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
      refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, // Fall back to old refresh token
    };
  } catch (error) {
    console.log(error);

    return {
      ...token,
      error: 'RefreshAccessTokenError',
    };
  }
}

Conclusion

There's plenty of code in the SaaS starter that I didn't go over such as the ability to generate new tailwind themes and run Storybook. You can look in the readme for all the various features that are included. Due to some missing functionality between Auth.js and Grafbase, there are a few considerations/limitations.

Database integration: Grafbase is being used to augment our authentication but is not fully integrated with Auth.js to do there will need to be a database adapter. There is currently a PR to add an adapter for Grafbase so if this is something you're interested in please upvote it.

Owner-based auth: The current schema uses private auth rules but would be better suited to owner based auth. With the current setup, we cannot get the user authorization token and sub before creating the user/identity. Because of this, we cannot use owner based auth rules to prevent users from looking up other accounts. This is a problem that could potentially be fixed by a database adapter.