aliev / aioauth

Asynchronous OAuth 2.0 provider for Python 3
https://aliev.me/aioauth
MIT License
214 stars 19 forks source link

Passing extra query parameters to the request #63

Closed pedropregueiro closed 2 years ago

pedropregueiro commented 2 years ago

What's the best way to pass custom query parameters to the Storage methods? For example, slack supports sending a team= query parameter for narrowing down oauth2 flows. I'd love to do something similar (with param 'channel'), but getting errors when trying to pass any extra query params:

...     query=Query(**query_params),
TypeError: __init__() got an unexpected keyword argument 'channel'

I understand the Query dataclass doesn't have support for it today, but is there any other good way to handle this? Or am I overcomplicating things?

p.s. – Thanks for the great work on this lib btw! 🙏

aliev commented 2 years ago

@pedropregueiro you can inherit from Query and add your own fields, like this:


from aioauth.requests import Post, Query as _Query, Request
from dataclasses import dataclass

@dataclass
class Query(_Query):
    team: str | None = None

async def to_oauth2_request(
    request: Request, settings: Settings = Settings()
) -> OAuth2Request:
    """Converts :py:class:`fastapi.Request` instance to :py:class:`aioauth.requests.Request` instance"""
    form = await request.form()

    post = dict(form)
    query_params = dict(request.query_params)
    method = request.method
    headers = HTTPHeaderDict(**request.headers)
    url = str(request.url)

    user = None

    if request.user.is_authenticated:
        user = request.user

    return OAuth2Request(
        settings=settings,
        method=RequestMethod[method],
        headers=headers,
        post=Post(**post),
        query=Query(**query_params),
        url=url,
        user=user,
    )
pedropregueiro commented 2 years ago

Perfect, this works!

pedropregueiro commented 2 years ago

@aliev using custom dataclasses is creating some type validation issues (using mypy==0.931).

Here's an example with custom Post and Request classes:

from dataclasses import dataclass
from typing import Optional

from aioauth.models import AuthorizationCode
from aioauth.requests import Post as _Post
from aioauth.requests import Request as _Request
from aioauth.storage import BaseStorage
from aioauth.types import RequestMethod

@dataclass
class CustomPost(_Post):
    my_field: str = ""

@dataclass
class CustomRequest(_Request):
    post: CustomPost = CustomPost()

class Storage(BaseStorage):
    async def create_authorization_code(
        self,
        request: CustomRequest,
        client_id: str,
        scope: str,
        response_type: str,
        redirect_uri: str,
        code_challenge_method: Optional[str],
        code_challenge: Optional[str],
        code: str,
    ) -> AuthorizationCode:
        pass

oauth2_request = CustomRequest(
    method=RequestMethod.POST,
    post=CustomPost(**{"grant_type": "authorization_code", "my_field": "test"}),
)

When running mypy I get the following errors:

test.py:22: error: Argument 1 of "create_authorization_code" is incompatible with supertype "BaseStorage"; supertype defines the argument type as "Request"
test.py:22: note: This violates the Liskov substitution principle
test.py:22: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides
test.py:38: error: Argument 1 to "CustomPost" has incompatible type "**Dict[str, str]"; expected "Optional[GrantType]"
Found 2 errors in 1 file (checked 1 source file)

Tbh, I haven't used dataclasses that often so not sure what's the best way to handle type inheritance.

aliev commented 2 years ago

hmm that seems to be expected. I think I can find possible solutions, give me some time.

aliev commented 2 years ago

@pedropregueiro Created PR. not sure if it won't break anything further. needs to be tested

pedropregueiro commented 2 years ago

thanks @aliev! that solves the error with the Storage methods, but unfortunately not the custom Query or Post one:

test.py:38: error: Argument 1 to "CustomPost" has incompatible type "**Dict[str, str]"; expected "Optional[GrantType]"

I tried to fix this locally with the same idea you applied (using generic types) but bumped into an issue when trying to do:

@dataclass
class Request(Generic[TQuery, TPost]):
  ...
  query: TQuery = Query()
  post: TPost = Post()
  ...

mypy didn't like having a different type as the default value. thoughts?

aliev commented 2 years ago

well, something like this should work:

TQuery = TypeVar("TQuery", bound=Query)
TPost = TypeVar("TPost", bound=Post)
TUser = TypeVar("TUser")

