Skip to content

Latest commit

 

History

History
476 lines (344 loc) · 21.7 KB

README.md

File metadata and controls

476 lines (344 loc) · 21.7 KB

Flask Entra Auth

Flask extension for authenticating and authorising requests using the Entra identity platform.

Overview

Note: This project is focused on needs within the British Antarctic Survey. It has been open-sourced in case it is of interest to others. Some resources, indicated with a '🛡' or '🔒' symbol, can only be accessed by BAS staff or project members respectively. Contact the Project Maintainer to request access.

Note: This extension was rewritten in version 0.8.0 with a new name and non-backwards compatible design.

Purpose

Allows routes in a Flask application to be restricted using the Microsoft Entra identity platform.

Use this if you use Entra ID and want to authenticate and optionally authorise users or clients of your Flask app.

Install

The extension can be installed using Pip from PyPi:

$ pip install flask-entra-auth

Note: Since version 0.6.0, this extension requires Flask 2.0 or greater.

Usage

After creating an App Registration in Entra, initialise and Configure the extension in your Flask app:

from flask import Flask, current_app
from flask_entra_auth.resource_protector import FlaskEntraAuth
from flask_entra_auth.token import EntraToken

app = Flask(__name__)
app.config["ENTRA_AUTH_CLIENT_ID"] = 'xxx'
app.config["ENTRA_AUTH_OIDC_ENDPOINT"] = 'xxx'
app.config["ENTRA_AUTH_ALLOWED_SUBJECTS"] = ['xxx']  # optional, allows all subjects if empty or not set
app.config["ENTRA_AUTH_ALLOWED_APPS"] = ['xxx']  # optional, allows all applications if empty or not set

auth = FlaskEntraAuth()
auth.init_app(app)

# Example routes

@app.route("/restricted/red")
@app.auth()
def authenticated():
    """Route requires authenticated user."""
    return "Authenticated route."

@app.route("/restricted/blue")
@app.auth(['APP_SCOPE_1'])
def authorised():
    """Route requires authenticated and authorised user, specifically having the 'APP_SCOPE_1' scope."""
    return "Authorised route."

@app.route("/restricted/green")
@app.auth(['APP_SCOPE_1 APP_SCOPE_2'])
def authorised_and():
    """Route requires authenticated and authorised user, specifically having both the 'APP_SCOPE_1' and 'APP_SCOPE_2' scopes."""
    return "Authorised route."

@app.route("/restricted/yellow")
@app.auth(['APP_SCOPE_1', 'APP_SCOPE_2'])
def authorised_either():
    """Route requires authenticated and authorised user, specifically having either the 'APP_SCOPE_1' or 'APP_SCOPE_2' scopes."""
    return "Authorised route."

@app.route("/restricted/purple")
@app.auth()
def current_token():
    """Get a claim from the current token"""
    token: EntraToken = current_app.auth.current_token
    return f"Hello {token.claims['name']}"

Generating access tokens

See the official Microsoft MSAL library, which can also validate ID tokens.

Inspecting access tokens

See the official Microsoft jwt.ms tool for introspecting and debugging access tokens.

Using scopes to control access

See the Token Scopes section for more information.

Generating fake tokens

See the Testing Support section for more information on how to generate fake tokens for application testing.

Configuration

These config options are read from the Flask config object:

Option Required Description
ENTRA_AUTH_CLIENT_ID Yes Entra Application (Client) ID
ENTRA_AUTH_OIDC_ENDPOINT Yes OpenID configuration document URI
ENTRA_AUTH_ALLOWED_SUBJECTS No An allowed list of end-users
ENTRA_AUTH_ALLOWED_APPS No An allowed list of client applications
ENTRA_AUTH_CONTACT No A URI to a contact website or mailto address

The CLIENT_ID represents the Flask application being secured (i.e. a client of Entra ID).

The ALLOWED_APPS list of clients represents clients of the Flask application (as well as Entra ID).

See the Entra ID documentation for how to get the Client ID and OIDC Endpoint for your application.

See the Error Handling section for more information on the ENTRA_AUTH_CONTACT option.

Implementation

Resource protector

This extension provides a AuthLib Flask resource protector, EntraResourceProtector, to secure access to routes within an application by requiring a valid user (authentication) and optionally one or more required Scopes (authorisation).

The AuthLib resource protector uses different validators for different token types. In this case a BearerTokenValidator, EntraBearerTokenValidator, is used to Validate a bearer JSON Web Token (JWT) specified in the Authorization request header. If validation fails, an Error is returned as the request response.

