Bootstrapping Django App with Cognito: Personal Experience

  • Viewed:

    1780
  • Shared:

    8
Bootstrapping Django App with Cognito: Personal Experience
gleb-pushkov

Gleb Pushkov

Senior Software Developer @ Django Stars

Nowadays, more and more developers integrate their app with Single sign-on (SSO) services. It greatly increases the speed of development, because the basic routine is already implemented, tested, and hosted: sign-in, registration, reset password, 2FA, email and phone verification, welcome-email, and other. It’s especially useful if you have a microservice architecture – once a user is authorized, they can send requests to any of them. Below, I’ll share tips on integration of such service from my experience.

Table of Contents:

  1. Cognito and Django: Bootstrapping an App
  2. What’s Worth to Note when Working with AWS Cognito
  3. Cognito and CloudFormation: Things to Keep in Mind
  4. Summary

There are several solutions offered by the following services: Azure Active Directory B2C, AWS Cognito, Okta, Auth0, and others. They differ by functionality (ensure it meets your app needs, like multilanguage emails), and especially, by price.

AWS Cognito is the cheapest one (but be aware that using lambdas, 2FA, SNS could additionally generate associated costs which might not be originally mentioned). Also, it’s very flexible. You can customize workflow with Lambda Triggers. Namely, during authorization events, your custom AWS Lambda functions could be called wherever you need them to do whatever you want. Or you can create your own Authentication Challenges. If your application stack is hosted on AWS and managed via CloudFormation (or Terraform), it’s also handy to set up and configure Cognito as an additional resource of your IaC (but you should to be aware of some cautions mentioned after the Django integration part).

According to documentation, after successful authentication, Amazon Cognito API returns id_token, access_token and refresh_token. Both id_token and access_token are JSON Web Tokens and could be used to identify a user during API requests to the Django application. The id_token contains personal identity information such as name, email, and phone_number. The access_token doesn’t carry such information, so it’s more secure to use it as JWT encoded via base64, and everybody who gets this token can easily see personal data. Even if you have encrypted https connection, there are ways to steal the data, like SSL Stip (hint: configure HSTS headers!).

On a high-view level the authentication process will look like that:

authentication-process

Cognito and Django: Bootstrapping an App

In this guide, I will cover a case of Django app development with Cognito when we want to have two types of users – back office users (to login and work with django-admin, session authorization) and application users (to interact with api endpoints; such users are registered in Cognito, jwt-authorization).

Step 1. Install packages

I found several libraries that help with Django/AWS Cognito integration, but it’s not hard to build flexible and easy-to-extend configuration on your own using DRF djangorestframework-jwt.

NOTE: The original library is no longer maintained, so we will use a fork of it (drf-jwt). Another alternative library for jwt is currently also looking for a maintainer.

pip install djangorestframework cryptography drf-jwt

Step 2. Create a User Pool in AWS Cognito

Sign-in into your AWS console and proceed to Cognito. Press Manage User Pools (the Identity pool is something different). Create a new user pool and configure attributes.

NOTE: once you set up required attributes, you wouldn’t be able to change them without re-creating a pool and losing all users’ passwords. By the way, you have to migrate your users on your own (more thoughts about that at the end of the article). I recommend having as few required attributes as possible, for example, only email.

Create two app clients. The one for a frontend app: `Enable username-password (non-SRP) flow for app-based authentication (USER_PASSWORD_AUTH)`.

And if you will need to access user pool data from the backend app, add one more client: `Enable sign-in API for server-based authentication (ADMIN_NO_SRP_AUTH)`.

For easy testing of integration you can enable “Hosted UI”. Sign-Up/Sign-In pages are provided by Cognito, so you can easily obtain JWT tokens and use them in Postman to ensure the configuration of the Django side was done in a proper way.

sign-in-form

To do this you need to specify Amazon Cognito domain in “Domain name” section, e.g.:

https://any-fancy-name-you-like.auth.eu-central-1.amazoncognito.com

