question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Subclass fastapi.params.Security to extend JWT payload checks for route authorization(?)

See original GitHub issue

First check

  • I added a very descriptive title to this issue.
  • I used the GitHub search to find a similar issue and didn’t find it.
  • I searched the FastAPI documentation, with the integrated search.
  • I already searched in Google “How to X in FastAPI” and didn’t find any information.
  • I already read and followed all the tutorial in the docs and didn’t find an answer.
  • I already checked if it is not related to FastAPI but to Pydantic.
  • I already checked if it is not related to FastAPI but to Swagger UI.
  • I already checked if it is not related to FastAPI but to ReDoc.
  • After submitting this, I commit to one of:
    • Read open issues with questions until I find 2 issues where I can help someone and add a comment to help there.
    • I already hit the “watch” button in this repository to receive notifications and I commit to help at least 2 people that ask questions in the future.
    • Implement a Pull Request for a confirmed bug.

Example

Here’s a self-contained, minimal, reproducible, example with my use case:

import logging
from dataclasses import dataclass, field
from enum import Enum, unique
from typing import (
    Any,
    Callable,
    List,
    Mapping,
    Optional,
    Sequence,
    Type,
)

import requests
from fastapi import (
    APIRouter,
    Depends,
    FastAPI,
    HTTPException,
    status,
)
from fastapi.params import Security
from fastapi.security.oauth2 import OAuth2AuthorizationCodeBearer
from jose import jwt
from jose.constants import ALGORITHMS
from pydantic import BaseModel, BaseSettings

logger = logging.getLogger(__name__)


class Config(BaseSettings):
    AUTH0_DOMAIN: str
    AUTH0_AUDIENCE: str

    class Config:
        env_file = ".env"
        case_sensitive = True
        validate_all = True
        allow_mutation = False


config = Config()


@dataclass
class JwtPayload:
    iss: str
    sub: str
    aud: List[str]
    iat: int
    exp: int
    azp: str
    scope: str
    permissions: List[str] = field(default_factory=list)


@unique
class Permission(Enum):
    """Enum values match permissions configured in Auth0"""
    THING_READ = "read:thing"
    THING_WRITE = "write:thing"


@dataclass
class Auth0User:
    token: str
    payload: JwtPayload
    permissions: List[Permission]

    @property
    def id(self) -> str:
        return self.payload.sub

    def has_permissions(self, permissions: List[Permission]) -> bool:
        return bool(permissions) and all(p in self.permissions for p in permissions)


def get_permissions(payload: JwtPayload, all_permissions: Type[Permission]) -> List[Permission]:
    """Parse permission strings into corresponding Enums"""
    permissions = []
    available_permissions_map = {r.value: r for r in all_permissions}
    for p_value in payload.permissions:
        if p := available_permissions_map.get(p_value):
            permissions.append(p)
        else:
            logger.warning(f"unrecognized: {p_value}")
    return permissions


def get_jwk(domain: str, token: str) -> Optional[Mapping[str, Any]]:
    """Get pubkey associated with Auth0 account that matches token's KID"""
    header = jwt.get_unverified_header(token)
    jwks = requests.get(f"https://{domain}/.well-known/jwks.json").json()
    return next((key for key in jwks["keys"] if key.get("kid") == header["kid"]), None)


oauth2_scheme = OAuth2AuthorizationCodeBearer(
    authorizationUrl=config.AUTH0_DOMAIN, tokenUrl=config.AUTH0_AUDIENCE,
)


# Question: does the value for `permissions` come from the route dependency? ex: Auth0Security(current_user, permissions=[Permission.THING_READ])
async def current_user(
    permissions: Optional[Sequence[Permission]], token: str = Depends(oauth2_scheme)
) -> Auth0User:
    rsa_key = get_jwk(config.AUTH0_DOMAIN, token)
    try:
        payload = jwt.decode(
            token,
            rsa_key,
            algorithms=[ALGORITHMS.RS256],
            audience=config.AUTH0_AUDIENCE,
            issuer=f"https://{config.AUTH0_DOMAIN}",
        )
    except Exception:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")

    jwt_payload = JwtPayload(**payload)
    user = Auth0User(
        token=token, payload=jwt_payload, permissions=get_permissions(jwt_payload, Permission)
    )

    if permissions and not user.has_permissions(permissions):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="insufficient permissions",
        )

    return user


class Auth0SecurityParam(Security):
    """Following pattern like fastapi.params.Security"""
    def __init__(
        self,
        dependency: Optional[Callable[..., Any]] = None,
        *,
        scopes: Optional[Sequence[str]] = None,
        permissions: Optional[Sequence[Permission]] = None,
        use_cache: bool = True,
    ):
        super().__init__(dependency=dependency, scopes=scopes, use_cache=use_cache)
        self.permissions = list(permissions or [])