The AuthLib resource protector assumes the application is running its own OAuth server, and so has a record of tokens it has issued and can determine their validity (not revoked, expired or having insufficient scopes). This assumption doesn't hold for Entra tokens, so instead we validate the token using PyJWT and some additional checks statelessly.

For convenience, the resource protector is exposed as a Flask extension, including a current_token property that gives access to the access token taken from the request as an EntraToken instance.

Entra Tokens

This extension uses a custom EntraToken class to represent Entra Access Tokens (not ID tokens which can be validated with the official MSAL library).

This class provides token Validation, Introspection and access methods of and to tokens and their claims.

Note: Creating an EntraToken instance will automatically and implicitly Validate a token.

Note: Validating an EntraToken instance will automatically fetch the OIDC metadata and the JSON Web Key Set (JWKS) this specifies from their respective URIs, which are then Cached.

WARNING: EntraTokens do not re-validate themselves automatically once created. It is assumed tokens will be tied to a request, and that these will be processed before they become invalid (i.e. within ~60 seconds).

If desired, this class can be used outside the Resource Protector by passing a token string, OIDC metadata endpoint, client ID (audience) and optionally an allowed list of subjects and client applications:

from flask import Flask
from flask_entra_auth.token import EntraToken

app = Flask(__name__)
app.config["ENTRA_AUTH_CLIENT_ID"] = 'xxx'
app.config["ENTRA_AUTH_OIDC_ENDPOINT"] = 'xxx'
app.config["ENTRA_AUTH_ALLOWED_SUBJECTS"] = ['xxx']  # optional, allows all subjects if empty or not set
app.config["ENTRA_AUTH_ALLOWED_APPS"] = ['xxx']  # optional, allows all applications if empty or not set

# allowing all subjects but a restricted list of client applications
token = EntraToken(
  token='eyJhbGciOiJSUzI1NiIsImtpZCI6IjBYZ0ZndE5iLXVHazU1LUdSX1BMQ3JzN29aREtLWlRRNE5YUVM2NnhyLWsiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5hdXRoLmV4YW1wbGUuY29tIiwic3ViIjoidGVzdF9zdWJqZWN0IiwiYXVkIjoidGVzdF9hcHBfMSIsImV4cCI6MTcyMzQ1NzAwOCwibmJmIjoxNzIzNDUzNDA4LCJhenAiOiJ0ZXN0X2FwcF8yIiwidmVyIjoiMi4wIiwic2NwcyI6WyJTQ09QRV9BIiwiU0NPUEVfQiIsIlNDT1BFX0MiXSwicm9sZXMiOlsiUk9MRV8xIiwiUk9MRV8yIiwiUk9MRV8zIl19.jOoVhWLku34OUY4XBfUddeW39R0W2PxMmf_dKiSPr87pzg0m3d5_HqVOOVyB_qKvODPT8LHT3lrKIn1D9_67ERoa5clCn23DJAOZnux-hMXd19CCPWdBMu2yC1_kBzMdIkZbTgiuTjTleLYLl5JV3livdE0JVXaSHsj7Qt5c6yypfOBbk5uM4hYqpAnMpl6XToZgnBaI1SuRF2bj2bddLNzVxvg4yOYnX25Ruz5eMkKZonBI9FyumysD7CNOEnyANdaT4z4Z5siGI046hjt10if-Iz8EmDR7Srx_wX_KLng8qS0VE3qzxhEAycoBS6RKlZ2NRfPqkwkizUi0TlDLsA',
  oidc_endpoint='https://login.microsoftonline.com/{tenancy}/v2.0/.well-known/openid-configuration',
  client_id='test_app_1',
  allowed_apps=['deb4356e-1570-4d5a-bdaa-86cf545a8045']
)

# get a validated claim
print(token.claims['exp'])  # 1723457008

# get list of scopes
print(token.scopes)  # ['SCOPE_A', 'SCOPE_B', 'SCOPE_C', 'ROLE_1', 'ROLE_2', 'ROLE_3']

If validation fails (which is checked implicitly on init), an EntraAuthError exception will be raised.

OIDC and JWKS caching

Data from the OIDC metadata and JWKS endpoints are cached in memory for 60 seconds within (but not between) EntraToken instances. This speeds up access to OIDC metadata properties, such as the JWKS and issuer, which otherwise would trigger multiple requests to information that is very unlikely to change within the lifetime of a token.

Token scopes

