Amazon Verified Permissions — Part III — Integration with Amazon Cognito

Learn how to utilize Amazon Verified Permissions with Amazon Cognito to authorize calls with JWT tokens.

Amazon Verified Permissions — Part III — Integration with Amazon Cognito

This article is part of a series about Amazon Verified Permissions:

Welcome back to this article series about Amazon Verified Permissions. The last article covered different integration points when making authorization decisions inside your application. This article continues the journey with Authorization APIs. The next, and currently the last, API to cover is the IsAuthorizedWithToken, which presently supports only identity and access tokens issued by Amazon Cognito in JSON Web Token (JWT) format.

What is Amazon Cognito?

Amazon Cognito is an identity platform for web and mobile apps. It’s a user directory, an authentication server, and an authorization service for OAuth 2.0 access tokens and AWS credentials. You can authenticate and authorize users from the built-in user directory that supports users, groups, and customer attributes. Or integrate Cognito with enterprise directory or consumer identity providers like Google and Facebook. It’s a fully managed, serverless service that scales to handle millions of users and provide low-latency responses.

Amazon Cognito User Pool

A user pool is a user directory and OpenID Connect (OIDC) identity provider built-in Amazon Cognito service. It’s for authentication; users can sign in through the user pool or federate through a third-party identity provider (IdP). The other half of the Cognito is identity pools for authorization user access to AWS APIs, but that’s out of the scope of this article. After successful authentication, Amazon Cognito creates a session and returns an identity token, an access token, and a refresh token for the authenticated user:

Both identity and access tokens are supported for authorization with IsAuthorizedWithToken API in Amazon Verified Permissions for tokens provided by Amazon Cognito User Pools. You can use a refresh token to get new tokens or revoke existing tokens. Identity Token

An identity token contains claims about the authenticated user’s identity, such as name, email, and phone number.

Example content of the Cognito identity token:

{
  "sub": "b89463bf-c061-4945-a17b-4a3d9bea33fa",
  "cognito:groups": [
    "User"
  ],
  "email_verified": true,
  "iss": "https://cognito-idp.eu-north-1.amazonaws.com/eu-north-1_her0vmgIe",
  "phone_number_verified": true,
  "cognito:username": "hero-user",
  "origin_jti": "cf0ec9f6-e990-4dce-a864-29131082d927",
  "aud": "5dlnem8jsrdivs7e2724usinkm",
  "event_id": "7b8c5882-e6fd-4706-8fbc-334b6b1581ab",
  "token_use": "id",
  "auth_time": 1710426106,
  "name": "John Smith",
  "phone_number": "+12065551212",
  "exp": 1710429706,
  "iat": 1710426106,
  "jti": "98f25027-0a5e-408e-b27b-b6ed876edc21",
  "email": "hero.user@example.com",
  "custom:user_tier": "partner"
}

The ID token can contain OIDC standard claims defined in OIDC standard claims. The ID token can also include custom attributes you define in your user pool. Amazon Cognito prepends “custom:” to custom attribute names and writes attribute values to the ID token as strings regardless of attribute type.

More detailed information is in the Cognito documentation.

Access Token

The idea of an access token is defined in OAuth specification, while identity tokens are defined in OpenID Connect. By Oauth 2.0 specification, the purpose of the access token is to authorize API operations by using it in the requests to the resource server. Identity tokens should not be used to make requests to the resource server; this is sometimes referred to as an anti-pattern. These principles have been misused; for example, API Gateway and AppSync authorizer for Cognito also support identity tokens. I’m getting sidetracked here, but you can use both also with Amazon Verified Permissions.

In the Cognito access token, the scope claim is a special one. The scope is a logical grouping of claims the access token can access. The format differs depending on the identity server product. Still, it can often consist of two parts, with the Cedar terminology, action, and the resource, delimited with a special character like order_read or read:orders. Check out this great article, “On The Nature of OAuth2’s Scopes”, on the Okta website to learn more about the concept.

Example content of the Cognito access token:

{
  "sub": "b89463bf-c061-4945-a17b-4a3d9bea33fa",
  "cognito:groups": [
    "User"
  ],
  "iss": "https://cognito-idp.eu-north-1.amazonaws.com/eu-north-1_5hlzvmgIe",
  "client_id": "5dlnem8jsrdivs7e2724usinkm",
  "origin_jti": "cf0ec9f6-e990-4dce-a864-29131082d927",
  "event_id": "7b8c5882-e6fd-4706-8fbc-334b6b1581ab",
  "token_use": "access",
  "scope": "aws.cognito.signin.user.admin",
  "auth_time": 1710426106,
  "exp": 1710429706,
  "iat": 1710426106,
  "jti": "744da750-af1f-4b62-b360-41abaf5936d3",
  "username": "hero-user"
}

The aws.cognito.signin.user.admin scope authorizes the Amazon Cognito user pools API and is the only scope provided for user pool users by default. It permits the query and update of all information about a user pool user with, for example, the GetUser and UpdateUserAttributes API operations. We can also notice that Cognito uses a dotted approach for claims.

You can create your resource servers to define custom scopes, but that is out of the scope of this article. For more information, check the Cognito documentation. Remember that there are limitations when using Secure Remote Password (USER_SRP_AUTH authentication flow in Cognito), and custom scopes work only with the hosted UI. You can get around this with the pre-token generation Lambda trigger, which will be explained more in the next section. I think this is not that clearly stated in the documentation, but the following note tries to warn about this:

Customizing the Tokens with Lambda Functions