In “App client settings” you need to enable any of “OAuth Flows” (let’s say Implicit grant) and at least “OAuth Scope” (openid). Also provide a callback URL – http://localhost:8000/admin.

After saving your changes, at the bottom of the same page you will see the “Launch Hosted UI” link. It will lead you to the login form. Create a user in the “Users and groups” tab and use its credentials to log in via Hosted UI. As a result, a browser will be redirected to callback URL which will have necessary tokens:

http://localhost:8000/admin#id_token=eyJraWQiOiJNdm…&access_token=eyJraWQiOiJqenIwdnRVK….&expires_in=3600&token_type=Bearer

Congrats, we obtained id_token and access_token!​

To see what’s inside, go to https://jwt.io/ and put the token into debugger. For access_token payload would be:

 

Step 3. Create custom User model

It’s a good practice to override the default user model once you start your Django app development, otherwise, it will be painful to migrate on a mid-project phase.

NOTE: I would recommend setting up an abstract base model which would be used everywhere. It can be placed in the special app where general and non-related to business logic application code lives,  for example, core.

These are reasons why it’s helpful:

  1. uuid4 as a primary key – identifiers like 91eea07d-0742-4925-9c39-fb6d6e352f2a would be generated instead of regular incremental 12, 13, 14. Nobody will know how many Orders or Users do you have. Also, uuid4 is generated on Python-side, so you can know a PK before insertion. There are many discussions about the size, performance and other disadvantages, one of the good articles I found you may check here. In my experience, uuid4is really neat and it gives additional flexibility. For example, if you don’t know which table to lookup, you can query multiple of them until you get the desired row, since UUID would be unique across the whole database;
  2. created_at and updated_at – useful fields which will be added to all models;
  3. Informative __repr__ – extremely helpful if you’re using Sentry to collect errors and exceptions. In the stack trace you will see <User 75145554-4142-480b-bcfa-36840b315ba1> instead of <object at 0x1028ed080>
  4. Such a layer gives you the ability to override the behavior of all your models in the transparent way like we just did with __repr__, or with base fields.

Now let’s create a custom user at account/models.py:

And in settings.py change a default user model

AUTH_USER_MODEL = 'account.User'

Now we can run migrations.

After that, we move to the final step. Let’s register custom model in account/admin.py:

 

Step 4. Configure REMOTE_USER

In case when external authentication sources are used, additional configuration has to be done. The RemoteUserBackend creates a new User record in the database if it can’t find existing. This behavior can be changed by create_unknown_user, find more info in the docs.

 

Step 5. Configure DRF

settings.py

NOTE: To reduce the number of security issues that could happen due to inattentiveness, I would recommend to override the default permission class. In core/api/permissionsyou can put the following class:

settings.py

 

Step 6. Configure djangorestframework-jwt

On application launch, we need to download public JWKS that will be used to verify JWT.

settings.py

In a real project, settings have to be less hardcoded, something like that

COGNITO_AWS_REGION = env('COGNITO_AWS_REGION', default=None)

To master managing Django settings, I recommend checking this article by my colleague Alexander Ryabtsev. It’s really worth it.

Configuring Django Settings: Best Practices

This article is intended for engineers who use the Django framework. It gives a deep insight into configuring Django project settings, and the pros and cons of different approaches. In the article, you will also find recommendations concerning tools, best practices and architectural solutions, all time-tested and proven by successful projects. Table of contents: Managing Django Settings: Issues Setting Configuration: Different Approaches settings_local.py Separate Settings File for Each Environment Environment...

Managing Django's settings cover

Downloaded RSA keys look like that:

If you check a decoded id_token and access_token, their headers include

{
"kid": "Mvd6BSFCvQ+PbEOQCqOZd3CCSdd/d/mw+65R5uN1+r0=",
"alg": "RS256"
}

That specifies which JWKS should be used to validate a token. The “mapping” logic has to be implemented by our own in cognito_jwt_decode_handler:

core/utils/jwt.py

Usually, JWKS should be periodically rotated by auth service. And you may have a KeyError which indicates that you need to download new JWKS keys. It’s been more than 3 years already, but Cognito still hasn’t implemented this functionality.

A few notes about  get_username_from_payload_handler: sub in jwt payload carries unique UUID of authenticated user in Cognito User Pool. You can use it as pk for your users in a database, or, to be more independent, keep your own-generated UUID4 as pk for users, and store Cognito sub as username (it’s what we’re doing now). But be aware that this solution can add complexity to your implementation, if a client/frontend app works directly with Cognito and “knows” only sub, but not internal UUID4 of a user, which will be used in DB to build relations between models.

 

Step 7. Create test view

account/api/serializers.py

account/api/views.py

urls.py

 

Step 8. Run server and make a request

We will use a Postman to ensure integration code works as it should.

Create a new GET request to http://localhost:8000/api/v1/me and in Headers provide Authorization with value Bearer <access_token>. The token is valid for 1 hour, so you may need to obtain a new one via Hosted UI (Step 3).

Congratulations, everything is done!

(Full project sample may be found at my GitHub).

Debugging

Sometimes debugging is hard and it’s better to dig into the source code and check when and why any of the messages appear. For example {"detail": "Invalid signature."}actually means that there is no such user in the database that indicates that REMOTE_USER was not properly configured, as it has to create a new user, if it fails to find an existing one.

What’s Worth to Note when Working with AWS Cognito

Below, I’ve listed a number of facts that I’ve faced when working with Cognito. They aren’t either pros or cons. These are just peculiarities you should know when starting to develop a Django app authentication feature with Cognito and tips how to solve them.

  • Sing-in and reset password API explicitly indicate that the user with provided email hasn’t registered: {__type: "UserNotFoundException", message: "User does not exist."}. By default, anyone can test a list of emails and identify who’s already registered in your application. Usually, only during registration anybody can identify whether the particular email is registered in the service. And, for example, to make a process harder for bad-intention-guys, a captcha could be added. However, with Cognito this will not work (since anybody can use the developer console in the browser and make requests to Cognito). Using backend app as a proxy for Cognito requests might be the option, but it brings additional effort, and instead of doing life easier, Cognito might make it harder;
  • Things have changed while this article was being prepared: finally, “Amazon Cognito User Pools service now supports case insensitivity for user aliases”. It’s a new feature for new User Pools, but existing ones still have such a problem. I was very surprised when we found out that both usernames and emails were treated as case-sensitive. So “[email protected]” is different from “[email protected],” that is different from “[email protected]”. One option to fix that was the following: on the frontend side, the app made emails lowercase. Another idea was to fix that on the Cognito Pool level with lambda function on the “PreSignUp” trigger, but it didn’t allow me to change the input values. Thus, the only way was to override email/username after a user was created in “PostConfirmation” lambda, which is not much elegant;
  • Depending on the type of application, you can face a problem when you need to display a list of users with name/picture/phone/other-field. In such a case, you need to retrieve users from Cognito each time when data is requested, or implement profiles’ sync logic and store a copy of all Cognito users in your database. It’s common for any SSO service, but it’s related to the next issue you may face;
  • No lambda trigger on attribute update. If a user modifies one’s profile data via Cognito API, there is no callback which indicates that data has been changed. If you store a copy of Cognito data in your database (for convenience), you have to use some workarounds, like: fronted code has to notify your services explicitly when user data in Cognito has been successfully updated. And then you can pull the changes from User Pool;
  • Cognito allows the creation of multiple users with the same email until one of them becomes verified (might happen when the user double-clicks the submit button of the registration form). That’s OK, but you have to cover this case when working with User Pool via code/scripts;
  • No export users functionality. There is an npm package which can do that, or you need to implement this all by yourself;
  • You can’t export users’ password hashes (in the case if you want to change SSO provider or use your own auth services, or even if you want to move users from one User Pool to another). You will have to send an email asking them to set a new password with proper explanation. Afterwards, you’ll have to support a flow when users will start trying to login with their passwords;
  • During signup flow, users always have to enter login & password right after clicking on the confirmation link in the email. There is no way to auto-login the user. It gives a rise to multiple complaints, but it still this hasn’t been fixed by Cognito;
  • Default fields phone_verified and email_verified may indicate “True” or “true”. I have no clues why. Perhaps, it might be a bug on the AWS side;
  • And a few more things have been already covered here.