@dataclass
class BaseRequest(Generic[TQuery, TPost, TUser]):
    method: RequestMethod
    query: TQuery
    post: TPost
    headers: HTTPHeaderDict = HTTPHeaderDict()
    url: str = ""
    user: Optional[TUser] = None
    settings: Settings = Settings()

@dataclass
class Request(BaseRequest[Query, Post, Any]):
    """Object that contains a client's complete request."""

    headers: HTTPHeaderDict = HTTPHeaderDict()
    query: Query = Query()
    post: Post = Post()
    url: str = ""
    user: Optional[Any] = None
    settings: Settings = Settings()
    method: RequestMethod

thus, to make a custom Request, you will have to inherit from BaseRequest:

class MyRequest(BaseRequest[MyQuery, MyPost, MyUser]):
    ...

I'm checking now.

aliev commented 2 years ago

@pedropregueiro could you check what i just pushed?

pedropregueiro commented 2 years ago

sorry for the delay, this seems to be working as expected now, mypy is happy!

I had to change the CustomPost initialization, but that seems to be a diff issue. here's the latest working sample code for reference:

from dataclasses import dataclass
from typing import Any, Optional

from aioauth.requests import Query, Post, BaseRequest
from aioauth.types import RequestMethod
from starlette.datastructures import ImmutableMultiDict

@dataclass
class MyPost(Post):
    channel: Optional[str] = None

@dataclass
class MyRequest(BaseRequest[Query, MyPost, Any]):
    post: MyPost = MyPost()
    query: Query = Query()

test_form_dict = ImmutableMultiDict(
    {"grant_type": "authorization_code", "channel": "123"}
)
post: MyPost = MyPost(**test_form_dict)

oauth2_request = MyRequest(
    method=RequestMethod.POST,
    post=post,
)

print("request", oauth2_request)
print("channel", oauth2_request.post.channel)

result

request MyRequest(method=<RequestMethod.POST: 'POST'>, query=Query(client_id=None, redirect_uri='', response_type=None, state='', scope='', nonce=None, code_challenge_method=None, code_challenge=None, response_mode=None), post=MyPost(grant_type='authorization_code', client_id=None, client_secret=None, redirect_uri=None, scope='', username=None, password=None, refresh_token=None, code=None, token=None, token_type_hint=None, code_verifier=None, channel='123'), headers={}, url='', user=None, settings=Settings(TOKEN_EXPIRES_IN=86400, REFRESH_TOKEN_EXPIRES_IN=172800, AUTHORIZATION_CODE_EXPIRES_IN=300, INSECURE_TRANSPORT=False, ERROR_URI='', AVAILABLE=True))
channel 123

I'm using the ImmutableMultiDict because that's the type I'll get from FastAPI requests form data. That said, if I try to instantiate the MyPost() just using a normal dictionary, mypy will keep failing with the error I described before: error: Argument 1 to "MyPost" has incompatible type "**Dict[str, str]"; expected "Optional[GrantType]"

aliev commented 2 years ago

try like this?


from aioauth.types import GrantType

test_form_dict = ImmutableMultiDict(
    {"grant_type": GrantType.TYPE_AUTHORIZATION_CODE, "channel": "123"}
)
pedropregueiro commented 2 years ago

yeah, of course, but I meant when parsing a framework's Request (eg: FastAPI) into aioauth's structure. I was using the to_oauth2_request method from your fastapi-extension as inspiration, but that's where the type issues were coming from:

async def to_oauth2_request(
    request: Request, settings: Settings = Settings()
) -> MyRequest:
  form = await request.form()  # type: ImmutableMultiDict
  post = dict(form)  # type: dict
  # ... some more code hidden
  return MyRequest(
    post=MyPost(**post),  # this gives the incompatible type error
  )

A simple change, fixed this for me:

async def to_oauth2_request(
    request: Request, settings: Settings = Settings()
) -> MyRequest:
  form = await request.form()
  post: MyPost = MyPost(**form)
  # ... some more code hidden
  return MyRequest(
    post=post,  # ok types
  )

Anyway, this feels unrelated now. Thanks for fixing the custom types!

aliev commented 2 years ago

maybe we should use Literals instead of enums. I'll take a look later

aliev commented 2 years ago

I can add these changes to the current PR, but I'm not sure how soon I can merge it, since the documentation needs to be edited, a lot has changed.