Amazon Cognito also provides a programmatical way of customizing the token contents. You can execute your custom code and programmatically return additional custom claims using the pre-token generation Lambda trigger:

Enriching the token with identity-related data from the database is a good practice. I don’t recommend using Amazon Cognito as a user database for often queried or mutated attributes because it has a relatively low request rate quota limit. GetUser API has a limit of 120 requests per second by default, but it is adjustable. You can purchase an increase to adjustable Amazon Cognito quotas. Check out tips for optimizing request rates here.

The pre-token Lambda trigger was previously limited to identity tokens, but on December 18th, 2023, Amazon announced the support for access tokens. Note that this requires enabling the Cognito advanced security features, which impacts the price.

Policies and Cognito Tokens

Amazon Verified Permissions automatically validates the token signature and expiration date and extracts the token claims. However, you still need to model those as attributes, in the case of identity token, or as a context, in the case of access token, in your schema.

Note: If you delete an Amazon Cognito user pool or user, tokens from that deleted pool or that deleted user continue to be usable until they expire.

Identity Token

From the identity token example above, we can map custom:user_tier claims in the following way to make it optional:

{
  "HeroApp": {
    "entityTypes": {
      "User": {
        "memberOfTypes": [
          "Group"
        ],
        "shape": {
          "type": "Record",
          "attributes": {
            "custom": {
              "type": "Record",
              "attributes": {
                "user_tier": {
                  "type": "String"
                }
              }
            }
          }
        }
      },
     ...

When writing Cedar policies using Amazon Cognito user pool claims that contain a colon character, you must reference them with a period (.) instead. For example, you must change cognito:username to cognito.username. Unfortunately, cognito:groups claim is not currently supported. We can now implement an attribute-based partner policy by using the custom:user_tier claim:

permit(
  principal,
  action in [HeroApp::Action::"AddHero"],
  resource
) when {
  principal.custom.user_tier == "partner"
};

Note that this would require all our supported principals to have a custom.user_tier attribute, so also HeroApp::Group entity or the policy creation will fail. Amazon Verified Permissions currently supports Cedar v2.4. In Cedar v3, you can use the “is” (entity type test) keyword, so maybe something like this works in the future:

permit(
  principal is HeroApp::User,
  action in [HeroApp::Action::"AddHero"],
  resource
) when {
  principal.custom.user_tier == "partner"
};

Access Token

An access token is mapped to a context object when passed to Verified Permissions, which must also be mapped to the schema:

{
  "HeroApp": {
    "actions": {
      "AddHero": {
        "appliesTo": {
          "context": {
            "type": "ReusedContext"
          },
          "resourceTypes": [
            "Hero"
          ]
        }
      }
    },
    ...
    "commonTypes": {
      "ReusedContext": {
        "attributes": {
          "token": {
            "type": "Record",
            "attributes": {
              "scope": {
                "type": "String"
              },
              "client_id": {
                "type": "String"
              }
            }
          }
        },
        "type": "Record"
      }
    }
  }
}

So we could allow the same AddHero action when the client_id matches and aws.cognito.signin.user.admin is included in the scope claim:

permit(
  principal,
  action in [HeroApp::Action::"AddHero"],
  resource
) when { 
  context.token.client_id == "5dlnem8jsrdivs7e2724usinkm" &&
  context.token.scope like "*aws.cognito.signin.user.admin*"
};

Integration

The service integration is straightforward. All you need to do is to add a new identity source to your policy store. You can do it with a simple form in the AWS Console:

I recommend the Infrastructure as Code approach, where we can again utilize the hashicorp/awscc provider and the awscc_verifiedpermissions_identity_source resource.

resource "awscc_verifiedpermissions_identity_source" "identity_source" {
  policy_store_id = aws_verifiedpermissions_policy_store.store.id
  configuration = {
    cognito_user_pool_configuration = {
      user_pool_arn = aws_cognito_user_pool.this.arn
      client_ids = [aws_cognito_user_pool_client.this.id]
    }
  }
  principal_entity_type = "HeroApp::User"
}

No matter the approach, you must define the Cognito User Pool ID and map the principal. It is not that the identifier will be a combination of the user pool ID and the user sub. Additionally, you can restrict the validation to specific client application identifiers only.

Testing the Policy

You can’t test the authorization with the Test Bench feature of Amazon Verified Permissions. Note that IsAuthorizedWithToken API derives the principal from the identity token. You must not include that principal in the entity parameter, or the operation fails and reports a conflict between the two entity sources. If you provide an access token, you can include the entity as part of the entity parameter to provide additional attributes.

An example with AWS CLI and the first identity token above:

aws verifiedpermissions is-authorized-with-token \
    --policy-store-id <policy_store_id> \
    --identity-token <id_token> \
    --action actionType="HeroApp::Action",actionId="AddHero"

Response:

{
    "decision": "ALLOW",
    "determiningPolicies": [
        {
            "policyId": "KRRbJQyUebgvjjEAAHXkFB"
        }
    ],
    "errors": []
}

Where policyId is the policy identifier of our newly made partner policy.

Conclusion

Unfortunately, some critical parts are still missing, including the Cognito user pool group claim, the batch authorization support, and the testing support with the Test Bench. It would be very nice if the group claim could be mapped to a HeroApp::Group parent principal. I’m waiting for these to eliminate some of the unnecessary heavy lifting. In the following article, we are putting the theory into action. We will create an example API utilizing the new skills we have learned!

Continue reading: Part 4 — Complete Example

Categories:

Want to be the hero of cloud?

Great, we are here to help you become a cloud services hero!

Let's start!
Book a meeting!