JWT verification against Amazon Cognito as an IdP Service using the Quarkus Framework.
Continuing with my series of the Timer Service using Java and Quarkus on AWS, the next step is to verify the JWT against Cognito using a Java Web Filter. Remember that I discussed the JWT Authorizer in the API Gateway in a previous post. But what happens if we also need a 2nd fence JWT validation from our Timer Service? This is a security best practice because we are not letting the user’s token validate in a single point of our solution architecture (API). This also could bring a security breach because some internal malicious software in our data center can directly access our Timer Service and make REST operations without any problem. So, let’s start adding a Web Filter to our Timer Service that interacts with AWS Cognito to validate the JWT from every user request.
To complete this guide, you’ll need:
- An AWS account.
- Git.
- Amplify CLI.
- AWS Copilot CLI.
- GraalVM 22.1.0 with OpenJDK 17. You can use the SDKMAN tool.
- Apache Maven 3.8 or superior.
- Docker and Docker Compose.
- IntelliJ or Eclipse IDE.
NOTE: I’ll use the Timer Service project for this tutorial and the Docker-Compose tool to deploy the local development environment. Based on my previous article, I will use the last changes I made in that project.
IMPORTANT: As I mentioned in my previous article about using a Docker container image for DynamoDB in our local environments, I will publish a public Docker image of the Timer Service, which I used alongside the present article. So you’re free to deploy and use that image directly for your local environment testing purposes.
Local Deployment
First, init and deploy the Amplify project using your AWS account and add the Auth module for Cognito integration:
# amplify init
# amplify add auth
# amplify push
Then, in the Java Timer Service project, update the Java project version to 1.1.0 in the Maven POM file and the version of the Timer Service image to 1.0.0 in the Docker Compose file.
Also, add the Quarkus OIDC Maven dependency:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
Then, add the OIDC configuration in the “application.properties” file with the value of your Cognito IdP URL:
quarkus.oidc.auth-server-url=https://cognito-idp.<aws-region>.amazonaws.com/<your-cognito-user-pool-id>
NOTE: You can override this value using an environment variable called “QUARKUS_OIDC_AUTH_SERVER_URL,” and that’s our case because we are using “docker-compose” for the local environment, and in AWS, we are using the ECS service.
You can use the Postman tool to obtain the global configuration of your Cognito IdP, adding the standard path “.well-known/openid-configuration” as follows:
These values are also obtained and configured automatically by the Quarkus OIDC Maven dependency and the previous configuration property.
In the “TaskResource.java,” we need to add the “@Authenticated” annotation that informs Quarkus to validate the user session JWT before accessing the resources defined in this class:
@Path("/tasks")
@Authenticated
public class TaskResource {
...
}
Now build and deploy the Timer Service locally using Docker Compose in the project’s root directory:
# docker-compose -f utils/docker/docker-compose.yml build
# docker-compose -f utils/docker/docker-compose.yml up
NOTE: If you have an AMD microprocessor platform, consider creating the native Linux executable with Maven and then the Docker image. Finally, use the docker-compose command to deploy your local cluster and not to build the Timer Service image. This accelerates your development process when you use Quarkus with native container images.
When your local compose cluster is up, try to access the Timer Service through the Postman tool:
You will see an HTTP 401 error in the response. So, let’s try to deploy the Ionic app to see what happens:
# ionic build
# ionic serve
After you login into the app, you must access the main page with the tasks you created at the moment:
You also must see in the console of the browser a log message with the values of the HTTP headers:
Only for test purposes, I created a REST method to obtain the Security Identity of the caller as follows:
Copy the “Authorization” and “Accept” values from your console log of the browser and paste them into the Postman tool to send the request:
The username value in the response matches the username of your user created in the signup process of the app. Go to your Cognito user’s pool in your AWS account and corroborate this value:
Now, let’s deploy our changes to AWS and validate that all works fine on the cloud.
AWS Deployment
In this section, we must use the shell scripts of the project to deploy the required services to AWS. I wrote an article about the Copilot CLI in more detail so that you can read that article for more information.
I’ve created a shell script with a menu we can use as a step follower to deploy all the application resources into AWS. If you executed the commands in the previous section, you could jump to step #2. I’ll be simulating that I haven’t any AWS resources deployed yet, so I’ll deploy the project from scratch. So, let’s execute the following command in the project’s root directory:
# ./run-scripts.sh
The following menu will appear:
We must follow each step that guides us to enter the required parameters and then deploy every needed resource to AWS. So, let’s begin with steps 1 to 4, and then if you wish to populate some faker data into DynamoDB, use step #5 of the main menu.
NOTE: Every step asks you for the AWS profile and region to deploy the required infra on AWS. If you don’t want to answer every time the same thing, please export the environment variables AWS_PROFILE and AWS_REGION. I usually use different AWS accounts for other purposes, so it’s a best practice to specify the account ID used for your specific deployments.
Create Front-End (AMPLIFY):
The code base for the front-end app is the same as I described in my previous tutorial. The only thing we need to do here is to configure the “Hosting Environment” on the Amplify console, which is still a manual task. The other changes are for building configuration files, which is not difficult. So, let’s start initializing the Amplify project in your git repository.
NOTE: The following commands are for your reference. The shell script that I created before runs these commands for you:
# amplify init
# amplify add auth
IMPORTANT: In the previous command, when the Amplify “init” command asks you for the “Build Command,” please write “npm run-script build-dev.” Later in this section, I’ll explain the motivation for this change.
I created a new file called “environment.dev.ts” in the “src/environments” directory. This file contains the URL of the Timer Service API Gateway that interacts internally with the ECS cluster. But at the moment, we don’t have this URL because we haven’t had the API created yet.
In the last section, we simulate an API gateway using Nginx, and we use the “environment.ts” file to define the URL of our API Gateway, which is localhost in this case. But now, our Amplify application needs to know the URL of the API Gateway on AWS. So first, we must add the following content in the “angular.json” file:
Furthermore, in the “package.json” file, we need to add the following line in the “scripts” section:
"build-dev": "ng run app:build:dev"
With these changes, we are indicating to Angular that it must build our project using the new “dev” configuration setting, which internally uses the content of the “environment.dev.ts” file at build time to generate the “www” directory ready for publishing.
Then, go to your Amplify Console, at the “Hosting environments” sections, select your Git repository provider, and click next:
In the next step, choose the corresponding Git repository. Check the “Monorepo” option to indicate to Amplify the path of the Ionic project that is located at the “frontend/angular-timer-service-ionic” directory:
Next, select the “dev” environment we created using the amplify-cli. Then, select the Amplify role used to deploy the needed resources on AWS. If you don’t have anyone yet, Amplify will help you to create one.
NOTE: If you see that Amplify is not using our Angular build setting as configured before, you are ignoring the Amplify directory (.amplify) in your Git repository. I’m doing this here because this directory contains sensible data about my AWS account. If you use a private Git repository, this can be unnecessary. But, if you don’t have the “.amplify” directory in your Git repository, Amplify tries to configure one for us:
Later, we can change this setting to force Amplify to use our “build-dev” build configuration.
In the last step, review that the information you entered is correct, and click the “Save and deploy” button. Amplify initiates a CI/CD pipeline before publishing our Ionic/Angular application to the Internet:
When this process is finalized, you must see all the pipeline’s steps in green, indicating that the CI/CD process was successfully executed and your code is published on the Internet.
Finally, click on the generated URL by Amplify to access the app in a new browser tab:
Don’t access the app yet because we must create the API Gateway first. If you try to access it, you’ll get an error in your browser console:
Our app still uses the Nginx endpoint to access the Timer Service. So, in the following sections, we can deploy the API gateway and our ECS cluster.
2. Create Backend (COPILOT):
The Copilot configuration files for the Timer Service are now in the “/utils/aws” directory for your reference.
NOTE: If you have a Mac M1 chip, when you install the Copilot CLI, use the following signature before verifying it against the Copilot command:
# curl -Lo copilot.asc https://github.com/aws/copilot-cli/releases/latest/download/copilot-darwin-arm64.asc
IMPORTANT: In my previous tutorial, when I talked about Copilot, I created the ECS cluster from scratch using the copilot-cli. But at some point, some resources were not deleted successfully from my AWS account. So when I tried to deploy this new project version, I faced some errors. For example, I was getting the following error when I wanted to deploy the “dev” environment after the “copilot init” command:
execute svc deploy: get available features of the dev environment stack: describe stack timerservice-dev: describe stack timerservice-dev: AccessDenied: User: arn:aws:iam::1234567890:user/aosolorzano is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::1234567890:role/timerservice-dev-EnvManagerRole
status code: 403, request id: 20c0352a-6be5-4277-b581-6cd2
The tutorial uses the same “copilot init” command to deploy the existing cluster definition (manifest.yml) on AWS. When I executed that command, I noticed that on my AWS CloudFormation service, the “XXXX-test-EnvManagerRole” role was created by Copilot. So, I tried to follow the tutorial again on the official site of Copilot to see if I was missing something. But returning to my Timer Service application, Copilot didn’t create that role for my application, which must be timerservice-dev-EnvManagerRole.
So, the workaround here is to manually create the “timerservice-dev-EnvManagerRole” role. Assign it a “root” inline policy and a Trust Relationship. I added these 2 files as references in the project’s utils directory for your reference. Then, I executed the “copilot app delete” to remove all pending resources on AWS, and finally, I can continue creating our Timer Service ECS cluster.
As in the previous section, the following commands are for your reference. The shell script that I created runs these commands for you:
# copilot init \
--app timerservice \
--name tasks \
--type 'Backend Service'
When the command asks if you want to deploy the “test” environment on AWS, answer no. Then, in the second command, you can create the required “dev” environment on AWS:
# copilot env init \
--app timerservice \
--name dev \
--profile <your-aws-profile> \
--region <your-aws-region> \
--default-config
As the command output suggests, we can create the Copilot environment on AWS to prepare the infra resources before deploying the Timer Service:
# copilot env deploy \
--app timerservice \
--name dev
These are the resources used by the Timer Service environment on AWS, so Copilot created them. Finally, we can deploy the Timer Service application on the ECS service:
# copilot deploy \
--app timerservice \
--name tasks \
--env dev
You can validate the created resources on the AWS Console, starting with the ECS service. Go to your AWS console and try to see the details of the Timer Service cluster:
Click the “Task ID” link to navigate the container details. You must see, for example, the new “Quarkus OIDC” parameter in the “Environment Variables” section, among other variables used by our Timer Service:
At the end of the web page, you can also find a link to see the application logs on the CloudFormation:
When you click on the link, you must see the logs of our Timer Service in CloudFormation service indicating the Quarkus application was successfully started:
Copilot’s other crucial new configuration is the AWS security ingress rule, configured in the “dev” environment “manifest.yml” file. You can check the result of this configuration in the “Security Groups” section of the VPC service:
The Copilot configures other essential services you can check out in your peace, but these are the most important ones for this article.
3. AWS API Gateway
Here are no essential changes. I’m still using a CloudFormation template to create the API Gateway with the Authorizer module enabled to validate the user’s JWT against Cognito. But before completing it, we need the URL of the deployed Amplify app because of the CORS configuration inside the API Gateway. So, in step 1, when we created the Amplify app, the generated URL was:
https://master.d2ot7gow6m5l8q.amplifyapp.com
This information is found in the “Domain management” section in the Amplify console. Also, notice that this URL is formed with the App ID generated for our Timer Service:
So, using our shell script, enter the requested values to create the API Gateway on AWS:
The other information like “Cognito User Pool ID” and “Cognito App Client ID Web” you can find in the CloudFormation console in the stack generated by Amplify when you deploy the Authentication module. When you are inside the stack, go to the “Outputs” tab:
You can also navigate to the main page of the CloudFormation to see the status of the creation of the API Gateway. Its name is “timerservice-apigateway” and should be in “CREATE_COMPLETE” status:
So far, we have created our API Gateway configured with the “Authorizer” module to validate the user’s session against Cognito. If validation passes, the API gateway sends the HTTP request to the internal ECS cluster where our Timer Service backend is deployed.
4. Update Ionic/Angular API Client
Now is the time to update the Ionic/Angular app to use the newly created API Gateway endpoint. So, execute step #4 of our shell script to do this task for you:
Then, you can push the script’s changes to your Git repository. After that, Amplify runs the CI/CD pipeline and publishes these changes to the Web.
# git push
When the CI/CD process finishes without any error, we can now try to access our Ionic/Angular Timer Service app with no problems:
If you executed the steps detailed in the “Local deployment” section, your user must still exist. Otherwise, you must create a new one. So, use your user credentials to access the application:
As you can see in the browser’s “Console,” no error indicates that the app is still using the internal proxy web server with Nginx. Now, we’re using the API Gateway endpoint of AWS. Notice that there are no created tasks yet. So, let’s create a new task, but before, run the following command in your terminal window to see the ECS logs in real-time:
# copilot svc logs --app timerservice --name tasks --env dev --since 1h --follow
NOTE: If many AWS profiles are configured locally, export the environment variable “AWS_PROFILE.” This is because there is no parameter in the logs command to specify the region of the logs we’re trying to access.
Try to reload the main page of the Timer Service App to search again for the actual tasks. You must see something like the following in your terminal window:
Well, create a new task using the Timer Service App that must be executed after 2 or a few more minutes after your actual time:
The first group of logs that you must see is the creation task logs:
And after 2 minutes (in my case), you must see the logs for the trigger of the Timer Service task:
So now, you verify that the Timer Service App is working on AWS as expected.
5. Create DynamoDB Faker Data (Optional).
Use this step to populate the Timer Service App with faker data for visual testing purposes.
Then, open your dynamoDB console and validate the data created before:
Finally, go to the Timer Service App on AWS to see the created data:
You have generated the required data and can see it in the app. Remember that this data doesn’t cause any Timer Service task. The Java utility package only persists the faker data directly into DynamoDB. This is for visualization purposes only.
Final Opinions
So far, we are using Amplify to create fullstack applications for AWS, but more oriented in the front-end. Nevertheless, Amplify is more specialized in creating ECS clusters than Copilot. I read articles on the Internet that use Amplify to deploy ECS clusters on AWS, but it is more of a workaround and not a natively characteristic of Amplify. On the other hand, Copilot is focused on creating ECS clusters, services, and tasks on AWS. In the future, I would like to use Amazon CDK as a single entry point to make all the Timer Service stacks on AWS, so I will wait for a substantial maturity of this technology, and of course, I will be writing about this for sure.
I hope this article interests you, and I will see you in my next post!!
Thanks for your reading time.