Skip to content

Backend ‐ Authentication and Authorization

drewjhart edited this page Jun 4, 2024 · 4 revisions

Overview

Guiding Principles: Authentication/authorization has been set up per the following priorities:

  1. Separation of authentication and authorization - Authentication has been set up so that it occurs at the moment the user loads the app. There should be no interaction with the app that doesn't involve some form of auth role (even if that is just a guest). On the other hand, authorization is implemented as close the specific API request as possible, right in the graphql request itself. This is done to ensure that authorization is provided as explicitly as possible on a request-by-request basis.
  2. Leaning on default Amplify configurations and cli - This setup is done with none use of the Amplify console. Backend updates have been performed so that everything is centralized within our networking codebase and can be configured via amplify cli. This means that there should be a single source of truth for configuration. Configuring auth here is done via:
    • amplify update auth
    • amplify update api
    • graphql.schema

Authentication:

Authentication is provided via IAM and Cognito User Pools, depending on the type of user. Here is a breakdown of the user types currently supported:

  1. Unauthenticated Teachers - These are teachers that haven't logged in but simply want to play pre-existing games. When amplify add auth is added, an unauthorized IAM role is created automatically and we use this to provide limited read access to GameTemplates,QuestionTemplates and GameQuestions.

  2. Authenticated Teachers - These are teachers that have logged in via either a username/password or Google OAuth. These user are authenticated via a Cognito User Pool.

  3. Students - Students are given the same limited access to GameSession objects that Unauthenticated teachers have. Currently, this is done via the same IAM role but will ultimately need to be transitioned to a User Pool when we begin to integrate student accounts.

AuthAPIClient

Previously, all of our authentication was handled via an APIKEY. We then made use of authorization functions from aws-amplify directly in each of the clients. We have rewritten our handling of these functions so that they are centralized in a single APIClient called AuthAPIClient. The AuthAPIClient provides configuration and functionality for auth process throughout the App. This is initialized along with the other APIClients via the custom hook useApiClients to await the creation of the AuthAPIClient and then pass it down to the remaining APIClients. This is done via APIClients.create.

AuthAPIClient contains the following functions that can be used via apiClients.auth.<FUNCTION>:

  • configAmplify - initializes Auth settings and general AWS setup. Sets tokens to be stored in cookies instead of localStorage for signed in users (so they can be carried from central to host when a signed in teacher begins a new Game Session)
  • authEvents/authListener - used to detected changes to auth (user signing in) to be able to pass to components for UI changes. This is done via Hub
  • awsSignUp - sign up new user
  • awsConfirmSignUp - confirm new user
  • awsSignIn - sign in user manually
  • awsSignInFederated - sign in for Federated User (Google OAuth2)
  • awsSignOut - sign out user
  • verifyAuth - verify signed in user

However, before we are able to use these functions, we need to deploy and configure the respective AWS services. The next section goes step-by-step through that process.

Auth Setup Guide

Before getting into the steps required to set up the Auth, we can note a few things:

  1. We want to provide different access to resources for users that have signed in and for users that have not signed in.
  2. We want to enable users to use Google federated sign in to provide authentication
  3. Federated Sign-In via Google automatically uses User Pools as its means of authentication. Therefore, we can use User Pools for both Google OAuth2 and manually signed in users.
  4. IAM automatically generates an unauth role. We can use this role to provide a different (more restricted) level of access to resources.

In summary, if we can set up both User Pools and IAM authentication, we can provide the differing levels of access we need for the different types of users that will access our account.

The remainder of this document outlines how to set up these authentication and authorization steps throughout our apps. This document has been organized into three parts:

  1. Authentication
  2. Authorization

