12

Problem

I currently have JWT dependency named jwt which makes sure it passes JWT authentication stage before hitting the endpoint like this:

sample_endpoint.py:

from fastapi import APIRouter, Depends, Request
from JWTBearer import JWTBearer
from jwt import jwks

router = APIRouter()

jwt = JWTBearer(jwks)

@router.get("/test_jwt", dependencies=[Depends(jwt)])
async def test_endpoint(request: Request):
    return True

Below is the JWT dependency which authenticate users using JWT (source: https://medium.com/datadriveninvestor/jwt-authentication-with-fastapi-and-aws-cognito-1333f7f2729e):

JWTBearer.py

from typing import Dict, Optional, List

from fastapi import HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, jwk, JWTError
from jose.utils import base64url_decode
from pydantic import BaseModel
from starlette.requests import Request
from starlette.status import HTTP_403_FORBIDDEN

JWK = Dict[str, str]


class JWKS(BaseModel):
    keys: List[JWK]


class JWTAuthorizationCredentials(BaseModel):
    jwt_token: str
    header: Dict[str, str]
    claims: Dict[str, str]
    signature: str
    message: str


class JWTBearer(HTTPBearer):
    def __init__(self, jwks: JWKS, auto_error: bool = True):
        super().__init__(auto_error=auto_error)

        self.kid_to_jwk = {jwk["kid"]: jwk for jwk in jwks.keys}

    def verify_jwk_token(self, jwt_credentials: JWTAuthorizationCredentials) -> bool:
        try:
            public_key = self.kid_to_jwk[jwt_credentials.header["kid"]]
        except KeyError:
            raise HTTPException(
                status_code=HTTP_403_FORBIDDEN, detail="JWK public key not found"
            )

        key = jwk.construct(public_key)
        decoded_signature = base64url_decode(jwt_credentials.signature.encode())

        return key.verify(jwt_credentials.message.encode(), decoded_signature)

    async def __call__(self, request: Request) -> Optional[JWTAuthorizationCredentials]:
        credentials: HTTPAuthorizationCredentials = await super().__call__(request)

        if credentials:
            if not credentials.scheme == "Bearer":
                raise HTTPException(
                    status_code=HTTP_403_FORBIDDEN, detail="Wrong authentication method"
                )

            jwt_token = credentials.credentials

            message, signature = jwt_token.rsplit(".", 1)

            try:
                jwt_credentials = JWTAuthorizationCredentials(
                    jwt_token=jwt_token,
                    header=jwt.get_unverified_header(jwt_token),
                    claims=jwt.get_unverified_claims(jwt_token),
                    signature=signature,
                    message=message,
                )
            except JWTError:
                raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="JWK invalid")

            if not self.verify_jwk_token(jwt_credentials):
                raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="JWK invalid")

            return jwt_credentials 

jwt.py:

import os

import requests
from dotenv import load_dotenv
from fastapi import Depends, HTTPException
from starlette.status import HTTP_403_FORBIDDEN

from app.JWTBearer import JWKS, JWTBearer, JWTAuthorizationCredentials

load_dotenv()  # Automatically load environment variables from a '.env' file.

jwks = JWKS.parse_obj(
    requests.get(
        f"https://cognito-idp.{os.environ.get('COGNITO_REGION')}.amazonaws.com/"
        f"{os.environ.get('COGNITO_POOL_ID')}/.well-known/jwks.json"
    ).json()
)

jwt = JWTBearer(jwks)


async def get_current_user(
    credentials: JWTAuthorizationCredentials = Depends(auth)
) -> str:
    try:
        return credentials.claims["username"]
    except KeyError:
        HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Username missing") 

api_key_dependency.py (very simplified right now, it will be changed):

from fastapi import Security, FastAPI, HTTPException
from fastapi.security.api_key import APIKeyHeader

from starlette.status import HTTP_403_FORBIDDEN

async def get_api_key(
    api_key_header: str = Security(api_key_header)
):
    API_KEY = ... getting API KEY logic ...

    if api_key_header == API_KEY:
        return True
    else:
        raise HTTPException(
            status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials"
        ) 

Question

Depending on the situation, I would like to first check if it has API Key in the header, and if its present, use that to authenticate. Otherwise, I would like to use jwt dependency for authentication. I want to make sure that if either api-key authentication or jwt authentication passes, the user is authenticated. Would this be possible in FastAPI (i.e. having multiple dependencies and if one of them passes, authentication passed). Thank you!

