Friday, March 31, 2023

Adding Microsoft Graph authentication as a Flask Blueprint

Over the last month, I've been working a lot with the Microsoft Graph API identity platform, which makes it possible for users to login to a website with a Microsoft 365 account. With some additional configuration, it even acts as a generic customer identity management solution.

For Python developers, msal has been the traditional package for interacting with the API. However, the author of that library recently developed a higher-level package, identity, to wrap the most common authentication flow. The ms-identity-python-webapp sample demonstrates how to use that flow in a minimal Flask web app.

Flask developers often use Blueprints and Application factories to organize large Flask apps, and many requested guidance on integrating the authentication code with a Blueprint-based application. I have developed a few Blueprint-based apps lately, so I decided to attempt the integration myself. Good news: it worked! 🎉

You can see my integration in the identity branch of flask-surveys-container-app, and look through the changes in the pull request.

To aid others in their integration, I'll walk through the notable bits.

The auth blueprint

First I added an auth blueprint to bundle up the auth-related routes and templates.

auth/
├── __init__.py
├── routes.py
└── templates/
    └── auth/
        ├── auth_error.html
        └── login.html

The routes in the blueprint are similar to those in the original sample. However, I added a line to store next_url inside the current session. By remembering that, a user can login from any page and get redirected back to that page (versus getting redirected back to the index). That's a much nicer user experience in a large app.

@bp.route("/login")
def login():
    auth = current_app.config["AUTH"]
    session["next_url"] = request.args.get("next_url", url_for("index"))
    return render_template(
        "auth/login.html",
        version=identity.__version__,
        **auth.log_in(
            scopes=current_app.config["SCOPE"],
            redirect_uri=url_for(".auth_response", _external=True)
        ),
    )

@bp.route("/getAToken")
def auth_response():
    auth = current_app.config["AUTH"]
    result = auth.complete_log_in(request.args)
    if "error" in result:
        return render_template("auth/auth_error.html", result=result)
    next_url = session.pop("next_url", url_for("index"))
    return redirect(next_url)


@bp.route("/logout")
def logout():
    auth = current_app.config["AUTH"]
    return redirect(auth.log_out(url_for("index", _external=True)))

The templates in the auth blueprint now extend base.html, the only template in the root templates directory. That gives the authentication-related pages a more consistent UI with the rest of the site.

App configuration

The global app configuration happens in the root __init__.py, mostly inside the create_app function.

To make sure I could access the identity package's Auth object from any blueprint, I added it to app.config:

app.config.update(
    AUTH=identity.web.Auth(
        session=session,
        authority=app.config.get("AUTHORITY"),
        client_id=app.config["CLIENT_ID"],
        client_credential=app.config["CLIENT_SECRET"],
    )
)

I also used the app.context_processor decorator to inject a user variable into every template that gets rendered.

@app.context_processor
def inject_user():
    auth = app.config["AUTH"]
    user = auth.get_user()
    return dict(user=user)

Finally, I registered the new auth blueprint in the usual way:

from backend.auth import bp as auth_bp

app.register_blueprint(auth_bp, url_prefix="")

Requiring login

Typically, in an app with user authentication, logging in is required for some routes while optional for others. To mark routes accordingly, I defined a login_required decorator:

def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        auth = current_app.config["AUTH"]
        if auth.get_user() is None:
            login_url = url_for("auth.login", next_url=request.url)
            return redirect(login_url)
        return f(*args, **kwargs)
    return decorated_function

When that decorator sees that there is no current user, it forces a redirect to the auth blueprint's login route, and passes along the current URL as the next URL.

I then applied that decorator to any route that required login:

@bp.route("/surveys/new", methods=["GET"])
@login_required
def surveys_create_page():
    return render_template("surveys/surveys_create.html")

Accessing user in templates

I want to let users know their current login status on every page in the app, and give them a way to either login or log out. To accomplish that, I added some logic to the Bootstrap nav bar in base.html:

{% if user %}
  <li class="nav-item dropdown">
      <a class="nav-link dropdown-toggle" href="/"
            id="navbarDropdown" role="button"
            data-bs-toggle="dropdown" aria-expanded="false">
        {{ user.get("name")}}
      </a>
      <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
        <li class="nav-item">
          <a class="nav-link" href="{{ url_for('auth.logout') }}">Sign out</a>
        </li>
      </ul>
  </li>
{% else %}
  <li class="nav-item">
    <a class="nav-link" href="{{ url_for('auth.login') }}">Sign in</a>
  </li>
{% endif %}

...And that's about it! Look throughthe full code or pull request for more details. Let me know if you have suggestions for a better way to architect the auth blueprint, or if you end up using this in your own app. 🤔

1 comment:

Ben said...

This is great, I'm currently figuring out the best way to do this. As a non-programmer it's good to find examples such as yours with an explanation instead of digging through git repos.

Question though, how would you suggest including authorisation? Eg using Azure And/Entra ID group membership or app roles to control access to routes?

I note that authZ isn't yet implemented in ms-identity-python.

Thank you.