Typically, applications wish to limit which users or clients can perform particular actions (e.g. read vs. read-write) using custom permissions. These can be defined within the Entra ID application registration and then checked by the Resource Protector.

Entra distinguishes between permissions:

  • that apply to client applications directly, termed scps (scopes)
  • that apply to users (or other principles such as service accounts) and delegated to client applications, termed roles

This extension combines any scps and roles into a generic list of scopes, returned by the EntraToken.scopes property to make it easier to combine different combinations of permissions.

See the Entra Documentation for how to Register custom client scopes or to Register custom user roles.

In addition to using scopes and checking these within Flask, Entra also offers features such as User Assignment which apply 'upstream' as part of Generating Access Tokens.

The Resource Protector decorator supports both AND and OR local operators for specifying scopes. See the AuthLib documentation for more information.

Token validation

Microsoft does not provide an official library for validating Entra Tokens in Python.

This extension has opted to validate tokens using a combination of PyJWT and additional custom validation methods. This is in line with how others have solved the same problem.

Validation sequence

In summary:

  • get signing keys (JWKS) from Entra Open ID Connect (OIDC) endpoint (to avoid hard-coding keys that Entra may rotate)
  • validate standard claims using pyjwt.decode()
  • additionally validate the (Entra) ver claim is '2.0' so we know which claims we should expect
  • the sub and/or (Entra) azp claim values are validated against an allow list if set (otherwise all allowed)

In more detail:

  1. load OIDC metadata to get expected issuer and location to JWKS
  2. load JWKS
  3. parse token (base64 decode, JSON parse into header, payload and signature parts)
  4. match kid token header parameter to key in JWKS
  5. validate token signature using signing key
  6. validate issuer
  7. validate audience
  8. validate expiration
  9. validate not before
  10. validate issued at (omitted)
  11. validate token schema version
  12. validate subject (if configured)
  13. validate client (if configured)
  14. validate scopes (if configured)

Validation limitations

Authentication header

The Resource Protector checks for a missing authorisation header but doesn't raise a specific error for a missing auth scheme, or auth credential (i.e. either parts of the authorisation header). Instead, both errors are interpreted as requesting an unknown token type (meaning HTTP auth scheme (basic/digest/bearer/etc.) not OAuth type (access/refresh/etc.)) by the parse_request_authorization(). method.

Whilst this is technically true, it isn't as granular as we'd ideally like. Whilst it would be possible to overload the parse_request_authorization method, it's currently not deemed necessary and instead extra detail is included in the Error returned for a bad authorization header (i.e. no scheme, no credential or unsupported scheme).

iat claim

The optional iat claim is included in Entra tokens but is not validated because it can't be tested.

Currently, there is no combination of exp, nbf and iat claim values that mean only the iat claim is invalid, which is necessary to write an isolated test for it. Without a test we can't ensure this works correctly and is therefore disabled.

jit claim

The optional jit claim is not validated as this isn't included in Entra tokens.

Token introspection

The EntraToken class provides a rfc7662_introspection() method that returns standard/common claims from a token according to RFC 7662 (OAuth Token Introspection).

This returns a dict that can returned as a response. As per the RFC, the token to be introspected MUST be specified as form data. It MUST also be authenticated via a separate mechanism to the token. This latter feature is not provided by this library and would need implementing separately.

Note: The optional jti claim is not included as this isn't included in Entra tokens.

Example route (without separate authentication mechanism):

from flask import Flask, request
from flask_entra_auth.exceptions import EntraAuthError
from flask_entra_auth.token import EntraToken

app = Flask(__name__)
app.config["ENTRA_AUTH_CLIENT_ID"] = 'xxx'
app.config["ENTRA_AUTH_OIDC_ENDPOINT"] = 'xxx'

@app.route("/introspect", methods=["POST"])
def introspect_rfc7662():
    """
    Token introspection as per RFC7662.
    """
    try:
        token = EntraToken(
            token=request.form.get("token"),
            oidc_endpoint=app.config["ENTRA_AUTH_OIDC_ENDPOINT"],
            client_id=app.config["ENTRA_AUTH_CLIENT_ID"],
        )
        return token.rfc7662_introspection  # noqa: TRY300
    except EntraAuthError as e:
        return {"error": str(e)}, e.problem.status

Error handling

Errors encountered when accessing or validating the access token are raised as exceptions. These inherit from a base EntraAuthError exception and are based on RFC7807, encoded as JSON.

Where an exception is raised within the Resource Protector (including the EntraToken instance it creates), the exception is handled by returning as a Flask (error) response.

