Adding auth to a Flask App with Azure Active Directory and Oso

Securing application resources usually consists of two security processes: authentication and authorization. Authentication determines the identity of a user, and is often accomplished by some kind of sign-in process. Authorization determines whether or not a user is allowed to access a resource, typically based on their authenticated identity but often based on other factors as well, like having a certain role, being in a group, having specific attributes or meeting other criteria relevant to the use case.

A common authentication pattern involves the application exchanging information with an Identity Provider, or "IdP", to confirm the identity of the user. Well-known IdPs include Facebook, Google, and GitHub.

This post will show how to add authentication and authorization to a simple Flask app, using Azure Active Directory B2C as an IdP, and oso's oso-flask library to authorize requests.

Summary of what we'll cover:

  • Sign in users with Azure AD B2C
  • Use oso to restrict access to signed-in users
  • Add custom user attributes to our user profile
  • Write a policy to control access based on user attributes, like job title or team
  • Access Microsoft user data, like groups and managers, with the Graph API
  • Write an oso policy to implement role-based access control using Microsoft groups

Background

This post uses an example application that's written in Python with Flask. You can find the source code here.

Azure Active Directory ("AD") is Microsoft's cloud-based identity management service. We'll use it to sign in users and store user data. This example uses a newer variant of Active Directory called "B2C", which is designed for business-to-consumer apps to manage customer identities.

oso is an open-source policy engine for authorization that is embedded in your application. We'll use oso to authorize user access to our application's resources. Since our example application uses Flask, we're using the oso-flask integration, which includes middleware for authorizing Flask requests.

The example we'll use in this post is a very simple application that stores and displays documents. Its authorization model is as follows:

  • Users can always view documents that they are the owner of
  • Documents can belong to user groups
  • Public documents can be viewed by anyone
  • Private documents can only be viewed by users that belong to one of the document's groups

Authentication with Azure AD B2C

We used a sample app provided by Microsoft as the starting point for our example. Following Microsoft's tutorial on setting up authentication in their sample application will get you to the same starting point (you may have to complete a few prerequisite steps as well).

By the end of the tutorial, you should have registered the application with your Azure AD B2C tenant. We've registered ours as "python-webapp":

Adding%20auth%20to%20a%20Flask%20App%20with%20Azure%20Active%20Direc%20e4020384c5a348e499250ae95d24e0d1/Untitled.png

You should also have set up several User Flows in the Azure portal for signing in, profile editing, and logging out:

Adding%20auth%20to%20a%20Flask%20App%20with%20Azure%20Active%20Direc%20e4020384c5a348e499250ae95d24e0d1/Untitled%201.png

When you set up the user flows, make sure that you check the following boxes in "User Attributes" and "Application Claims":

  • Email
  • Given name
  • Surname
  • Display Name
  • Job Title
  • Object ID (Application Claims only)

Once you've reached the end of the tutorial, your app should be able to sign users in with an email and password, allow them to edit their profile, and log them out. Each of these user flows should return an id_token JWT to your app's redirect handler that stores the above attributes as claims. If you use Microsoft's provided JWT decoder, https://jwt.ms, to test your user flow, you should see something like this:

Adding%20auth%20to%20a%20Flask%20App%20with%20Azure%20Active%20Direc%20e4020384c5a348e499250ae95d24e0d1/Untitled%202.png

Once you've completed the tutorial, the application can be run with the following command from the root directory:

flask run --host localhost --port 5000

If you'd like to run our sample application, make sure to update the information in app_config.py:

# app_config.py

b2c_tenant = "your-tenant-name"
signupsignin_user_flow = "B2C_1_signupsignin1"
editprofile_user_flow = "B2C_1_profileediting1"
resetpassword_user_flow = "B2C_1_passwordreset1"
authority_template = (
    "https://{tenant}.b2clogin.com/{tenant}.onmicrosoft.com/{user_flow}"
)

CLIENT_ID = (
    "Enter_the_Application_Id_here"  # Application (client) ID of app registration
)

CLIENT_SECRET = (
    "Enter_the_Client_Secret_Here"  # Placeholder - for use ONLY during testing.
)

Creating the User model

Microsoft's sample app stores a raw dictionary of the id_token claims globally in session["user"]. In our app, we created a User class to store the user data, which we'll add to later on:

# user.py

class User:
    def __init__(self, id_token_claims):
        self.id = id_token_claims.get("oid")
        self.display_name = id_token_claims.get("name")
        self.first_name = id_token_claims.get("given_name")
        self.surname = id_token_claims.get("family_name")
        self.emails = id_token_claims.get("emails")
        self.job_title = id_token_claims.get("jobTitle")

In our redirect handler, we construct an instance of the current User from the id_token_claims dictionary, and store it on the session object:

# app.py

@app.route(app_config.REDIRECT_PATH)
def authorized():
    # ...
    user = User(id_token_result.get("id_token_claims"))
    session["user"] = user

We've successfully added authentication to our app. Now let's use oso to authorize our authenticated users' requests.