pedropregueiro commented 2 years ago

Can maybe open a diff issue for the literal work, if that's what you think will delay a release?

The way I have it now works well and is valid with mypy, not in a rush to change the enums.

aliev commented 2 years ago

@pedropregueiro I think that I will make a release on the weekend, as this requires updating the documentation as well. give me some time

pedropregueiro commented 2 years ago

Of course, no problems!

aliev commented 2 years ago

@pedropregueiro v1.5.0 has been released, including the fixes with literals (enums)

pedropregueiro commented 2 years ago

With the latest changes, I have to either maintain my own enums or have some hardcoded strings in the code:

if request.post.grant_type == "authorization_code":

Rather than what was possible to do before:

if request.post.grant_type == GrantType.TYPE_AUTHORIZATION_CODE:

Is there a better way to do this?

aliev commented 2 years ago

I see several issues with Enum that I come up in this project:

1) they must be explicitly converted to a string, otherwise mypy will complain

2) duplicates in the code. how does GrantType.TYPE_AUTHORIZATION_CODE actually differ from the string literal "authorization_code"? Actually, there is no difference. It only adds complication in the code and unnecessary imports.

string literals are typed, so the mypy will complain to the following code:

from enum import Enum
from typing import Literal

GrantType = Literal["authorization_code"]

def check_grant_type(grant_type: GrantType):
    ...

check_grant_type("refresh_token")
test_enum.py:12: error: Argument 1 to "check_grant_type" has incompatible type "Literal['refresh_token']"; expected "Literal['authorization_code']"

replacing check_grant_type("refresh_token") with check_grant_type("authorization_code") will fix the error.

so string literals are very useful, they add typing, and at the same time you can enjoy all the benefits of strings without additional type casting.

in your case, I think the first option is quite appropriate.

pedropregueiro commented 2 years ago

duplicates in the code. how does GrantType.TYPE_AUTHORIZATION_CODE actually differ from the string literal "authorization_code"? Actually, there is no difference. It only adds complication in the code and unnecessary imports.

I disagree with this. It differs because explicitly having to write the string (e.g.) "authorization_code" might generate odd errors on the developer end. Someone might write "authorisation_code" or a myriad of other typos. Even further, someone might confuse grant_type with reponse_type and just use the word "code" wrongfully.

In both cases, unless the dev is using a type checker (and continuously re-checking their code on each change), the errors will go unnoticed as the interpreter won't raise them on start. This will lead to unnecessary time trying to figure out why things are failing down the line. Especially with OAuth2, where errors tend to be slightly generalized for obfuscation purposes.

aliev commented 2 years ago

Someone might write "authorisation_code" or a myriad of other typos. Even further, someone might confuse grant_type with reponse_type and just use the word "code" wrongfully.

Literal won't let you do that, mypy will complain if the passed string doesn't match what it expects:

(.venv) ➜  ~ cat test_enum.py 
from enum import Enum
from typing import Literal

GrantType = Literal["authorization_code"]

def check_grant_type(grant_type: GrantType):
    ...

check_grant_type("authorisation_code")
(.venv) ➜  ~ mypy test_enum.py 
test_enum.py:12: error: Argument 1 to "check_grant_type" has incompatible type "Literal['authorisation_code']"; expected "Literal['authorization_code']"
Found 1 error in 1 file (checked 1 source file)
(.venv) ➜  ~ 

In both cases, unless the dev is using a type checker (and continuously re-checking their code on each change), the errors will go unnoticed as the interpreter won't raise them on start. This will lead to unnecessary time trying to figure out why things are failing down the line. Especially with OAuth2, where errors tend to be slightly generalized for obfuscation purposes.

that's why we have to rely on CI with mypy and tests :) aioauth using pre-commit, before I commit changes, it checks for typing issues.

pedropregueiro commented 2 years ago

mypy will complain if the passed string doesn't match what it expects

Indeed, but if you take the same example and do python test_enum.py you won't get any errors calling check_grant_type("authorisation_code")

that's why we have to rely on CI with mypy and tests :)

100% agree with having mypy and tests (+more) on CI, and I also use pre-commit locally as an additional check as you do on aioauth. That said, I think it's risky to assume that all devs using the library will be using mypy or any form of type checking. Still early days for typed Python after all :)

In any case, I made the changes to make this work for me, so mostly discussing this for the future of the library and upcoming users hehe