Example response:

{
  "detail": "Ensure your request includes an 'Authorization' header and try again.",
  "status": 401,
  "title": "Missing authorization header",
  "type": "auth_header_missing"
}

Error point of contact URI

Optionally, a contact URI can be included in errors by setting the ENTRA_AUTH_CONTACT Config option to be included in errors returned by the Resource Protector (standalone uses of the EntraToken class do not support this feature).

Example response (where app.config.["ENTRA_AUTH_CONTACT"]="mailto:[email protected]"):

{
  "contact": "mailto:[email protected]",
  "detail": "Ensure your request includes an 'Authorization' header and try again.",
  "status": 401,
  "title": "Missing authorization header",
  "type": "auth_header_missing"
}

Testing support

If needed for application testing, this extension includes mock classes to generate fake tokens and signing keys. These can be used to simulate different scopes and/or error conditions. This requires changing the app under test to:

  • configure the Resource Protector to load a fake OIDC endpoint:
    • by setting the ENTRA_AUTH_OIDC_ENDPOINT Config option to this fake endpoint
    • this endpoint returning metadata referencing a fake JWKS endpoint
    • this JWKS endpoint in turn containing a fake JWK (signing key)
  • make requests to the app with local/fake access tokens (i.e. not issued by Entra) configured with relevant claims

The Resource Protector can be used as normal for authentication and authorisation using the claims set in the fake token. Additional claims for name, upn, etc. can be included in these tokens as needed.

If using pytest, the pytest-httpserver plugin is recommended to serve this fake OIDC endpoint.

For example, these fixtures:

  • return a Flask test client with a fake OIDC endpoint, JWKS endpoint and signing key
  • return a JWT client that can generate tokens with overridden, omitted and additional claims
import pytest
from pytest_httpserver import HTTPServer
from flask.testing import FlaskClient
from flask_entra_auth.mocks.jwks import MockJwks
from flask_entra_auth.mocks.jwt import MockClaims, MockJwtClient

# replace with reference to your Flask app or app factory
from your_app import app

mock_jwks = MockJwks()
mock_iss = 'fake-issuer'

@pytest.fixture()
def app_client(httpserver: HTTPServer) -> FlaskClient:
    """Flask test client configured with fake signing key."""
    oidc_metadata = {"jwks_uri": httpserver.url_for("/keys"), "issuer": mock_iss}
    httpserver.expect_request("/.well-known/openid-configuration").respond_with_json(oidc_metadata)
    httpserver.expect_request("/keys").respond_with_json(mock_jwks.as_dict())

    app.config['ENTRA_AUTH_OIDC_ENDPOINT'] = httpserver.url_for("/.well-known/openid-configuration")
    return app.test_client()

@pytest.fixture()
def jwt_client() -> MockJwtClient:
    claims = MockClaims(self_app_id=app.config['ENTRA_AUTH_CLIENT_ID'])  # so tokens have the expected audience
    return MockJwtClient(key=mock_jwks.jwk, claims=claims)

Then in a test:

from flask.testing import FlaskClient
from flask_entra_auth.mocks.jwt import MockJwtClient

def test_ok(self, app_client: FlaskClient, jwt_client: MockJwtClient):
    """Request to authenticated route is successful."""
    token = jwt_client.generate()  # default claims and values

    response = app_client.get("/restricted", headers={"Authorization": f"Bearer {token}"})
    assert response.status_code == 200

To tweak the claims in the token you can override their value, omit them by setting to False or add other claims. E.g:

from flask_entra_auth.mocks.jwt import MockJwtClient

def test_tokens(self, jwt_client: MockJwtClient):
    t = jwt_client.generate()  # default claims and values
    t = jwt_client.generate(roles=False, scps=False)  # no scopes
    t = jwt_client.generate(roles=['MY_APP.FOO.READ', 'MY_APP.BAR.READ'], scps=['MY_APP.SOMETHING'])  # custom scopes
    t = jwt_client.generate(exp=1)  # expired token (don't use `0` as this equates to None and won't be overridden)
    t = jwt_client.generate(additional_claims={'name': 'Connie Watson', 'upn': '[email protected]'})  # additional claims

Developing

See Developing documentation.

Releases

Project maintainer

British Antarctic Survey (BAS) Mapping and Geographic Information Centre (MAGIC). Contact [email protected].

The project lead is @felnne.

Licence

Copyright (c) 2019 - 2024 UK Research and Innovation (UKRI), British Antarctic Survey (BAS).

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.