I am using the built-in Oauth2 fastapi module to contact the Keycloak token endpoint and get an access token.
In Keycloak I have a client with openid-connect and confidential access type, and client credentials flow enabled (this looked like the more suitable for my project since it is a server-to-server api).
In my python application, I have an Oauth2ClientCredentials class that inherits directly from Oauth2 in Fastapi, and I have an authenticate method which depends of my client credentials oauth2 scheme.
All is working perfectly to get the access token, and use it to safely access my api resources. Howewer, I can't find a way to use the keycloak refresh token with fastapi.
I saw it wasn't recommended to use refresh-token with client credentials flow, but I have the same issue with the other Keycloak flows anyway.
To retrieve the refresh token with Postman, I tried to activate the "Use Refresh Tokens For Client Credentials Grant" in the Compatibility Modes section of my client in the keycloak admin console, which worked. I also tried to fill the refreshUrl in the oauth2_scheme to see if the Fastapi implementation of Oauth2 was able to automatically get the refresh token and ask for the new access token, but it didn't work.
Fastapi doesn't seem to have a built-in Oauth2 method to automatically get and use the refresh-token from an external identity provider, and I can't retrieve the refresh token inside Fastapi to code by hand something less clean to use it.
I don't want to use the python-keycloak package in my project, since it should be able to work with other identity providers as well if I want to. And I need to keep using fastapi for my app.
Do you know a clean way to automatically ask for a new access token using a refresh token when the previous one is expired in Fastapi with keycloak ? Or a way to code it by hand ? I spent a lot of time checking all the documentation and the others stackoverflow posts to find how to do it but I didn't find anything really accurate.
Authentication and authorization module:
from fastapi import Depends, HTTPException, status
from fastapi.security import SecurityScopes
import json
import jwt
from jwt import PyJWKClient
from models import TokenData, Oauth2ClientCredentials
from config import openid_auth
from config.logger import logger
from typing import Optional
oauth2_scheme = Oauth2ClientCredentials(
tokenUrl=openid_auth.ACCESS_TOKEN_URL,
refreshUrl=openid_auth.ACCESS_TOKEN_URL
)
def _authorize(token_data: TokenData, security_scopes: SecurityScopes, authenticate_value: str):
""" Check the user permissions
Raise exception if not enough permissions to consume the requested resource
"""
for scope in security_scopes.scopes:
if scope not in token_data.scopes:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions",
headers={"WWW-Authenticate": authenticate_value},
)
return True
async def authenticate(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)):
""" Check the access token generated by the keycloak server
Get the token requested from the browser client and extract data from it
"""
if security_scopes.scopes:
authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
else:
authenticate_value = f"Bearer"
try:
jwk_client = PyJWKClient(openid_auth.KEYCLOAK_CERTS)
signing_key = jwk_client.get_signing_key_from_jwt(token)
payload = jwt.decode(token, signing_key.key, algorithms=[openid_auth.ALGORITHM], options={"verify_aud": False})
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token is expired. Please update your token.",
headers={"WWW-Authenticate": authenticate_value},
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unable to parse authentication token. " + str(e),
headers={"WWW-Authenticate": authenticate_value},
)
username: Optional[str] = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": authenticate_value},
)
token_scopes = payload["realm_access"]["roles"]
token_data = TokenData(scopes=token_scopes, username=username)
if _authorize(token_data, security_scopes, authenticate_value):
return token_data
Here is my Oauth2ClientCredentials class (based on the other Oauth2 implementations of Fastapi):
from typing import Dict, Optional
from fastapi.exceptions import HTTPException
from fastapi.security import OAuth2
from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel
from fastapi.security.utils import get_authorization_scheme_param
from starlette.requests import Request
from starlette.status import HTTP_401_UNAUTHORIZED
""" Implementation of the client credentials flow for fastapi """
class Oauth2ClientCredentials(OAuth2):
def __init__(
self,
tokenUrl: str,
refreshUrl: Optional[str] = None,
scheme_name: Optional[str] = None,
scopes: Optional[Dict[str, str]] = None,
auto_error: bool = True,
):
if not scopes:
scopes = {}
flows = OAuthFlowsModel(
clientCredentials={"tokenUrl": tokenUrl, "scopes": scopes})
super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)
async def __call__(self, request: Request) -> Optional[str]:
authorization: str = request.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
else:
return None
return param