Authentication:

  • We have upgraded amplify from v5 to v6. This has involved some minor syntax rewrites that were incorporated in this PR: [Networking] - Upgrade Amplify v5 to v6 #1059
  • To begin, we run amplify add auth and follow this guide to set up auth with both user pools and IAM. At this point, we can also configure our Google OAuth settings. This is all done following this article: Amplify Docs - React - Add social provider sign-in
  • We also need to configure our AppSynch API to grant access to these types of authentication. To do this, we run amplify api update and select Authorization configuration. We then set IAM as our default (as most users will be unauth initially) and then set User Pools as a secondary (so that signed in users can access the API as well).
  • We authenticate via the unAuth role on an app-by-app basis via the AuthAPIClient in the networking layer. To do this, we use a custom Hook via const { apiClients, loading } = useAPIClients(Environment.Developing); in the respective App.tsx file. This will automatically provide unAuth authentication via IAM during initialization.
  • AuthAPIClient provides all necessary auth related functions (including both user and Google OAuth User Pool Sign In) and also includes an auth event listener via AWS Hub to respond to User Pool sign in.
  • One final point about AuthAPIClient: a signed in teacher will be necessarily passing their credentials across from central to host. To accomplish this, we can't use the localStorage store for credential data that Amplify uses for default. If we store the data in cookies, however, it will persist between the two apps. Therefore, when we do: cognitoUserPoolsTokenProvider.setKeyValueStorage(new CookieStorage()); in the configAmplify function to ensure this is possible.

At this point, we have set up authorization via IAM and User Pool and configured our apps via the AuthAPIClient to provide the proper credentials on startup and during gameplay. We now need to consider Authorization.

Authorization:

  • Authorization involves the granting of privileges based on the differing roles above. This will be done primarily via the graphql.schema file.
  • As was mentioned in the Overview document, we will use the @auth directive to establish the different authorization patterns on a field-by-field basis. Again, these are:

GameTemplates/QuestionTemplates:

@auth(
  rules: [
    { allow: public, operations: [read], provider: iam  },
    { allow: private, operations: [read, create, update, delete], provider: userPools  }
  ]
)

GameSession etc:

@auth(
  rules: [
    { allow: public, operations: [read, create, update], provider: iam  },
    { allow: private, operations: [read, create, update, delete], provider: userPools }
  ]
)

In addition, any custom defined mutations, queries and subscriptions also need the @auth directive, but operations are not specified per Amplify docs. This looks like:

type Mutation {
  createGameSessionFromTemplate(input: CreateGameSessionFromTemplateInput!): String 
  @function(name: "createGame-${env}") 
  @auth(
    rules: [
      { allow: public, provider: iam  },
      { allow: private, provider: userPools }
    ]
  )
}
type Subscription {
  onGameSessionUpdatedById(id: ID!): GameSession
    @aws_subscribe(mutations: ["updateGameSession"])
    @auth(
      rules: [
        { allow: public, provider: iam  },
        { allow: private, provider: userPools }
      ]
    )
  onTeamMemberUpdateByTeamId(teamTeamMembersId: ID!): TeamMember
    @aws_subscribe(mutations: ["updateTeamMember"])
    @auth(
      rules: [
        { allow: public, provider: iam  },
        { allow: private, provider: userPools }
      ]
    )
  onTeamCreateByGameSessionId(gameSessionTeamsId: ID!): Team
    @aws_subscribe(mutations: ["createTeam"])
    @auth(
      rules: [
        { allow: public, provider: iam  },
        { allow: private, provider: userPools }
      ]
    )
  onTeamDeleteByGameSessionId(gameSessionTeamsId: ID!): Team
    @aws_subscribe(mutations: ["deleteTeam"]) 
    @auth(
      rules: [
        { allow: public, provider: iam  },
        { allow: private, provider: userPools }
      ]
    )
}
  • Additionally, our Lambda function for createGame also needs to be configured to be granted access to our API resources. We do this by running amplify function update and selecting resources. We can then select the API and then select Mutations and Queries as the Lambda function needs both to operate.
  • This will ensure that an Execution Role is provided to the Lambda function (based on IAM) that wil provide authorization during compute. However, I've found we need to explicitly tell Amplify about this role. We do this by creating amplify/backend/function/<FUNCTION_NAME>/custom-resources.json and populating it with:
{
  "adminRoleNames": [
     "<ExecuationRoleName>-${env}"
  ]
}

This is buried in the amplify docs here: Customize authorization rules