Cognito and CloudFormation: Things to Keep in Mind

CloudFormation is a great tool that allows you to store and maintain your infrastructure as a code. You can define multiple stacks, for each stack you need to create a special JSON template that defines your resources and configuration. Such files could be managed manually, or they could be generated by Troposphere via Python code (still keep in mind that when coding for growing infrastructure, it’s easy to fall into overengineering and end up with a hardly maintainable codebase. In this case managing flat YAML/JSON files would be a much better choice in long-term perspective). CloudFormation automatically rolls back all changes, if something goes wrong during a stack update what is a really great feature. But the way it manages Cognito is a bit strange, thus, I would like to highlight the most important notes.

  • If you created User Pool manually (as any other type of resources), it’s impossible to add it to CloudFormation stack without re-creation. CloudFormation can only manage resources that were created by CloudFormation. For “stateless” resources, like EC2, it’s not a problem, but for RDS it’s trickier as it will require a database restore from a snapshot not to lose data. However, with Cognito, it’s not possible to migrate easily, as it doesn’t allow to export passwords. Instead, this information will be lost and users will need to set up passwords again. Still, in such situation User Pool can be kept unmanaged, but you can use a reference (Ref) in the template to link it to the rest of your stack;
  • CloudFormation can drop the User Pool and all data you have inside. I found it out on my own experience in summer 2019 at our development environment (also mentioned in the referenced article above). Still, when I started the work on the article, half a year after the incident, and tried to reproduce the scenario (change the ordering of attributes in a template, or add a new one), it didn’t happen that time. Likely this was fixed, but I’m not sure for 100%. Actually, this was the main driver for me to share experience on working with this stack. Here are some advice you may to use just to be safe:

– ensure you provided DeletionPolicy. Even if something bad happens, the resource will be removed from a stack, but it wouldn’t be physically deleted (still will be accessible from a template using Ref);

– before applying a new template for each stack update, carefully review the change set. Look there for a User Pool resource name and for words like “changeSource”: “DirectModification” on this resource. That might be a mark that something might go wrong, depending on the changes. If AWS really fixed the issue, this point is not valid anymore, but personally I have a Vietnam Syndrome once I see that.

I’m not sure how Terraform or other tools deal with Cognito, but you have to be extreeeeeeemly careful here, otherwise you can accidentally kill the product.

Summary

Picture yourself as a barber and SSO service as a straight razor. It’s a great tool if you know how to deal with it, but otherwise the risk of making something wrong (with really bad consequences) is very high. It’s a tool that you have to pick wisely and configure carefully, put a lot of effort into investigating how it works, and even it ‘s worth to spend time building a proof of concept to get a better feeling of its abilities and restrictions. Once you release an application and get first users, there is no way back: migration to another service could be very painful with import/export of user passwords being a slippy thing. Thus, I’m sure sharing of experience in this topic is very useful. If you have anything to say, highlight some pitfalls, or recommend something around SSO integration, please leave a comment, and one day it will help someone to build a successful story.

tags

you may also like

Insights, case studies, success stories and professional discoveries

Latest articles right in your inbox

Tell about yourself, please

What are you interested in?

By clicking “SUBSCRIBE” you consent to the processing of your data by Django Stars company for marketing purposes, including sending emails. For details, check our Privacy Policy.
Thank you for subscribing to our newsletter!