From 4d38f396bef7568b7e900af4d95cf50f633775ed Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 15 Jul 2023 18:47:59 +0400 Subject: [PATCH 01/29] Make `redirect_uri` override `base_url` if set --- src/fastapi_oauth2/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index e8676de..f8d8ccf 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -58,6 +58,7 @@ def __init__(self, client: OAuth2Client) -> None: self.client_secret = client.client_secret self.scope = client.scope or self.scope self.provider = client.backend.name + self.redirect_uri = client.redirect_uri self.backend = client.backend(OAuth2Strategy()) self.authorization_endpoint = client.backend.AUTHORIZATION_URL self.token_endpoint = client.backend.ACCESS_TOKEN_URL @@ -115,7 +116,7 @@ async def get_token_data(self, request: Request) -> Optional[Dict[str, Any]]: async def token_redirect(self, request: Request) -> RedirectResponse: token_data = await self.get_token_data(request) access_token = request.auth.jwt_create(token_data) - response = RedirectResponse(request.base_url) + response = RedirectResponse(self.redirect_uri or request.base_url) response.set_cookie( "Authorization", value=f"Bearer {access_token}", From 0b1ec580e46355c7ffc2d46b7dcfa39fc49a7fcb Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 15 Jul 2023 20:17:43 +0400 Subject: [PATCH 02/29] Create a `User` model and API endpoints for creating and getting --- examples/demonstration/database.py | 21 +++++++++++++++++++++ examples/demonstration/main.py | 4 ++++ examples/demonstration/models.py | 23 +++++++++++++++++++++++ examples/demonstration/router.py | 18 ++++++++++++++++-- 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 examples/demonstration/database.py create mode 100644 examples/demonstration/models.py diff --git a/examples/demonstration/database.py b/examples/demonstration/database.py new file mode 100644 index 0000000..a22915f --- /dev/null +++ b/examples/demonstration/database.py @@ -0,0 +1,21 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +engine = create_engine( + "sqlite:///./database.sqlite", + connect_args={ + "check_same_thread": False, + }, +) + +Base = declarative_base() +SessionLocal = sessionmaker(bind=engine, autoflush=False) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/examples/demonstration/main.py b/examples/demonstration/main.py index 1fd1291..d8648b8 100644 --- a/examples/demonstration/main.py +++ b/examples/demonstration/main.py @@ -2,10 +2,14 @@ from fastapi import FastAPI from config import oauth2_config +from database import Base +from database import engine from fastapi_oauth2.middleware import OAuth2Middleware from fastapi_oauth2.router import router as oauth2_router from router import router as app_router +Base.metadata.create_all(bind=engine) + router = APIRouter() app = FastAPI() diff --git a/examples/demonstration/models.py b/examples/demonstration/models.py new file mode 100644 index 0000000..911b22a --- /dev/null +++ b/examples/demonstration/models.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy.orm import Session + +from database import Base + + +class BaseModel(Base): + __abstract__ = True + + def save(self, db: Session): + db.add(self) + db.commit() + db.refresh(self) + return self + + +class User(BaseModel): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String) diff --git a/examples/demonstration/router.py b/examples/demonstration/router.py index fb8cf24..090e5e7 100644 --- a/examples/demonstration/router.py +++ b/examples/demonstration/router.py @@ -1,12 +1,15 @@ import json +from fastapi import APIRouter from fastapi import Depends from fastapi import Request -from fastapi import APIRouter from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session +from database import get_db from fastapi_oauth2.security import OAuth2 +from models import User oauth2 = OAuth2() router = APIRouter() @@ -19,5 +22,16 @@ async def root(request: Request): @router.get("/user") -def user(request: Request, _: str = Depends(oauth2)): +def user_get(request: Request, _: str = Depends(oauth2)): return request.user + + +@router.get("/users") +def users_get(request: Request, db: Session = Depends(get_db), _: str = Depends(oauth2)): + return db.query(User).all() + + +@router.post("/users") +async def users_post(request: Request, db: Session = Depends(get_db), _: str = Depends(oauth2)): + data = await request.json() + return User(**data).save(db) From cc35c8f7ce43be92e6db16ee314da239e2853342 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 17 Jul 2023 19:52:43 +0400 Subject: [PATCH 03/29] Add an auth simulation --- examples/demonstration/router.py | 22 +++++++++++++++++++++ examples/demonstration/templates/index.html | 9 ++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/examples/demonstration/router.py b/examples/demonstration/router.py index 090e5e7..7a7fa72 100644 --- a/examples/demonstration/router.py +++ b/examples/demonstration/router.py @@ -6,6 +6,7 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session +from starlette.responses import RedirectResponse from database import get_db from fastapi_oauth2.security import OAuth2 @@ -21,6 +22,27 @@ async def root(request: Request): return templates.TemplateResponse("index.html", {"request": request, "user": request.user, "json": json}) +@router.get("/auth") +def sim_auth(request: Request): + access_token = request.auth.jwt_create({ + "id": 0, + "avatar_url": None, + "name": "John Doe", + "email": "john.doe@auth.sim", + "login": "JohnDoe", + "exp": 3689609839, + }) + response = RedirectResponse("/") + response.set_cookie( + "Authorization", + value=f"Bearer {access_token}", + max_age=request.auth.expires, + expires=request.auth.expires, + httponly=request.auth.http, + ) + return response + + @router.get("/user") def user_get(request: Request, _: str = Depends(oauth2)): return request.user diff --git a/examples/demonstration/templates/index.html b/examples/demonstration/templates/index.html index c42adf1..1768e05 100644 --- a/examples/demonstration/templates/index.html +++ b/examples/demonstration/templates/index.html @@ -12,8 +12,15 @@
{% if request.user.is_authenticated %} Sign out - Pic + {% if request.user.avatar_url %} + Pic + {% else %} + Pic + {% endif %} {% else %} + + Simulate Login + From 7606da67826e551ccadfcb9eded140c6a680bc96 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 17 Jul 2023 23:40:05 +0400 Subject: [PATCH 04/29] Extend from `AuthCredentials` and `BaseUser` superclasses --- src/fastapi_oauth2/middleware.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index dc47017..f50e82e 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -9,7 +9,9 @@ from fastapi.security.utils import get_authorization_scheme_param from jose.jwt import decode as jwt_decode from jose.jwt import encode as jwt_encode +from starlette.authentication import AuthCredentials from starlette.authentication import AuthenticationBackend +from starlette.authentication import BaseUser from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request from starlette.types import ASGIApp @@ -22,7 +24,7 @@ from .core import OAuth2Core -class Auth: +class Auth(AuthCredentials): http: bool secret: str expires: int @@ -30,9 +32,6 @@ class Auth: scopes: List[str] clients: Dict[str, OAuth2Core] = {} - def __init__(self, scopes: Optional[List[str]] = None) -> None: - self.scopes = scopes or [] - @classmethod def set_http(cls, http: bool) -> None: cls.http = http @@ -67,13 +66,25 @@ def jwt_create(cls, token_data: dict) -> str: return cls.jwt_encode({**token_data, "exp": expire}) -class User(dict): - is_authenticated: bool - +class User(BaseUser, dict): def __init__(self, seq: Optional[dict] = None, **kwargs) -> None: - self.is_authenticated = seq is not None + self._is_authenticated = seq is not None + self._display_name = "" + self._identity = "" super().__init__(seq or {}, **kwargs) + @property + def is_authenticated(self) -> bool: + return self._is_authenticated + + @property + def display_name(self) -> str: + return self._display_name + + @property + def identity(self) -> str: + return self._identity + class OAuth2Backend(AuthenticationBackend): def __init__(self, config: OAuth2Config) -> None: From 124114072d38d67cc04c4069c9775f1ddc549c8b Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 17 Jul 2023 23:41:11 +0400 Subject: [PATCH 05/29] GH-9: Add `callback` argument to middleware --- src/fastapi_oauth2/middleware.py | 38 ++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index f50e82e..80c1c5f 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -1,5 +1,7 @@ from datetime import datetime from datetime import timedelta +from typing import Awaitable +from typing import Callable from typing import Dict from typing import List from typing import Optional @@ -87,13 +89,20 @@ def identity(self) -> str: class OAuth2Backend(AuthenticationBackend): - def __init__(self, config: OAuth2Config) -> None: + """Authentication backend for AuthenticationMiddleware.""" + + def __init__( + self, + config: OAuth2Config, + callback: Callable[[User], Union[Awaitable[None], None]] = None, + ) -> None: Auth.set_http(config.allow_http) Auth.set_secret(config.jwt_secret) Auth.set_expires(config.jwt_expires) Auth.set_algorithm(config.jwt_algorithm) for client in config.clients: Auth.register_client(client) + self.callback = callback async def authenticate(self, request: Request) -> Optional[Tuple["Auth", "User"]]: authorization = request.headers.get( @@ -106,18 +115,39 @@ async def authenticate(self, request: Request) -> Optional[Tuple["Auth", "User"] return Auth(), User() user = Auth.jwt_decode(param) - return Auth(user.pop("scope", [])), User(user) + auth, user = Auth(user.pop("scope", [])), User(user) + + # Call the callback function on authentication + if callable(self.callback): + coroutine = self.callback(user) + if issubclass(type(coroutine), Awaitable): + await coroutine + return auth, user class OAuth2Middleware: + """Wrapper for the Starlette AuthenticationMiddleware.""" + auth_middleware: AuthenticationMiddleware = None - def __init__(self, app: ASGIApp, config: Union[OAuth2Config, dict]) -> None: + def __init__( + self, + app: ASGIApp, + config: Union[OAuth2Config, dict], + callback: Callable[[User], Union[Awaitable[None], None]] = None, + **kwargs, # AuthenticationMiddleware kwargs + ) -> None: + """Initiates the middleware with the given configuration. + + :param app: FastAPI application instance + :param config: middleware configuration + :param callback: callback function to be called after authentication + """ if isinstance(config, dict): config = OAuth2Config(**config) elif not isinstance(config, OAuth2Config): raise TypeError("config is not a valid type") - self.auth_middleware = AuthenticationMiddleware(app, OAuth2Backend(config)) + self.auth_middleware = AuthenticationMiddleware(app, backend=OAuth2Backend(config, callback), **kwargs) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.auth_middleware(scope, receive, send) From 72b0a80852977f1df56538618fdfa6432de8c712 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Tue, 18 Jul 2023 22:08:15 +0400 Subject: [PATCH 06/29] GH-9: Finish up the implementation of `BaseUser` properties --- src/fastapi_oauth2/middleware.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index 80c1c5f..e97a98f 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -70,10 +70,10 @@ def jwt_create(cls, token_data: dict) -> str: class User(BaseUser, dict): def __init__(self, seq: Optional[dict] = None, **kwargs) -> None: - self._is_authenticated = seq is not None - self._display_name = "" - self._identity = "" super().__init__(seq or {}, **kwargs) + self._is_authenticated = seq is not None + self._identity = self.get("identity", "") + self._display_name = self.get("display_name", "") @property def is_authenticated(self) -> bool: From 74a702e74116f52d664f9b13d0e791c834409bb1 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Tue, 18 Jul 2023 22:27:27 +0400 Subject: [PATCH 07/29] GH-9: Implement a data standardizer for converting fields to the common interface --- src/fastapi_oauth2/core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index f8d8ccf..8ab6612 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -109,7 +109,7 @@ async def get_token_data(self, request: Request) -> Optional[Dict[str, Any]]: async with httpx.AsyncClient() as session: response = await session.post(token_url, headers=headers, content=content, auth=auth) token = self.oauth_client.parse_request_body_response(json.dumps(response.json())) - data = self.backend.user_data(token.get("access_token")) + data = self.standardize(self.backend.user_data(token.get("access_token"))) return {**data, "scope": self.scope} @@ -125,3 +125,8 @@ async def token_redirect(self, request: Request) -> RedirectResponse: httponly=request.auth.http, ) return response + + def standardize(self, data: Dict[str, Any]) -> Dict[str, Any]: + data["identity"] = "%s:%s" % (self.provider, data.get("id")) + data["display_name"] = data.get("name") + return data From 32839692989f8c48ce091368294a563c3f6fa2bf Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Tue, 18 Jul 2023 22:29:07 +0400 Subject: [PATCH 08/29] Add new user fields in the user model --- examples/demonstration/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/demonstration/models.py b/examples/demonstration/models.py index 911b22a..ed86f45 100644 --- a/examples/demonstration/models.py +++ b/examples/demonstration/models.py @@ -21,3 +21,7 @@ class User(BaseModel): id = Column(Integer, primary_key=True, index=True) username = Column(String) + email = Column(String) + name = Column(String) + image = Column(String) + identity = Column(String, unique=True) # provider_name:user_id From d38d51321103f71c0ca3fce585c0d8a822244a22 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Tue, 18 Jul 2023 22:30:10 +0400 Subject: [PATCH 09/29] Fix the field names in mocked user data --- examples/demonstration/router.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/demonstration/router.py b/examples/demonstration/router.py index 7a7fa72..8656b1a 100644 --- a/examples/demonstration/router.py +++ b/examples/demonstration/router.py @@ -25,11 +25,12 @@ async def root(request: Request): @router.get("/auth") def sim_auth(request: Request): access_token = request.auth.jwt_create({ - "id": 0, - "avatar_url": None, - "name": "John Doe", + "id": 1, + "identity": "demo:1", + "image": None, + "display_name": "John Doe", "email": "john.doe@auth.sim", - "login": "JohnDoe", + "username": "JohnDoe", "exp": 3689609839, }) response = RedirectResponse("/") From 2b2f0a5da80ba33faf2df52e59631a24afe2135a Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Tue, 18 Jul 2023 22:33:50 +0400 Subject: [PATCH 10/29] Showcase the usage of `callback` of the middleware Performing user onboarding - The callback sample checks for user's existence and creates if there is no such user with the given identity. --- examples/demonstration/main.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/examples/demonstration/main.py b/examples/demonstration/main.py index d8648b8..036605d 100644 --- a/examples/demonstration/main.py +++ b/examples/demonstration/main.py @@ -1,18 +1,38 @@ from fastapi import APIRouter from fastapi import FastAPI +from sqlalchemy.orm import Session from config import oauth2_config from database import Base from database import engine +from database import get_db from fastapi_oauth2.middleware import OAuth2Middleware +from fastapi_oauth2.middleware import User from fastapi_oauth2.router import router as oauth2_router +from models import User as UserModel from router import router as app_router Base.metadata.create_all(bind=engine) router = APIRouter() + +async def on_auth(user: User): + # perform a check for user existence in + # the database and create if not exists + db: Session = next(get_db()) + query = db.query(UserModel) + if not query.filter_by(identity=user.identity).first(): + UserModel(**{ + "identity": user.get("identity"), + "username": user.get("username"), + "image": user.get("image"), + "email": user.get("email"), + "name": user.get("name"), + }).save(db) + + app = FastAPI() app.include_router(app_router) app.include_router(oauth2_router) -app.add_middleware(OAuth2Middleware, config=oauth2_config) +app.add_middleware(OAuth2Middleware, config=oauth2_config, callback=on_auth) From e40321ecb4ffb62fcc24ddf66ce2b38232753d8f Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 20 Jul 2023 21:19:36 +0400 Subject: [PATCH 11/29] Add a TODO note for future --- src/fastapi_oauth2/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index 8ab6612..72bc73e 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -127,6 +127,9 @@ async def token_redirect(self, request: Request) -> RedirectResponse: return response def standardize(self, data: Dict[str, Any]) -> Dict[str, Any]: + # TODO: Create an issue for collecting all possible field names + # and finish up this method and create a unit-test for each. + # :https://github.com/python-social-auth/social-core/tree/master/social_core/backends data["identity"] = "%s:%s" % (self.provider, data.get("id")) data["display_name"] = data.get("name") return data From 3b2b883962ad9659b7088248e01913dc434f18a4 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 20 Jul 2023 21:20:39 +0400 Subject: [PATCH 12/29] Remove the existing feature from the list of 'to be' --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index ac8f71f..029bc88 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ the [social-core](https://github.com/python-social-auth/social-core) authenticat - Use multiple OAuth2 providers at the same time * There need to be provided a way to configure the OAuth2 for multiple providers -- Token -> user data, user data -> token easy conversion - Customizable OAuth2 routes - Registration support From d01395362ba5df55be50383c5a93a37d3cf4d733 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 20 Jul 2023 21:41:01 +0400 Subject: [PATCH 13/29] Add class description comments --- src/fastapi_oauth2/client.py | 2 ++ src/fastapi_oauth2/config.py | 2 ++ src/fastapi_oauth2/core.py | 6 +++--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/fastapi_oauth2/client.py b/src/fastapi_oauth2/client.py index b88e245..cef0d0a 100644 --- a/src/fastapi_oauth2/client.py +++ b/src/fastapi_oauth2/client.py @@ -6,6 +6,8 @@ class OAuth2Client: + """OAuth2 client configuration for a single provider.""" + backend: Type[BaseOAuth2] client_id: str client_secret: str diff --git a/src/fastapi_oauth2/config.py b/src/fastapi_oauth2/config.py index 707ad66..3befc2b 100644 --- a/src/fastapi_oauth2/config.py +++ b/src/fastapi_oauth2/config.py @@ -6,6 +6,8 @@ class OAuth2Config: + """Configuration class of the authentication middleware.""" + allow_http: bool jwt_secret: str jwt_expires: int diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index 72bc73e..a7244b4 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -20,12 +20,12 @@ class OAuth2LoginError(HTTPException): - """Raised when any login-related error occurs - (such as when user is not verified or if there was an attempt for fake login) - """ + """Raised when any login-related error occurs.""" class OAuth2Strategy(BaseStrategy): + """Dummy strategy for using the `BaseOAuth2.user_data` method.""" + def request_data(self, merge=True): return {} From 099ec02b78de8e46cb2d7279fa273ef503125a1b Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 20 Jul 2023 22:19:29 +0400 Subject: [PATCH 14/29] Add the missing typehints --- src/fastapi_oauth2/client.py | 2 +- src/fastapi_oauth2/config.py | 2 +- src/fastapi_oauth2/core.py | 10 +++++----- src/fastapi_oauth2/middleware.py | 2 +- src/fastapi_oauth2/security.py | 26 ++++++++++++++++---------- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/fastapi_oauth2/client.py b/src/fastapi_oauth2/client.py index cef0d0a..3b7521d 100644 --- a/src/fastapi_oauth2/client.py +++ b/src/fastapi_oauth2/client.py @@ -22,7 +22,7 @@ def __init__( client_secret: str, redirect_uri: Optional[str] = None, scope: Optional[Sequence[str]] = None, - ): + ) -> None: self.backend = backend self.client_id = client_id self.client_secret = client_secret diff --git a/src/fastapi_oauth2/config.py b/src/fastapi_oauth2/config.py index 3befc2b..8eb4b85 100644 --- a/src/fastapi_oauth2/config.py +++ b/src/fastapi_oauth2/config.py @@ -22,7 +22,7 @@ def __init__( jwt_expires: Union[int, str] = 900, jwt_algorithm: str = "HS256", clients: List[OAuth2Client] = None, - ): + ) -> None: if allow_http: os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" self.allow_http = allow_http diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index a7244b4..b128b58 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -26,17 +26,17 @@ class OAuth2LoginError(HTTPException): class OAuth2Strategy(BaseStrategy): """Dummy strategy for using the `BaseOAuth2.user_data` method.""" - def request_data(self, merge=True): + def request_data(self, merge=True) -> Dict[str, Any]: return {} - def absolute_uri(self, path=None): + def absolute_uri(self, path=None) -> str: return path - def get_setting(self, name): - return None + def get_setting(self, name) -> Any: + """Mocked setting method.""" @staticmethod - def get_json(url, method='GET', *args, **kwargs): + def get_json(url, method='GET', *args, **kwargs) -> httpx.Response: return httpx.request(method, url, *args, **kwargs) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index e97a98f..1e7dfa0 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -104,7 +104,7 @@ def __init__( Auth.register_client(client) self.callback = callback - async def authenticate(self, request: Request) -> Optional[Tuple["Auth", "User"]]: + async def authenticate(self, request: Request) -> Optional[Tuple[Auth, User]]: authorization = request.headers.get( "Authorization", request.cookies.get("Authorization"), diff --git a/src/fastapi_oauth2/security.py b/src/fastapi_oauth2/security.py index 4c6aeed..0ef8979 100644 --- a/src/fastapi_oauth2/security.py +++ b/src/fastapi_oauth2/security.py @@ -1,3 +1,7 @@ +from typing import Callable +from typing import Optional +from typing import Type + from fastapi.security import OAuth2 as FastAPIOAuth2 from fastapi.security import OAuth2AuthorizationCodeBearer as FastAPICodeBearer from fastapi.security import OAuth2PasswordBearer as FastAPIPasswordBearer @@ -5,9 +9,11 @@ from starlette.requests import Request -def use_cookie(cls: FastAPIOAuth2): - def _use_cookie(*args, **kwargs): - async def __call__(self, request: Request): +def use_cookies(cls: Type[FastAPIOAuth2]) -> Callable[[...], FastAPIOAuth2]: + """OAuth2 classes wrapped with this decorator will use cookies for the Authorization header.""" + + def _use_cookies(*args, **kwargs) -> FastAPIOAuth2: + async def __call__(self: FastAPIOAuth2, request: Request) -> Optional[str]: authorization = request.headers.get("Authorization", request.cookies.get("Authorization")) if authorization: request._headers = Headers({**request.headers, "Authorization": authorization}) @@ -16,19 +22,19 @@ async def __call__(self, request: Request): cls.__call__ = __call__ return cls(*args, **kwargs) - return _use_cookie + return _use_cookies -@use_cookie +@use_cookies class OAuth2(FastAPIOAuth2): - ... + """Wrapper class of the `fastapi.security.OAuth2` class.""" -@use_cookie +@use_cookies class OAuth2PasswordBearer(FastAPIPasswordBearer): - ... + """Wrapper class of the `fastapi.security.OAuth2PasswordBearer` class.""" -@use_cookie +@use_cookies class OAuth2AuthorizationCodeBearer(FastAPICodeBearer): - ... + """Wrapper class of the `fastapi.security.OAuth2AuthorizationCodeBearer` class.""" From 6d6c149a47a23f661bfe430c215dd17601a7973f Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 21 Jul 2023 09:38:30 +0400 Subject: [PATCH 15/29] Fix the typing for Python<3.10 versions --- src/fastapi_oauth2/security.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/fastapi_oauth2/security.py b/src/fastapi_oauth2/security.py index 0ef8979..0f5d3b3 100644 --- a/src/fastapi_oauth2/security.py +++ b/src/fastapi_oauth2/security.py @@ -1,5 +1,8 @@ +from typing import Any from typing import Callable +from typing import Dict from typing import Optional +from typing import Tuple from typing import Type from fastapi.security import OAuth2 as FastAPIOAuth2 @@ -9,7 +12,7 @@ from starlette.requests import Request -def use_cookies(cls: Type[FastAPIOAuth2]) -> Callable[[...], FastAPIOAuth2]: +def use_cookies(cls: Type[FastAPIOAuth2]) -> Callable[[Tuple[Any], Dict[str, Any]], FastAPIOAuth2]: """OAuth2 classes wrapped with this decorator will use cookies for the Authorization header.""" def _use_cookies(*args, **kwargs) -> FastAPIOAuth2: From 774512c0333e465d395a0b4e17a1610dbaa993f9 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 22 Jul 2023 22:22:29 +0400 Subject: [PATCH 16/29] Merge dependent methods --- src/fastapi_oauth2/core.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index b128b58..a532682 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -41,7 +41,7 @@ def get_json(url, method='GET', *args, **kwargs) -> httpx.Response: class OAuth2Core: - """Base class (mixin) for all SSO providers""" + """OAuth2 flow handler of a certain provider.""" client_id: str = None client_secret: str = None @@ -72,17 +72,14 @@ def oauth_client(self) -> WebApplicationClient: def get_redirect_uri(self, request: Request) -> str: return urljoin(str(request.base_url), "/oauth2/%s/token" % self.provider) - async def get_login_url(self, request: Request) -> Any: + async def login_redirect(self, request: Request) -> RedirectResponse: redirect_uri = self.get_redirect_uri(request) state = "".join([random.choice(string.ascii_letters) for _ in range(32)]) - return self.oauth_client.prepare_request_uri( + return RedirectResponse(str(self.oauth_client.prepare_request_uri( self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope - ) - - async def login_redirect(self, request: Request) -> RedirectResponse: - return RedirectResponse(await self.get_login_url(request), 303) + )), 303) - async def get_token_data(self, request: Request) -> Optional[Dict[str, Any]]: + async def token_redirect(self, request: Request) -> RedirectResponse: if not request.query_params.get("code"): raise OAuth2LoginError(400, "'code' parameter was not found in callback request") if not request.query_params.get("state"): @@ -111,11 +108,7 @@ async def get_token_data(self, request: Request) -> Optional[Dict[str, Any]]: token = self.oauth_client.parse_request_body_response(json.dumps(response.json())) data = self.standardize(self.backend.user_data(token.get("access_token"))) - return {**data, "scope": self.scope} - - async def token_redirect(self, request: Request) -> RedirectResponse: - token_data = await self.get_token_data(request) - access_token = request.auth.jwt_create(token_data) + access_token = request.auth.jwt_create({**data, "scope": self.scope}) response = RedirectResponse(self.redirect_uri or request.base_url) response.set_cookie( "Authorization", From fe95111962baa926d8b9f586d85008993c80e6d9 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 23 Jul 2023 21:36:49 +0400 Subject: [PATCH 17/29] GH-9: Add custom user properties and remove polymorph constructor --- src/fastapi_oauth2/middleware.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index 1e7dfa0..c5ea798 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -27,6 +27,8 @@ class Auth(AuthCredentials): + """Extended auth credentials schema based on Starlette AuthCredentials.""" + http: bool secret: str expires: int @@ -69,23 +71,27 @@ def jwt_create(cls, token_data: dict) -> str: class User(BaseUser, dict): - def __init__(self, seq: Optional[dict] = None, **kwargs) -> None: - super().__init__(seq or {}, **kwargs) - self._is_authenticated = seq is not None - self._identity = self.get("identity", "") - self._display_name = self.get("display_name", "") + """Extended user schema based on Starlette BaseUser.""" @property def is_authenticated(self) -> bool: - return self._is_authenticated + return bool(self) @property def display_name(self) -> str: - return self._display_name + return self.get("display_name", "") # name @property def identity(self) -> str: - return self._identity + return self.get("identity", "") # username + + @property + def picture(self) -> str: + return self.get("picture", "") # image + + @property + def email(self) -> str: + return self.get("email", "") # email class OAuth2Backend(AuthenticationBackend): From 367374ff36a751813dc095bbe998efcbd91e5c48 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 27 Jul 2023 23:55:12 +0400 Subject: [PATCH 18/29] GH-9: Implement the skeleton of claim declarator --- src/fastapi_oauth2/claims.py | 62 ++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/fastapi_oauth2/claims.py diff --git a/src/fastapi_oauth2/claims.py b/src/fastapi_oauth2/claims.py new file mode 100644 index 0000000..27d9229 --- /dev/null +++ b/src/fastapi_oauth2/claims.py @@ -0,0 +1,62 @@ +from typing import Any +from typing import Callable +from typing import Union + + +class Claims(dict): + """Claims configuration for a single provider.""" + + display_name: Union[str, Callable[[dict], Any]] + identity: Union[str, Callable[[dict], Any]] + picture: Union[str, Callable[[dict], Any]] + email: Union[str, Callable[[dict], Any]] + + def __init__(self, seq=None, **kwargs) -> None: + super().__init__(seq or {}, **kwargs) + self.display_name = self.get("display_name", "name") + self.identity = self.get("identity", "sub") + self.picture = self.get("picture", "picture") + self.email = self.get("email", "email") + + # @property + # def display_name(self) -> str: + # return self.get("display_name", "") + # + # @display_name.setter + # def display_name(self, value: Union[Any, Callable[[dict], Any]]) -> None: + # self["display_name"] = self._get_value(value) + # + # @property + # def identity(self) -> str: + # return self.get("identity", "") + # + # @identity.setter + # def identity(self, value: Union[str, Callable[[dict], Any]]) -> None: + # self["identity"] = self._get_value(value) + # + # @property + # def picture(self) -> str: + # return self.get("picture", "") + # + # @picture.setter + # def picture(self, value: Union[str, Callable[[dict], Any]]) -> None: + # self["picture"] = self._get_value(value) + # + # @property + # def email(self) -> str: + # return self.get("email", "") + # + # @email.setter + # def email(self, value: Union[str, Callable[[dict], Any]]) -> None: + # self["email"] = self._get_value(value) + + def __getattr__(self, item): + attr = super().get(item) + if callable(attr): + return attr(self) + return self.get(attr) + + # def _get_value(self, value: Union[str, Callable[[dict], Any]]) -> Any: + # if callable(value): + # return value(self) + # return self.get(value, "") From afcc936acabad566459c0e158e4082650b6c6e0c Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 27 Jul 2023 23:55:57 +0400 Subject: [PATCH 19/29] GH-9: Each client config can have its claim mappings --- src/fastapi_oauth2/client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/fastapi_oauth2/client.py b/src/fastapi_oauth2/client.py index 3b7521d..9db113c 100644 --- a/src/fastapi_oauth2/client.py +++ b/src/fastapi_oauth2/client.py @@ -1,9 +1,12 @@ from typing import Optional from typing import Sequence from typing import Type +from typing import Union from social_core.backends.oauth import BaseOAuth2 +from .claims import Claims + class OAuth2Client: """OAuth2 client configuration for a single provider.""" @@ -13,6 +16,7 @@ class OAuth2Client: client_secret: str redirect_uri: Optional[str] scope: Optional[Sequence[str]] + claims: Optional[Union[Claims, dict]] def __init__( self, @@ -22,9 +26,11 @@ def __init__( client_secret: str, redirect_uri: Optional[str] = None, scope: Optional[Sequence[str]] = None, + claims: Optional[Union[Claims, dict]] = None, ) -> None: self.backend = backend self.client_id = client_id self.client_secret = client_secret self.redirect_uri = redirect_uri self.scope = scope or [] + self.claims = Claims(claims) From 6cdfddccc42bdd82d81e5bbb65428241bc874b95 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 28 Jul 2023 18:36:02 +0400 Subject: [PATCH 20/29] GH-9: Fix init when `Claims` obtains `Claims` as a value --- src/fastapi_oauth2/claims.py | 51 +++--------------------------------- 1 file changed, 4 insertions(+), 47 deletions(-) diff --git a/src/fastapi_oauth2/claims.py b/src/fastapi_oauth2/claims.py index 27d9229..2f5ef68 100644 --- a/src/fastapi_oauth2/claims.py +++ b/src/fastapi_oauth2/claims.py @@ -13,50 +13,7 @@ class Claims(dict): def __init__(self, seq=None, **kwargs) -> None: super().__init__(seq or {}, **kwargs) - self.display_name = self.get("display_name", "name") - self.identity = self.get("identity", "sub") - self.picture = self.get("picture", "picture") - self.email = self.get("email", "email") - - # @property - # def display_name(self) -> str: - # return self.get("display_name", "") - # - # @display_name.setter - # def display_name(self, value: Union[Any, Callable[[dict], Any]]) -> None: - # self["display_name"] = self._get_value(value) - # - # @property - # def identity(self) -> str: - # return self.get("identity", "") - # - # @identity.setter - # def identity(self, value: Union[str, Callable[[dict], Any]]) -> None: - # self["identity"] = self._get_value(value) - # - # @property - # def picture(self) -> str: - # return self.get("picture", "") - # - # @picture.setter - # def picture(self, value: Union[str, Callable[[dict], Any]]) -> None: - # self["picture"] = self._get_value(value) - # - # @property - # def email(self) -> str: - # return self.get("email", "") - # - # @email.setter - # def email(self, value: Union[str, Callable[[dict], Any]]) -> None: - # self["email"] = self._get_value(value) - - def __getattr__(self, item): - attr = super().get(item) - if callable(attr): - return attr(self) - return self.get(attr) - - # def _get_value(self, value: Union[str, Callable[[dict], Any]]) -> Any: - # if callable(value): - # return value(self) - # return self.get(value, "") + self["display_name"] = kwargs.get("display_name", self.get("display_name", "name")) + self["identity"] = kwargs.get("identity", self.get("identity", "sub")) + self["picture"] = kwargs.get("picture", self.get("picture", "picture")) + self["email"] = kwargs.get("email", self.get("email", "email")) From 49115c92b05141e77622b0d66a5d735032efc4df Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 28 Jul 2023 18:37:03 +0400 Subject: [PATCH 21/29] GH-9: Add the `claims` attribute --- src/fastapi_oauth2/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index a532682..0ee2109 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -16,6 +16,7 @@ from starlette.requests import Request from starlette.responses import RedirectResponse +from .claims import Claims from .client import OAuth2Client @@ -47,6 +48,7 @@ class OAuth2Core: client_secret: str = None callback_url: Optional[str] = None scope: Optional[List[str]] = None + claims: Optional[Claims] = None backend: BaseOAuth2 = None _oauth_client: Optional[WebApplicationClient] = None @@ -56,7 +58,8 @@ class OAuth2Core: def __init__(self, client: OAuth2Client) -> None: self.client_id = client.client_id self.client_secret = client.client_secret - self.scope = client.scope or self.scope + self.scope = client.scope + self.claims = client.claims self.provider = client.backend.name self.redirect_uri = client.redirect_uri self.backend = client.backend(OAuth2Strategy()) From ead1086db014aea70a5924f28e3dfdeb4621e73a Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 28 Jul 2023 18:37:58 +0400 Subject: [PATCH 22/29] GH-9: Add `provider` in the user entity --- src/fastapi_oauth2/core.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index 0ee2109..a9e7291 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -109,9 +109,9 @@ async def token_redirect(self, request: Request) -> RedirectResponse: async with httpx.AsyncClient() as session: response = await session.post(token_url, headers=headers, content=content, auth=auth) token = self.oauth_client.parse_request_body_response(json.dumps(response.json())) - data = self.standardize(self.backend.user_data(token.get("access_token"))) + token_data = self.standardize(self.backend.user_data(token.get("access_token"))) + access_token = request.auth.jwt_create(token_data) - access_token = request.auth.jwt_create({**data, "scope": self.scope}) response = RedirectResponse(self.redirect_uri or request.base_url) response.set_cookie( "Authorization", @@ -123,9 +123,6 @@ async def token_redirect(self, request: Request) -> RedirectResponse: return response def standardize(self, data: Dict[str, Any]) -> Dict[str, Any]: - # TODO: Create an issue for collecting all possible field names - # and finish up this method and create a unit-test for each. - # :https://github.com/python-social-auth/social-core/tree/master/social_core/backends - data["identity"] = "%s:%s" % (self.provider, data.get("id")) - data["display_name"] = data.get("name") + data["provider"] = self.provider + data["scope"] = self.scope return data From 86ff69f183487c472a3c4a955814f4c26f943e1a Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 28 Jul 2023 18:39:38 +0400 Subject: [PATCH 23/29] GH-9: Handle claims mapping for certain provider --- src/fastapi_oauth2/middleware.py | 40 +++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index c5ea798..8291905 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -1,10 +1,12 @@ from datetime import datetime from datetime import timedelta +from typing import Any from typing import Awaitable from typing import Callable from typing import Dict from typing import List from typing import Optional +from typing import Sequence from typing import Tuple from typing import Union @@ -21,6 +23,7 @@ from starlette.types import Scope from starlette.types import Send +from .claims import Claims from .client import OAuth2Client from .config import OAuth2Config from .core import OAuth2Core @@ -36,6 +39,17 @@ class Auth(AuthCredentials): scopes: List[str] clients: Dict[str, OAuth2Core] = {} + provider: str + default_provider: str = "local" + + def __init__( + self, + scopes: Optional[Sequence[str]] = None, + provider: str = default_provider, + ) -> None: + super().__init__(scopes) + self.provider = provider + @classmethod def set_http(cls, http: bool) -> None: cls.http = http @@ -79,19 +93,29 @@ def is_authenticated(self) -> bool: @property def display_name(self) -> str: - return self.get("display_name", "") # name + return self.__getprop__("display_name") @property def identity(self) -> str: - return self.get("identity", "") # username + return self.__getprop__("identity") @property def picture(self) -> str: - return self.get("picture", "") # image + return self.__getprop__("picture") @property def email(self) -> str: - return self.get("email", "") # email + return self.__getprop__("email") + + def use_claims(self, claims: Claims) -> "User": + for attr, item in claims.items(): + self[attr] = self.__getprop__(item) + return self + + def __getprop__(self, item, default="") -> Any: + if callable(item): + return item(self) + return self.get(item, default) class OAuth2Backend(AuthenticationBackend): @@ -120,8 +144,12 @@ async def authenticate(self, request: Request) -> Optional[Tuple[Auth, User]]: if not scheme or not param: return Auth(), User() - user = Auth.jwt_decode(param) - auth, user = Auth(user.pop("scope", [])), User(user) + user = User(Auth.jwt_decode(param)) + user.update(provider=user.get("provider", Auth.default_provider)) + auth = Auth(user.pop("scope", []), user.get("provider")) + client = Auth.clients.get(auth.provider) + claims = client.claims if client else Claims() + user = user.use_claims(claims) # Call the callback function on authentication if callable(self.callback): From 6cc5b611247b0927c72ee833bc53697ab13933ce Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 28 Jul 2023 18:40:50 +0400 Subject: [PATCH 24/29] Showcase the usage of `claims` config --- examples/demonstration/config.py | 5 +++++ examples/demonstration/main.py | 2 +- examples/demonstration/templates/index.html | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/demonstration/config.py b/examples/demonstration/config.py index c63b136..935c2b1 100644 --- a/examples/demonstration/config.py +++ b/examples/demonstration/config.py @@ -3,6 +3,7 @@ from dotenv import load_dotenv from social_core.backends.github import GithubOAuth2 +from fastapi_oauth2.claims import Claims from fastapi_oauth2.client import OAuth2Client from fastapi_oauth2.config import OAuth2Config @@ -20,6 +21,10 @@ client_secret=os.getenv("OAUTH2_CLIENT_SECRET"), # redirect_uri="http://127.0.0.1:8000/", scope=["user:email"], + claims=Claims( + picture="avatar_url", + identity=lambda user: "%s:%s" % (user.get("provider"), user.get("id")), + ), ), ] ) diff --git a/examples/demonstration/main.py b/examples/demonstration/main.py index 036605d..a48e607 100644 --- a/examples/demonstration/main.py +++ b/examples/demonstration/main.py @@ -22,7 +22,7 @@ async def on_auth(user: User): # the database and create if not exists db: Session = next(get_db()) query = db.query(UserModel) - if not query.filter_by(identity=user.identity).first(): + if user.identity and not query.filter_by(identity=user.identity).first(): UserModel(**{ "identity": user.get("identity"), "username": user.get("username"), diff --git a/examples/demonstration/templates/index.html b/examples/demonstration/templates/index.html index 1768e05..9a8b81d 100644 --- a/examples/demonstration/templates/index.html +++ b/examples/demonstration/templates/index.html @@ -12,8 +12,8 @@
{% if request.user.is_authenticated %} Sign out - {% if request.user.avatar_url %} - Pic + {% if request.user.picture %} + Pic {% else %} Pic {% endif %} @@ -32,7 +32,7 @@
{% if request.user.is_authenticated %} -

Hi, {{ request.user.name }}

+

Hi, {{ request.user.display_name }}

This is what your JWT contains currently

{{ json.dumps(request.user, indent=4) }}
{% else %} From ca1cc3ce418d57d32150afd4cbd5f9cc2f8491b5 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 29 Jul 2023 17:22:01 +0400 Subject: [PATCH 25/29] GH-9: Add `Auth` to on-auth callback --- examples/demonstration/main.py | 3 ++- src/fastapi_oauth2/middleware.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/demonstration/main.py b/examples/demonstration/main.py index a48e607..e657bf1 100644 --- a/examples/demonstration/main.py +++ b/examples/demonstration/main.py @@ -6,6 +6,7 @@ from database import Base from database import engine from database import get_db +from fastapi_oauth2.middleware import Auth from fastapi_oauth2.middleware import OAuth2Middleware from fastapi_oauth2.middleware import User from fastapi_oauth2.router import router as oauth2_router @@ -17,7 +18,7 @@ router = APIRouter() -async def on_auth(user: User): +async def on_auth(auth: Auth, user: User): # perform a check for user existence in # the database and create if not exists db: Session = next(get_db()) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index 8291905..c921f7b 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -124,7 +124,7 @@ class OAuth2Backend(AuthenticationBackend): def __init__( self, config: OAuth2Config, - callback: Callable[[User], Union[Awaitable[None], None]] = None, + callback: Callable[[Auth, User], Union[Awaitable[None], None]] = None, ) -> None: Auth.set_http(config.allow_http) Auth.set_secret(config.jwt_secret) @@ -153,7 +153,7 @@ async def authenticate(self, request: Request) -> Optional[Tuple[Auth, User]]: # Call the callback function on authentication if callable(self.callback): - coroutine = self.callback(user) + coroutine = self.callback(auth, user) if issubclass(type(coroutine), Awaitable): await coroutine return auth, user @@ -168,7 +168,7 @@ def __init__( self, app: ASGIApp, config: Union[OAuth2Config, dict], - callback: Callable[[User], Union[Awaitable[None], None]] = None, + callback: Callable[[Auth, User], Union[Awaitable[None], None]] = None, **kwargs, # AuthenticationMiddleware kwargs ) -> None: """Initiates the middleware with the given configuration. From 6d60c55d83f75dd965eb1d4380268c1d0e8702f1 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 29 Jul 2023 17:23:47 +0400 Subject: [PATCH 26/29] GH-9: Describe `claims` and add a usage example --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 029bc88..9cdbbc9 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,14 @@ middleware configuration is declared with the `OAuth2Config` and `OAuth2Client` - `client_secret` - The OAuth2 client secret for the particular provider. - `redirect_uri` - The OAuth2 redirect URI to redirect to after success. Defaults to the base URL. - `scope` - The OAuth2 scope for the particular provider. Defaults to `[]`. +- `claims` - Claims mapping for the certain provider. It is also important to mention that for the configured clients of the auth providers, the authorization URLs are accessible by the `/oauth2/{provider}/auth` path where the `provider` variable represents the exact value of the auth provider backend `name` attribute. ```python +from fastapi_oauth2.claims import Claims from fastapi_oauth2.client import OAuth2Client from fastapi_oauth2.config import OAuth2Config from social_core.backends.github import GithubOAuth2 @@ -64,6 +66,10 @@ oauth2_config = OAuth2Config( client_secret=os.getenv("OAUTH2_CLIENT_SECRET"), redirect_uri="https://pysnippet.org/", scope=["user:email"], + claims=Claims( + picture="avatar_url", + identity=lambda user: "%s:%s" % (user.get("provider"), user.get("id")), + ), ), ] ) From 920d78d810e04856b0bd2f322bb894dd360a4e7a Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 30 Jul 2023 20:39:54 +0400 Subject: [PATCH 27/29] GH-9: Remove 'Registration support' from to be list --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 9cdbbc9..490cbe2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ the [social-core](https://github.com/python-social-auth/social-core) authenticat - Use multiple OAuth2 providers at the same time * There need to be provided a way to configure the OAuth2 for multiple providers - Customizable OAuth2 routes -- Registration support ## Installation From c5b6329a367caafc82773b10bb912d2cd5a38e8c Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 30 Jul 2023 20:40:56 +0400 Subject: [PATCH 28/29] Upgrade the `fastapi` version for tox environment --- .github/workflows/tests.yml | 8 ++++---- tox.ini | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 817762a..de2453a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,13 +32,13 @@ jobs: env: py311-fastapi84 - python: "3.7" - env: py37-fastapi99 + env: py37-fastapi100 - python: "3.9" - env: py39-fastapi99 + env: py39-fastapi100 - python: "3.10" - env: py310-fastapi99 + env: py310-fastapi100 - python: "3.11" - env: py311-fastapi99 + env: py311-fastapi100 steps: - uses: actions/checkout@v2 diff --git a/tox.ini b/tox.ini index 9ec924f..7edde4a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,11 @@ [tox] envlist = py{36,38,310,311}-fastapi68 - py{37,39,310,311}-fastapi{84,99} + py{37,39,310,311}-fastapi{84,100} [testenv] deps = - fastapi99: fastapi>=0.99.0 + fastapi100: fastapi>=0.100.0 fastapi84: fastapi<=0.84.0 fastapi68: fastapi<=0.68.1 -r{toxinidir}/tests/requirements.txt From b876221e567b5befaf9da417f09e434e6f3a1845 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 30 Jul 2023 20:41:40 +0400 Subject: [PATCH 29/29] Upgrade the version to `alpha.1` --- src/fastapi_oauth2/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastapi_oauth2/__init__.py b/src/fastapi_oauth2/__init__.py index 5f1e750..a390618 100644 --- a/src/fastapi_oauth2/__init__.py +++ b/src/fastapi_oauth2/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0-alpha" +__version__ = "1.0.0-alpha.1"