def Auth0Security(
    dependency: Optional[Callable[..., Any]] = None,
    *,
    scopes: Optional[Sequence[str]] = None,
    permissions: Optional[Sequence[Permission]] = None,
    use_cache: bool = True,
) -> Any:
    """Following pattern like fastapi.param_functions.Security"""
    return Auth0SecurityParam(
        dependency=dependency, scopes=scopes, permissions=permissions, use_cache=use_cache
    )


hello = APIRouter()


class OutModel(BaseModel):
    hello: str


@hello.get("/", response_model=OutModel)
async def hello_world(
    *, user: Auth0User = Auth0Security(current_user, permissions=[Permission.THING_READ]),
):
    return {"hello": "world"}


router = APIRouter()
router.include_router(hello, prefix="/hello", tags=["hello"])

app = FastAPI()
app.include_router(router, prefix="/v1")

Description

Auth0’s core RBAC can include permissions in the JWT payload along with scopes. Given the documented ability to use Security(factory, scopes=["me"]) as deps for a route, I thought it may be possible to subclass Security to extend this functionality to verify the permissions field of a JWT’s payload. This doesn’t appear to work as expected.

Should this be possible? Or is there a different way that FastAPI recommends achieving this?

As commented in the minimal example: does the value for permissions come from the route dependency? ex: Auth0Security(current_user, permissions=[Permission.THING_READ])

Steps to reproduce

  • Open the browser and call the endpoint /v1/hello with a valid Authorization: Bearer <jwt> header that includes "permissions":["read:thing"] in the token’s payload
  • It returns 422: {"detail":[{"loc":["body"],"msg":"field required","type":"value_error.missing"}]}
  • But I expected it to return {"hello": "world"}.

Environment

  • OS: Linux
  • FastAPI Version: 0.61.1
  • Python version: 3.8.5

Additional context

  • I have been using Flask in production for years. I am trying-out FastAPI for the first time.

Issue Analytics

  • State:open
  • Created 3 years ago
  • Comments:5 (1 by maintainers)

github_iconTop GitHub Comments

1reaction
cahnacommented, Oct 21, 2020

I based my initial implementation on the Oauth2 Scopes example from the Advanced User Guide (referenced at the bottom of the JWT example). The documentation and code for fastapi.params.Security led me to believe that Security(...) can be used in the same way as Depends(...) (in fact, Security is a subclass of Depends). For reference, here is the example:

@app.get("/users/me/items/")
async def read_own_items(
    current_user: User = Security(get_current_active_user, scopes=["items"])
):
    return [{"item_id": "Foo", "owner": current_user.username}]

It was my hope that I can subclass Security to add my own JWT payload checks. Upon closer inspection, however, it seems like Security may be a single-purpose interface that cannot be extended. The fact that fastapi.dependencies.utils.get_sub_dependent() has conditional logic for isinstance(depends, params.Security) leads me to believe that this may be the case.

If Security is only meant to be used for authz based only on scopes, then I will definitely use your recommendation. It would be great, however, if the documented ability to nest Security dependencies for a dependency tree could be extended to other parts of a token’s payload. Is this possible without a change to framework code, @tiangolo?

1reaction
eseglemcommented, Oct 21, 2020

You aren’t calling Depends() on any function in your route, so the other code isn’t being used. Its expecting there to be data in the body of the request. And, at the moment you are saying user = Auth0Security(...) which does not return a user type, if it did get called.

Might be more like this:

from fastapi import Depends

def SecurityFactory(permissions):
    def check_perm(user: Auth0User = Depends(get_current_user)):
        if not set(user.permissions) & set(permissions):
            raise HTTPException(status_code=403)
        return user
    return check_perm

@hello.get("/", response_model=OutModel)
async def hello_world(
    *, user: Auth0User = Depends(SecurityFactory(permissions=[Permission.THING_READ])),
):
    return {"hello": "world"}

The JWT Example is really good. You shouldn’t have to do much different from it.

Read more comments on GitHub >

github_iconTop Results From Across the Web

OAuth2 scopes - FastAPI
OAuth2 with scopes is the mechanism used by many big authentication providers, ... try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) ...
Read more >
Securing FastAPI with JWT Token-based Authentication
In this tutorial, you'll learn how to secure a FastAPI app by enabling authentication using JSON Web Tokens (JWTs). We'll be using PyJWT...
Read more >
Securing a FastAPI route using JWT token (step-by-step)
By the end of this post, we'll build a small FastAPI server, send it a request and receive a response, and add authentication/authorization...
Read more >
tiangolo/fastapi - Gitter
FastAPI documentation in security uses parameter approach with Depends . ... unlike get_user """ return token.jwt_user() def get_user(db: Session ...
Read more >
FastAPI – Vector Tiles with Autorization - GIS • OPS
Learn how to build a vector tile server with built-in authorization using FastAPI and PostGIS.
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found