Adding oso

Since we're building a Flask app, we're going to use the flask-oso package to add oso to our application. flask-oso is an even lighter weight form of the oso package that provides convenient middleware for authorizing Flask requests. You can find a helpful guide to using flask-oso in our docs.

We followed three steps to add oso to the application:

  1. Create a .polar policy file
  2. Initialize the global oso instance by loading the policy and registering relevant application classes
  3. Add calls to flask_oso.FlaskOso.authorize() at authorization enforcement points

Writing a policy

oso policies are written in a declarative policy language called Polar, and are stored in files with the .polar extension. Take a look at Writing Policies for an overview of how to use oso policies.

The policy file in this example is called authorization.polar. We started with a simple rule to allow any logged-in user to get a public document:

# authorization.polar

# allow anyone to get public documents
allow(_actor: User, "GET", doc: Document) if
    not doc.is_private;

This rule works by specializing the _actor on the User class, which means that only actors of type User are allowed to take any action on any resource. Since get_current_user(), which is used to get the default actor, returns None when the current user is not logged in, the rule only applies when the current user is authenticated. The doc argument is specialized on the Document class, which allows us to safely access the is_private field.

Initializing oso

We wrote a function called init_oso(), which we put in a file called oso_auth.py:

# oso_auth.py

from oso import Oso
from flask_oso import FlaskOso

from document import Document
import user
from user import User

def init_oso(app):
    """ set up the `Oso` and `FlaskOso` objects, and add them to the global `app` instance."""
    oso = Oso()
    oso.register_class(Document)
    oso.register_class(User)
    oso.load_file("authorization.polar")

    flask_oso = FlaskOso(app=app, oso=oso)
    flask_oso.set_get_actor(user.get_current_user)

    app.oso = oso
    app.flask_oso = flask_oso

The init_oso() function creates an Oso instance, on which it registers the User class and the Document class that represents the app's document resources (registering Python classes with oso lets us reference them in our oso policies).

The function then uses the Oso instance to create the FlaskOso instance that we'll use to authorize requests. We call the set_get_actor() method to set the default actor to the current user. This default is used when we make calls to flask_oso.authorize() later on. The method we pass in, get_current_user() looks up the user on the session object:

# user.py

from flask import session

def get_current_user():
    return session.get("user")

Authorizing requests

We're now ready to use the oso library to authorize requests. In this example application, the resources we want to secure are documents, represented by the Document class in document.py:

# document.py

@dataclass
class Document:
    id: int
    owner_id: str
    groups: list
    is_private: bool
    content: str

def find_by_id(id):
    return DOCUMENTS.get(id)

DOCUMENTS = {
    1: Document(
        id=1,
        owner_id="5890e32a-c2ac-4aa0-902d-0717017d1bc3",
        groups=["engineering"],
        is_private=True,
        content="This is a private engineering doc.",
    ),
    2: Document(
        id=2,
        owner_id="273dd85f-0728-44c0-8588-c130f39c900b",
        groups=["marketing"],
        is_private=True,
        content="This is a private marketing doc.",
    ),
    3: Document(
        id=3,
        owner_id="273dd85f-0728-44c0-8588-c130f39c900b",
        groups=["admin"],
        is_private=False,
        content="This is a public admin doc.",
    ),
}

For this example we've simply hardcoded the document data, but this data would normally be stored in a database.

document.py has a route for viewing the documents, to which we've added a call to flask_oso.FlaskOso.authorize():

# document.py

@bp.route("/docs/<int:id>", methods=["GET"])
def get_doc(id):
    doc = find_by_id(id)
    current_app.flask_oso.authorize(resource=doc)
    return str(doc)

The authorize() method accepts the same arguments as the is_allowed() method of the oso package (actor, action, and resource), but provides sensible defaults for working with Flask. With our call to set_get_actor(), we set the default actor to the current user. The action defaults to the method of the current request, flask.request.method. We have to provide the resource we want to authorize; in this case, we pass in the Document instance that is being accessed.

With the allow rule we have in the policy so far, this authorize() call will ensure that logged-in users can get public, but not private documents. Users that are not logged in cannot get any documents. If you're following along, you can check this by trying to access localhost:5000/docs/3, a public document, before and after signing in. localhost:5000/docs/1 is private, so you shouldn't be able to access it even after you are signed in.

Attribute-Based Access Control using id_token claims

We have a couple of other rules in our policy to implement the rest of our authorization model:

# authorization.polar

# allow the CEO to view any document
allow(actor: User, "GET", _doc: Document) if
    actor.job_title = "CEO";

# allow users to view their own docs
allow(actor: User, "GET", doc: Document) if
    actor.id = doc.owner_id;

The job_title and id attributes of User come from the Azure "Job Title" and "Object ID" claims that we set up in our User Flows. You may notice that users can edit their own job title through the user flows, which isn't very secure. To fix this, uncheck "Job Title" in the "User Attributes" section of each user flow. This will make it so the field is only editable through the Azure portal.

