-
Notifications
You must be signed in to change notification settings - Fork 11
Backend ‐ Authentication and Authorization
Guiding Principles: Authentication/authorization has been set up per the following priorities:
- 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.
- 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 viaamplify cli
. This means that there should be a single source of truth for configuration. Configuringauth
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:
-
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 toGameTemplates
,QuestionTemplates
andGameQuestions
. -
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.
-
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.
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 fromcentral
tohost
when a signed in teacher begins a new Game Session) -
authEvents
/authListener
- used to detected changes toauth
(user signing in) to be able to pass to components for UI changes. This is done viaHub
-
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.
Before getting into the steps required to set up the Auth, we can note a few things:
- We want to provide different access to resources for users that have signed in and for users that have not signed in.
- We want to enable users to use Google federated sign in to provide authentication
- 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.
- 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:
- Authentication
- 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 selectAuthorization
configuration. We then setIAM
as our default (as most users will be unauth initially) and then setUser 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 theAuthAPIClient
in thenetworking
layer. To do this, we use a custom Hook viaconst { apiClients, loading } = useAPIClients(Environment.Developing);
in the respectiveApp.tsx
file. This will automatically provideunAuth
authentication viaIAM
during initialization. -
AuthAPIClient
provides all necessaryauth
related functions (including both user and Google OAuth User Pool Sign In) and also includes an auth event listener via AWSHub
to respond toUser Pool
sign in. - One final point about
AuthAPIClient
: a signed in teacher will be necessarily passing their credentials across fromcentral
tohost
. To accomplish this, we can't use thelocalStorage
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 theconfigAmplify
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 runningamplify function update
and selectingresources
. We can then select the API and then selectMutations
andQueries
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.
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)