lovprogramming
  • 593
  • 11
  • 24
  • A simple solution could be to have a unique `Dependency` that performs the check and calls the correct authentication method (JWT or KEY). If you need to authenticate certain paths just with JWT, you can use directly that dependency (the same approach applies for only KEY authentication) – lsabi Nov 07 '20 at 20:41
  • Thank you Isabi, just to clarify, what you mean is adding third dependency (which will be the only dependency for `sample_endpoint`) that performs the check and calls the correct authentication dependency (JWT dependency or Key dependency). Is this correct? – lovprogramming Nov 07 '20 at 21:32
  • Yes, some sort of factory method – lsabi Nov 07 '20 at 22:03
  • Could you provide a simple example where I can call other dependency inside the factory dependency? I researched a bit on how to do that, but wasnt able to find a good example on it. I've updated the post with sample dependency for api key validation. – lovprogramming Nov 08 '20 at 00:39
  • I've tried creating factory dependency (class), having \__call__ method inside factory dependency, and created two separate functions inside the class (`check_api_key`, `check_jwt`). It looks like `def call_api_key(self, b = Depends(get_api_key)):` for example. But it doesn't seem to call respective dependency inside `check_api_key` or `check_jwt` key at all. Would you be able to provide some guidance here? – lovprogramming Nov 08 '20 at 19:29

2 Answers2

10

Sorry, got lost with things to do

The endpoint has a unique dependency, call it check from the file check_auth

ENDPOINT

from fastapi import APIRouter, Depends, Request
from check_auth import check
from JWTBearer import JWTBearer
from jwt import jwks

router = APIRouter()

jwt = JWTBearer(jwks)

@router.get("/test_jwt", dependencies=[Depends(check)])
async def test_endpoint(request: Request):
    return True

The function check will depend on two separate dependencies, one for api-key and one for JWT. If both or one of these passes, the authentication passes. Otherwise, we raise exception as shown below.

DEPENDENCY

def key_auth(api_key=Header(None)):
    if not api_key:
      return None
    ... verification logic goes here ...

def jwt(authorization=Header(None)):
    if not authorization:
      return None
    ... verification logic goes here ... 
    
async def check(key_result=Depends(jwt_auth), jwt_result=Depends(key_auth)):
    if not (key_result or jwt_result):
        raise Exception
     
lovprogramming
  • 593
  • 11
  • 24
lsabi
  • 3,641
  • 1
  • 14
  • 26
  • Thanks Isabi! I got up to this part, but wasn't able to call other dependencies inside the factory dependency. I'm not really sure if this is supported in FastAPI. – lovprogramming Nov 10 '20 at 00:34
  • Probably it is due to the dependencies that the classes have. Maybe playing around with the request directly can allow you to access the keys – lsabi Nov 10 '20 at 11:16
  • @louprogramming did you manage to solve your problem? If so, feel free to improve my answer (maybe write EDIT before your changes) – lsabi Nov 11 '20 at 17:50
  • 1
    yeap, I ended up finding a workaround which was to move dependencies inside the factory dependency as two separate dependencies(methods). I was going to leave a comment on this, but didn't get a chance to do. I'll update it right now. – lovprogramming Nov 12 '20 at 05:05
  • @louprogramming Could you maybe provide an example of this? I have done something similar (made another dependency which allows both auth schemes); but then i lose the authorization option in the open api spec (the lock is gone). – Zeeshan Mar 14 '21 at 21:24
9

This worked for me (JWT or APIkey Auth). If both or one of the authentication method passes, the authentication passes.

def jwt_auth(auth: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
    if not auth:
      return None
    ## validation logic
    return True

def key_auth(apikey_header=Depends(APIKeyHeader(name='X-API-Key', auto_error=False))):
    if not apikey_header:
      return None
    ## validation logic
    return True

async def jwt_or_key_auth(jwt_result=Depends(jwt_auth), key_result=Depends(key_auth)):
    if not (key_result or jwt_result):
        raise HTTPException(status_code=401, detail="Not authenticated")


@app.get("/", dependencies=[Depends(jwt_or_key_auth)])
async def root():
    return {"message": "Hello World"}
Caleb Lee
  • 91
  • 1
  • 1