Advanced Topics
The following sections describe advanced usage of the library.
Request Modification Details
If all checks outlined above pass successfully, the actual endpoint (or next middleware) will be called. To persist the authentication result,
we’re using Starlettes Request object. The following attributes are added to the request:
User Object
The user object is stored in scope.user attribute. As the request is passed to further middlewares, dependencies or potentially decorators, the endpoint can access the user information.
Authorization Scopes
This example does not contain any scopes, so the scope.auth attribute will be an empty list. Refer to the advanced documentation for how to leverate authorization scopes.
Logging
Note straight from the Python Docs:
Note
It is strongly advised that you do not log to the root logger in your library. Instead, use a logger with a unique and easily identifiable name, such as the __name__ for your library’s top-level package or module. Logging to the root logger will make it difficult or impossible for the application developer to configure the logging verbosity or handlers of your library as they wish.
Also, it is recommended to use module level logging:
Note
A good convention to use when naming loggers is to use a module-level logger, in each module which uses logging, named as follows
This module implements these best practices.
Warning
Especially during the authorization phase, the user object is included in the log message. Make sure your user object is serializable to a string, otherwise the log message will contain non-helpful strings.
Token Introspection vs Opaque Tokens
By default, this library will attempt to validate the JWT signature locally using the public key obtained from Keycloak. This is the recommended way to validate the token, as it does not require any additional requests to Keycloak. Also, Keycloak does not support opaque tokens yet.
If you want to still use the token endpoint to validate the token, you can opt to do so:
# Set up Keycloak
keycloak_config = KeycloakConfiguration(
url="https://sso.your-keycloak.com/auth/",
realm="<Realm Name>",
client_id="<Client ID>",
client_secret="<Client Secret>",
use_introspection_endpoint=True
)
Please make sure to understand the consequences before applying this configuration.
JWT Decoding Options
The upstream project python-keycloak uses python-jose under the hood to verify JWT tokens. This library uses sensible defaults for the JWT verification, but it is possible to modify the decode options if needed.
# Set up Keycloak
keycloak_config = KeycloakConfiguration(
# ...
decode_options={
"verify_signature": True,
"verify_aud": False,
"verify_exp": True,
}
)
Unfortunately, python-jose does not document the available options very well. Please refer to the source code of python-jose to confirm the verification options that can be configured: https://github.com/mpdavis/python-jose/blob/4b0701b46a8d00988afcc5168c2b3a1fd60d15d8/jose/jwt.py#L81
Excluding Endpoints
You may not want to enforce authentication for all endpoints. For example, you may want to allow anonymous access to the health check endpoint or allow accessing the autogenerated docs and OpenAPI schema without authentication.
There are two ways of doing this.
Note
Both examples can also be combined to achieve more complex setups.
Exclude certain paths
The middleware provides a configuration option to exclude certain paths from authentication. Those are compiled as regex and then matched against the request path.
Example:
excluded_routes = [
"/status",
"/docs",
"/openapi.json",
"/redoc",
]
app = FastAPI()
app.add_middleware(
KeycloakMiddleware,
# ...
exclude_patterns=excluded_routes,
)
This would make sure you can access the docs, alternate docs, OpenAPI schema and health check endpoint without authentication.
Warning
At the moment only the paths are checked, not the request method or other criteria. See issue #3 for more details.
Technical Details:
Under the hood these paths are compiled to regex and then matched against the request path. Each string is passed as-is to re.compile and stored, such that it can be used later to patch against the request path.
Use Multiple Applications
Alternatively you can use multiple FastAPI applications and mount them to the main application. This way you can have different authentication requirements for different endpoints.
Example:
# This first app is secured
secured_app = FastAPI()
app.add_middleware(
KeycloakMiddleware,
# ...
exclude_patterns=excluded_routes,
)
# This second app has no middleware to it and is not protected
public_app = FastAPI()
# This is your main app, mounting the other two applications
app = FastAPI()
app.mount(path="/secured", app=secured_app)
app.mount(path="/public", app=public_app)
Device Authentication
If you need to authenticate devices, you can do so in various different ways. We need to distinguish between two different scenarios:
User Devices
These are devices that belong to a certain user. You can use Keycloak device authorization grant. The device can start the process by using the Keycloak REST API and show a code to the user. The user then enters this code in Keycloak and authenticates with the user credentials. The device can poll another endpoint and receives a token when the authentication is completed.
You only need to make sure that the same claims are mapped to tokens created by this client compared to the claims normal users would get. For this library there is no difference between those tokens then, so authentication and authorization work as previously described.
Standalone Devices
Overview
It gets a little more complicated if a device is not directly mapped to a user, for example IoT decices you maintain that need to access your API.
While the way how you obtain the token doesn’t really matter (could be device code flow as described above or could be Keycloak offline tokens), the user that is used for this matters.
Keycloak configuration
One example on how to configure the Keycloak side of things:
Create a user in Keycloak that represents the device
Create a client for device authentication
Create client roles for the devices you need to support and map them to the same claim you use for user roles on your user client
Map the device user to client roles of the device client
You can now obtain a refresh token on either using the device flow or my leveraging offline sessions and the device can use them to obtain an access token if it needs to perform requests against the API.
Note
This by no means is the only way to do this. Keycloak is very flexible, you’ll need to find the configuration that fits your needs.
Library configuration
Depending on your user handling within the API, you may need to take additional steps. If you also create the device users within your API environment and the user mapper can map them as normal, you don’t need to take additonal steps. If you don’t want to create these users within the API, this library has options to configure how to behave in case the user does not exist.
The default behavior is to fail authentication if the built-in or user-defined user mapper cannot return a user. For device authentication, it is possible to add a specific claim to the access token which tells the library that this is a device requesting access.
The following example shows the configurtion on the library side:
# Set up Keycloak
keycloak_config = KeycloakConfiguration(
url="https://sso.your-keycloak.com/auth/",
realm="<Realm Name>",
client_id="<Client ID>",
client_secret="<Client Secret>",
enable_device_authentication=True,
device_authentication_claim="is_device",
)
This tells the library to enable the aforementioned behavior. It will now:
The access token signature and validity will be checked as usual
Check if the claim
is_deviceis present in the access tokenIf it is present, it will evaluate the value of the claim. If it is a truthy value (
bool(value) === True), continue, otherwise fail authenticationThe remaining steps (claim extraction, user mapping, authorization scope mapping) will be skipped
If the claim is not present in the access token, the library will behave as usual and try normal user authentication.
Note
To add the claim to your token, you can either use a Hardcoded claim mapper or any other method you prefer.
Request Injection
Note
This section contains technical details about the implementation within the library and is not required to use the library. Feel free to skip it.
The decorator used to enforce permissions requires to have access to the Request object, as the middleware stores the user information and compiled permissions there.
FastAPI injects the request to the path function, if the path function declares the request parameter. If its not provided by the user, the request would normally not be passed and would therefore not be available to the decorator.
This would end up in some code like this:
@app.get("/users/me")
@require_permission("user:read")
def read_users_me(request: Request): # pylint: disable=unused-argument
return {"user": "Hello World"}
Not only would this require unneccessary imports and blow up the path function, it would also raise a warning for an unused variable which then would need to be suppressed.
To avoid this, the decorater uses a somewhat “hacky” way to modify the function signature and include the request parameter. This way, the user does not need to declare the request parameter and the decorator can still access it.
Lateron, before actually calling the path function, the request is removed from kwargs again, to avoid an exception being raised for an unexpected argument.
Details can be found in PEP 362 - Function Signature Object. Consider the following code:
# Get function signature
sig = signature(func)
# Get parameters
parameters: OrderedDict = sig.parameters
if "request" in parameters.keys():
# Request is already present, no need to modify signature
return wrapper
# Add request parameter by creating a new parameter list based on the old one
parameters = [
Parameter(
name="request",
kind=Parameter.POSITIONAL_OR_KEYWORD,
default=Parameter.empty,
annotation=starlette.requests.Request,
),
*parameters.values(),
]
# Create a new signature, as the signature is immutable
new_sig = sig.replace(parameters=parameters, return_annotation=sig.return_annotation)
# Update the wrapper function signature
wrapper.__signature__ = new_sig
return wrapper
The request is still passed to the path function if defined by the user, otherwise its removed before calling the path function.