*Finally, if we run into authorization issues (we start seeing 'Unauthorized' errors in the console), we can verify that the proper auth settings are set in our resolves. This seems like an Amplify issue (see Update(Mutation) returns Unauthorized even though IAM AuthPolicy is specified #9502) but we just need to ensure that the Resolver provides something like this:

#if( $util.authType() == "IAM Authorization" )
  #if( !$isAuthorized )
    #if( $ctx.identity.userArn == $ctx.stash.unauthRole )
      #set( $isAuthorized = true )
    #end
  #end
#end
#end
#if( $util.authType() == "User Pool Authorization" )
  #set( $isAuthorized = true )
#end

In the respective amplify/backend/api/<APINAME>/Resolves/<RESOLVERNAME>.auth.1.req.vtl

Following the above steps, should provide authorization and authentication to both signed-in and general users, as well as any Lambda functions, throughout our apps.

Public/Private:

Details:

unauthenticated user is provided via IAM role, automatically assigned via amplify. This is the standard way of handling unauth via amplify authenticated is provided via User Pools authorization is handled via @auth directive on the type. This involves selecting the allowed operations and the provider (IAM, User Pools etc) the @auth directive does not allow for conditional forms of authorization based on field content (ie having a PublicPrivate field) the @auth does provide provider: owner specifically to allow only individuals who created the field to access it. This is perfect for us. It is only available with User Pools. Goal:

Unauth Users - Users can fetch all public games and no private games Auth users - Users can fetch all public games and private games where their user name matches the owner field Possible Approaches:

Control authorization via unauth IAM policy: not possible. While IAM roles have an optional Condition element in their policy statement, this is checking against the request context, not in the retrieved DynamoDB table itself.

Automatically add unauth user to a User Group: while this is possible with our Federated SignIns (AWS automatically creates a Lambda function to synch up the permissions of 3rd party Federated users with manually signed in users), I don't think this is possible with unauthenticated users. We don't have the ability to automatically generated the Lambda function required. Also, because unauth'd users will represent the majority of are users (everyone, even those who haven't signed in yet) I don't want to set up an auth procedure that involves running a lambda function every time someone hits the main page of our website.

Handle the auth on the client side: This could look something like:

try {
    const user = await Auth.getCurrentUser();
    const result = await API.graphql(graphqlOperation(listGameTemplates, {
      filter: {
        owner: {
          eq: user.username
        },
        publicPrivate: {
          eq: 'PRIVATE'
        }
      }
    }));
    return result.data.listGameTemplates.items;
  } catch (error) {
    console.error('Error fetching private game templates:', error);
  }

This contradicts our original security principles (by exposing sensitive data to the client side and not positioning our auth as close to the request as possible). Therefore, I don't think this is an acceptable solution.

Separate tables: instead of trying to handle the fine-grained access controls via the role itself, if we separate the PUBLIC/PRIVATE data into separate DynamoDB tables we can easily control access via @auth directives. Central to this is using the owner auth to only allow for fields matching the user name. This looks like:

type PublicGameTemplate @model
@auth(
  rules: [
    { allow: owner, provider: userPools }
    { allow: private, operations: [read], provider: userPools },
    { allow: public, operations: [read], provider: iam  },
  ]
)

type PrivateGameTemplate @model
@auth(
  rules: [
    { allow: owner, provider: userPools }
  ]
)

Note: the allow: owner is not available for IAM

Pros:

Integrates into our existing authorization strategy as it just extends our @auth use with something more granular Provides single source of truth for all our auth policies in a single document (graphql.schema). We don't have competing authorization rules set somewhere in a policy on the console or anything. Doesn't sidestep anything provided automatically via amplify. We can still autogenerate everything we need via amplify push and we don't need to get into the console. Cons:

Increased number of DynamoDB tables. I don't see this as a big issue as DynamoDB tables are priced on-demand so I anticipate any pricing changes based on this shift to be negligible. Increased fetching complexity. This is the one area I see a drawback to and it is only in circumstances where we have to fetch both Public and Private games/questions simulataneously. If we have to do this, it will be very complicated to then have to sort them or filter them by any metric. This is because we are using the filtering and sorting via the graphql queries directly, so we'd need some kind of custom lambda function to handle this. I wouldn't want to run this on a commonly visited page then. Initial verdict: I think two tables makes the most sense and we can work with Design to limit circumstances where users are fetching both public and private games together.

After discussing this with the team, we have decided to move forward with separate tables (Option 3)