Flask extension for authenticating and authorising requests using the Entra identity platform.
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.
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.
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.
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']}"
See the official Microsoft MSAL library, which can also validate ID tokens.
See the official Microsoft jwt.ms tool for introspecting and debugging access tokens.
See the Token Scopes section for more information.
See the Testing Support section for more information on how to generate fake tokens for application testing.
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.
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.
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.
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.
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.
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.
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:
- load OIDC metadata to get expected issuer and location to JWKS
- load JWKS
- parse token (base64 decode, JSON parse into header, payload and signature parts)
- match
kid
token header parameter to key in JWKS - validate token signature using signing key
- validate issuer
- validate audience
- validate expiration
- validate not before
- validate issued at (omitted)
- validate token schema version
- validate subject (if configured)
- validate client (if configured)
- validate scopes (if configured)
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).
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.
The optional jit
claim is not validated as this isn't included in Entra tokens.
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
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"
}
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"
}
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)
- by setting the
- 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
See Developing documentation.
British Antarctic Survey (BAS) Mapping and Geographic Information Centre (MAGIC). Contact [email protected].
The project lead is @felnne.
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.