Role-Based Access Control using groups

As a directory service, Azure Active Directory provides tools for user management, including groups. You can create and assign users to groups through the Azure portal. Our sample application's policy uses group data from Active Directory to implement something like Role-based Access Control (RBAC), where access to resources depends on the user's role.

We have created the following groups:

Adding%20auth%20to%20a%20Flask%20App%20with%20Azure%20Active%20Direc%20e4020384c5a348e499250ae95d24e0d1/Screen_Shot_2020-09-10_at_8.34.38_AM.png

Unfortunately, the AD B2C product does not yet support including group membership information in the id_token claims, so the application uses Microsoft's Graph API to access group information.

We implemented a method called groups() on the User class that returns a user's groups by looking them up with the Graph API:

# user.py

def groups(self):
        token = msal_auth.get_access_token()
        id = session.get("user").id
        groups = requests.post(
            f"https://graph.microsoft.com/v1.0/users/{id}/getMemberGroups",
            headers={"Authorization": "Bearer " + token},
            json={"securityEnabledOnly": "false"},
        ).json()
        group_data = []
        if groups.get("value"):
            for id in groups.get("value"):
                group_data.append(
                    requests.get(
                        f"https://graph.microsoft.com/v1.0/groups/{id}",
                        headers={"Authorization": "Bearer " + token},
                    )
                    .json()
                    .get("displayName")
                )
        return group_data

Notice that this request requires an access_token, a different kind of token from the id_token. This token is granted using the "Client Credentials" OAuth flow, which grants access to the Graph API based on the application's own credentials (rather than the credentials of a user, as is the case for the sign in flow). In order for your application to access the users and groups endpoints of the Graph API, you'll need to grant it API access in the Azure portal, as explained here. The permissions necessary for our groups() method are User.Read.All and Group.Read.All. Make sure that the permission type is "Application", not "Delegation", so that your application has access independent of the signed-in user:

Adding%20auth%20to%20a%20Flask%20App%20with%20Azure%20Active%20Direc%20e4020384c5a348e499250ae95d24e0d1/Screen_Shot_2020-09-10_at_9.06.31_AM.png

With access to the current user's groups, we can add this rule to our policy:

# authorization.polar

# allow users to view documents in their group
allow(actor: User, "GET", doc: Document) if
    group in actor.groups() and
    group.lower() in doc.groups;

This rule allows members in a document's associated group to access the document, even if it is private. As an aside, the group.lower() call uses Python's built-in string methods, which can be called from oso policies.

To test the policy, you can create a few test users in the Azure Portal and assign them to various groups:

Adding%20auth%20to%20a%20Flask%20App%20with%20Azure%20Active%20Direc%20e4020384c5a348e499250ae95d24e0d1/Screen_Shot_2020-09-11_at_11.30.00_AM.png

For example, If I sign in as a user in the "Engineering" group, I can view Document 1:

Adding%20auth%20to%20a%20Flask%20App%20with%20Azure%20Active%20Direc%20e4020384c5a348e499250ae95d24e0d1/Screen_Shot_2020-09-10_at_9.12.32_AM.png

But if I try to access Document 2, a marketing doc, I am forbidden:

Adding%20auth%20to%20a%20Flask%20App%20with%20Azure%20Active%20Direc%20e4020384c5a348e499250ae95d24e0d1/Screen_Shot_2020-09-10_at_9.13.44_AM.png

Remember that I can still access Document 3, because it is public.

And now we've implemented our full authorization model! With four oso rules, we secured access to our application's document resources based on user attributes from Azure Active Directory. This was a relatively simple example, but hopefully it gave you a sense of what you can accomplish with oso. We used Azure AD as the Identity Provider in this example, but because of oso's simple interface, the same policy and library calls could work with user objects from any IdP.

Other things you could do

We kept the authorization model pretty simple, but you could quickly increase the complexity of the policy by adding things like folders to store documents, document editing endpoints, and user organizations.

The Graph API can also be used to get other information from Active Directory that might be useful for authorization, like a user's manager. We've implemented a manager() method on our User class to show what that would look like, though we don't use it in our policy. You could also use the Graph API to integrate your authorization with Microsoft Teams.

Azure AD B2C also makes it possible to integrate 3rd party IdPs, like Google and GitHub, with your application. You could sign in users with GitHub, for example, then use data from GitHub's API, like the user's organizations, to make authorization decisions.

Summary

Here's what this post covered:

  • Sign in users with Azure AD B2C
  • Use oso to restrict access to signed-in users
  • Add custom user attributes to our user profile
  • Write a policy to control access based on user attributes, like job title or team
  • Access Microsoft user data, like groups and managers, with the Graph API
  • Write an oso policy to implement role-based access control using Microsoft groups

We'd love to know what you think! If you tried this out yourself, have a question about using Azure AD with oso, or have any other technical questions or feedback, join us on Slack or open an issue.

Want us to remind you?
We'll email you before the event with a friendly reminder.

Write your first policy