From 59b013ce68ba8a4094d037ef62fd838e7af88077 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Tue, 29 Aug 2023 21:30:28 +0400 Subject: [PATCH 01/28] GH-18: Pass all URL params to `oauthlib` including PKCE ones --- src/fastapi_oauth2/core.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index 9dd3ae6..d6a9901 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -80,7 +80,11 @@ 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 RedirectResponse(str(self._oauth_client.prepare_request_uri( - self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope + self.authorization_endpoint, + state=state, + scope=self.scope, + **request.query_params, + redirect_uri=redirect_uri, )), 303) async def token_redirect(self, request: Request) -> RedirectResponse: @@ -89,17 +93,15 @@ async def token_redirect(self, request: Request) -> RedirectResponse: if not request.query_params.get("state"): raise OAuth2LoginError(400, "'state' parameter was not found in callback request") - url = request.url - scheme = "http" if request.auth.http else "https" - current_url = re.sub(r"^https?", scheme, str(url)) redirect_uri = self.get_redirect_uri(request) + scheme = "http" if request.auth.http else "https" + authorization_response = re.sub(r"^https?", scheme, str(request.url)) token_url, headers, content = self._oauth_client.prepare_token_request( self.token_endpoint, + **request.query_params, redirect_url=redirect_uri, - authorization_response=current_url, - code=request.query_params.get("code"), - state=request.query_params.get("state"), + authorization_response=authorization_response, ) headers.update({ From 50d0ccd776d6845018f1447c4a0d54dee582756f Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Tue, 29 Aug 2023 21:56:51 +0400 Subject: [PATCH 02/28] Create a showcase page for users list --- examples/demonstration/templates/base.html | 55 ++++++++++++++++++ examples/demonstration/templates/index.html | 62 +++------------------ examples/demonstration/templates/users.html | 8 +++ 3 files changed, 70 insertions(+), 55 deletions(-) create mode 100644 examples/demonstration/templates/base.html create mode 100644 examples/demonstration/templates/users.html diff --git a/examples/demonstration/templates/base.html b/examples/demonstration/templates/base.html new file mode 100644 index 0000000..6754f5f --- /dev/null +++ b/examples/demonstration/templates/base.html @@ -0,0 +1,55 @@ + + + + + + + OAuth2 Demonstration + + +
+
+ {% if request.user.is_authenticated %} + Sign out + {% if request.user.picture %} + Pic + {% else %} + Pic + {% endif %} + {% else %} + + Simulate Login + + {% for provider in request.auth.clients %} + + {{ provider }} icon + + {% endfor %} + {% endif %} +
+
+
+ {% if request.user.is_authenticated %} +

Hi, {{ request.user.display_name }}

+

+ You're signed in using + {% if request.auth.provider %} + external '{{ request.auth.provider.provider }}' OAuth2 provider. + {% else %} + local authentication system. + {% endif %} +

+ {% block content %}{% endblock %} + {% else %} +

You are not authenticated

+

You should sign in using one of the OAuth options

+ {% endif %} +
+ + \ No newline at end of file diff --git a/examples/demonstration/templates/index.html b/examples/demonstration/templates/index.html index caea8e5..092beae 100644 --- a/examples/demonstration/templates/index.html +++ b/examples/demonstration/templates/index.html @@ -1,56 +1,8 @@ - - - - - - - OAuth2 Demo - - -
-
- {% if request.user.is_authenticated %} - Sign out - {% if request.user.picture %} - Pic - {% else %} - Pic - {% endif %} - {% else %} - - Simulate Login - - {% for provider in request.auth.clients %} - - {{ provider }} icon - - {% endfor %} - {% endif %} +{% extends "base.html" %} + +{% block content %} +

This is what the JWT contains currently. See all users from database.

+
+
{{ json.dumps(request.user, indent=4) }}
-
-
- {% if request.user.is_authenticated %} -

Hi, {{ request.user.display_name }}

-

- You're signed in using - {% if request.auth.provider %} - external {{ request.auth.provider.provider }} OAuth2 provider. - {% else %} - local authentication system. - {% endif %} -

-

This is what your JWT contains currently

-
{{ json.dumps(request.user, indent=4) }}
- {% else %} -

You are not authenticated

-

You should sign in by clicking the GitHub's icon

- {% endif %} -
- - \ No newline at end of file +{% endblock %} \ No newline at end of file diff --git a/examples/demonstration/templates/users.html b/examples/demonstration/templates/users.html new file mode 100644 index 0000000..54aa804 --- /dev/null +++ b/examples/demonstration/templates/users.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block content %} +

This is the current list of all users. See JWT content.

+
+
{{ json.dumps(users, indent=4) }}
+
+{% endblock %} \ No newline at end of file From 1fab83dcf55e3d8d9527f0959c62c37e4e1a447c Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Tue, 29 Aug 2023 21:57:12 +0400 Subject: [PATCH 03/28] Segregate the routers into two types --- examples/demonstration/main.py | 6 ++- examples/demonstration/router.py | 60 ---------------------------- examples/demonstration/router_api.py | 32 +++++++++++++++ examples/demonstration/router_ssr.py | 35 ++++++++++++++++ 4 files changed, 71 insertions(+), 62 deletions(-) delete mode 100644 examples/demonstration/router.py create mode 100644 examples/demonstration/router_api.py create mode 100644 examples/demonstration/router_ssr.py diff --git a/examples/demonstration/main.py b/examples/demonstration/main.py index 4b78238..19071b1 100644 --- a/examples/demonstration/main.py +++ b/examples/demonstration/main.py @@ -12,7 +12,8 @@ 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 +from router_api import router_api +from router_ssr import router_ssr Base.metadata.create_all(bind=engine) @@ -36,7 +37,8 @@ async def on_auth(auth: Auth, user: User): app = FastAPI() -app.include_router(app_router) +app.include_router(router_api) +app.include_router(router_ssr) app.include_router(oauth2_router) app.mount("/static", StaticFiles(directory="static"), name="static") app.add_middleware(OAuth2Middleware, config=oauth2_config, callback=on_auth) diff --git a/examples/demonstration/router.py b/examples/demonstration/router.py deleted file mode 100644 index 8656b1a..0000000 --- a/examples/demonstration/router.py +++ /dev/null @@ -1,60 +0,0 @@ -import json - -from fastapi import APIRouter -from fastapi import Depends -from fastapi import Request -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 -from models import User - -oauth2 = OAuth2() -router = APIRouter() -templates = Jinja2Templates(directory="templates") - - -@router.get("/", response_class=HTMLResponse) -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": 1, - "identity": "demo:1", - "image": None, - "display_name": "John Doe", - "email": "john.doe@auth.sim", - "username": "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 - - -@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) diff --git a/examples/demonstration/router_api.py b/examples/demonstration/router_api.py new file mode 100644 index 0000000..984382e --- /dev/null +++ b/examples/demonstration/router_api.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter +from fastapi import Request +from fastapi.templating import Jinja2Templates +from starlette.responses import RedirectResponse + +from fastapi_oauth2.security import OAuth2 + +oauth2 = OAuth2() +router_api = APIRouter() +templates = Jinja2Templates(directory="templates") + + +@router_api.get("/auth") +def sim_auth(request: Request): + access_token = request.auth.jwt_create({ + "id": 1, + "identity": "demo:1", + "image": None, + "display_name": "John Doe", + "email": "john.doe@auth.sim", + "username": "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 diff --git a/examples/demonstration/router_ssr.py b/examples/demonstration/router_ssr.py new file mode 100644 index 0000000..cf33188 --- /dev/null +++ b/examples/demonstration/router_ssr.py @@ -0,0 +1,35 @@ +import json + +from fastapi import APIRouter +from fastapi import Depends +from fastapi import Request +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_ssr = APIRouter() +templates = Jinja2Templates(directory="templates") + + +@router_ssr.get("/", response_class=HTMLResponse) +async def root(request: Request): + return templates.TemplateResponse("index.html", { + "json": json, + "request": request, + }) + + +@router_ssr.get("/users", response_class=HTMLResponse) +async def users(request: Request, db: Session = Depends(get_db), _: str = Depends(oauth2)): + return templates.TemplateResponse("users.html", { + "json": json, + "request": request, + "users": [ + dict([(k, v) for k, v in user.__dict__.items() if not k.startswith("_")]) for user in db.query(User).all() + ], + }) From fd5f19e8f432d708d23bba82426558a3928b3e8c Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Wed, 30 Aug 2023 21:58:55 +0400 Subject: [PATCH 04/28] Change the README description of the demo app --- examples/demonstration/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/demonstration/README.md b/examples/demonstration/README.md index 6c75893..ee263e8 100644 --- a/examples/demonstration/README.md +++ b/examples/demonstration/README.md @@ -1,7 +1,9 @@ ## Demonstration -This sample application is made to demonstrate the use of -the [**fastapi-oauth2**](https://github.com/pysnippet/fastapi-oauth2) package. +This sample application demonstrates the use of the [**fastapi-oauth2**](https://github.com/pysnippet/fastapi-oauth2) +package and covers many topics from the [documentation](https://docs.pysnippet.org/fastapi-oauth2/). It is mainly +designed to help developers integrate and configure the package in their own applications. However, it can also be used +as a template for a new application or testing purposes. ## Installation From 517d6774f2b5ac256e1bd4144a9eabea6151f449 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Wed, 30 Aug 2023 21:59:48 +0400 Subject: [PATCH 05/28] Implement a sample IDP for CSRF and PKCE tests --- main.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ validator.py | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 main.py create mode 100644 validator.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..0603c6f --- /dev/null +++ b/main.py @@ -0,0 +1,66 @@ +import urllib.parse + +from flask import Flask, request, jsonify, url_for, redirect +from oauthlib.oauth2 import Server + +from validator import MyRequestValidator + +app = Flask(__name__) + +oauth2_server = Server(MyRequestValidator()) + + +@app.route('/auth', methods=['GET', 'POST']) +def auth(): + if request.method == 'GET': + try: + # Validate the client request for authorization + uri = request.url + http_method = request.method + headers = request.headers + body = request.get_data() + + scopes, credentials = oauth2_server.validate_authorization_request(uri, http_method, body, headers) + del credentials['request'] + action = url_for('auth') + "?" + urllib.parse.urlencode({"scopes": ','.join(scopes), **credentials}) + + # Assuming the user is authenticated and named 'user1' + # You can integrate real user authentication here + return f""" + Do you authorize the app to access your data? +
+ +
+ """ + except: + return "Invalid authorization request", 400 + + elif request.method == 'POST': + uri = request.url + http_method = request.method + headers = request.headers + body = request.get_data() + + headers, body, status = oauth2_server.create_authorization_response(uri, http_method, body, headers) + + if status == 302: + location = headers.get('Location', '') + return redirect(location) + + return jsonify(body), status + + +@app.route('/token', methods=['POST']) +def token(): + uri = request.url + http_method = request.method + headers = request.headers + body = request.get_data() + + headers, body, status = oauth2_server.create_token_response(uri, http_method, body, headers, {}) + + return body, status + + +if __name__ == "__main__": + app.run() diff --git a/validator.py b/validator.py new file mode 100644 index 0000000..87be649 --- /dev/null +++ b/validator.py @@ -0,0 +1,45 @@ +from oauthlib.oauth2 import Client +from oauthlib.oauth2 import RequestValidator + + +class MyRequestValidator(RequestValidator): + + def validate_client_id(self, client_id, request, *args, **kwargs): + return True + + def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): + return True + + def get_default_redirect_uri(self, client_id, request, *args, **kwargs): + return "" + + def get_default_scopes(self, client_id, request, *args, **kwargs): + return [] + + def authenticate_client(self, request, *args, **kwargs): + request.client = Client(client_id="my_client", access_token="my_token") + return True + + def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, *args, **kwargs): + return True + + def validate_code(self, client_id, code, client, request, *args, **kwargs): + return True + + def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): + return True + + def save_authorization_code(self, client_id, code, request, *args, **kwargs): + return True + + def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): + return True + + def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): + return True + + def save_bearer_token(self, token, request, *args, **kwargs): + return True + + def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): + return True From 8af36dae4add363813c9f5e0e3c230b418ab5061 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Thu, 31 Aug 2023 20:44:15 +0400 Subject: [PATCH 06/28] Convert the Flask IDP to FastAPI --- app.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 app.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..8247571 --- /dev/null +++ b/app.py @@ -0,0 +1,46 @@ +import json +import urllib.parse + +from fastapi import FastAPI, Request, Response +from oauthlib.oauth2 import Server +from starlette.responses import RedirectResponse + +from validator import MyRequestValidator + +app = FastAPI() +oauth2_server = Server(MyRequestValidator()) + + +@app.get("/auth") +async def auth(request: Request): + uri = str(request.url) + http_method = request.method + headers = dict(request.headers) + body_bytes = await request.body() + body = body_bytes.decode("utf-8") + + scopes, credentials = oauth2_server.validate_authorization_request(uri, http_method, body, headers) + uri = "/auth?" + urllib.parse.urlencode({"scopes": ','.join(scopes), **credentials}) + headers, body, status_code = oauth2_server.create_authorization_response(uri, http_method, body, headers) + + if status_code == 302: + location = headers.get('Location', '') + return RedirectResponse(location, headers=headers, status_code=status_code) + + return Response(content=body, status_code=status_code) + + +@app.post("/token") +async def token(request: Request): + uri = str(request.url) + http_method = request.method + headers = dict(request.headers) + body_bytes = await request.body() + body = body_bytes.decode("utf-8") + + headers, body, status_code = oauth2_server.create_token_response(uri, http_method, body, headers, {}) + + return Response(content=json.dumps({ + **json.loads(body), + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + }), status_code=status_code) From b885a43ecc2ae50888019710bb86eabab17ccfff Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 1 Sep 2023 17:05:12 +0400 Subject: [PATCH 07/28] Implement an IDP simulator for tests --- app.py | 46 ------------------ main.py | 66 -------------------------- tests/idp/__init__.py | 50 +++++++++++++++++++ tests/idp/backend.py | 11 +++++ validator.py => tests/idp/validator.py | 5 +- 5 files changed, 63 insertions(+), 115 deletions(-) delete mode 100644 app.py delete mode 100644 main.py create mode 100644 tests/idp/__init__.py create mode 100644 tests/idp/backend.py rename validator.py => tests/idp/validator.py (91%) diff --git a/app.py b/app.py deleted file mode 100644 index 8247571..0000000 --- a/app.py +++ /dev/null @@ -1,46 +0,0 @@ -import json -import urllib.parse - -from fastapi import FastAPI, Request, Response -from oauthlib.oauth2 import Server -from starlette.responses import RedirectResponse - -from validator import MyRequestValidator - -app = FastAPI() -oauth2_server = Server(MyRequestValidator()) - - -@app.get("/auth") -async def auth(request: Request): - uri = str(request.url) - http_method = request.method - headers = dict(request.headers) - body_bytes = await request.body() - body = body_bytes.decode("utf-8") - - scopes, credentials = oauth2_server.validate_authorization_request(uri, http_method, body, headers) - uri = "/auth?" + urllib.parse.urlencode({"scopes": ','.join(scopes), **credentials}) - headers, body, status_code = oauth2_server.create_authorization_response(uri, http_method, body, headers) - - if status_code == 302: - location = headers.get('Location', '') - return RedirectResponse(location, headers=headers, status_code=status_code) - - return Response(content=body, status_code=status_code) - - -@app.post("/token") -async def token(request: Request): - uri = str(request.url) - http_method = request.method - headers = dict(request.headers) - body_bytes = await request.body() - body = body_bytes.decode("utf-8") - - headers, body, status_code = oauth2_server.create_token_response(uri, http_method, body, headers, {}) - - return Response(content=json.dumps({ - **json.loads(body), - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", - }), status_code=status_code) diff --git a/main.py b/main.py deleted file mode 100644 index 0603c6f..0000000 --- a/main.py +++ /dev/null @@ -1,66 +0,0 @@ -import urllib.parse - -from flask import Flask, request, jsonify, url_for, redirect -from oauthlib.oauth2 import Server - -from validator import MyRequestValidator - -app = Flask(__name__) - -oauth2_server = Server(MyRequestValidator()) - - -@app.route('/auth', methods=['GET', 'POST']) -def auth(): - if request.method == 'GET': - try: - # Validate the client request for authorization - uri = request.url - http_method = request.method - headers = request.headers - body = request.get_data() - - scopes, credentials = oauth2_server.validate_authorization_request(uri, http_method, body, headers) - del credentials['request'] - action = url_for('auth') + "?" + urllib.parse.urlencode({"scopes": ','.join(scopes), **credentials}) - - # Assuming the user is authenticated and named 'user1' - # You can integrate real user authentication here - return f""" - Do you authorize the app to access your data? -
- -
- """ - except: - return "Invalid authorization request", 400 - - elif request.method == 'POST': - uri = request.url - http_method = request.method - headers = request.headers - body = request.get_data() - - headers, body, status = oauth2_server.create_authorization_response(uri, http_method, body, headers) - - if status == 302: - location = headers.get('Location', '') - return redirect(location) - - return jsonify(body), status - - -@app.route('/token', methods=['POST']) -def token(): - uri = request.url - http_method = request.method - headers = request.headers - body = request.get_data() - - headers, body, status = oauth2_server.create_token_response(uri, http_method, body, headers, {}) - - return body, status - - -if __name__ == "__main__": - app.run() diff --git a/tests/idp/__init__.py b/tests/idp/__init__.py new file mode 100644 index 0000000..e7327e9 --- /dev/null +++ b/tests/idp/__init__.py @@ -0,0 +1,50 @@ +import urllib.parse + +from fastapi import APIRouter +from fastapi import FastAPI +from fastapi import Request +from oauthlib.oauth2 import Server +from starlette.responses import RedirectResponse +from starlette.responses import Response + +from .backend import TestOAuth2 +from .validator import TestValidator + + +def get_idp(): + application = FastAPI() + app_router = APIRouter() + oauth2_server = Server(TestValidator()) + + @app_router.get("/oauth/authorization") + async def authorization(request: Request): + uri = str(request.url) + http_method = request.method + headers = dict(request.headers) + body_bytes = await request.body() + body = body_bytes.decode("utf-8") + + scopes, credentials = oauth2_server.validate_authorization_request(uri, http_method, body, headers) + uri = "http://idp/oauth/authorization?" + urllib.parse.urlencode({"scopes": ','.join(scopes), **credentials}) + headers, body, status_code = oauth2_server.create_authorization_response(uri, http_method, body, headers) + + if status_code == 302: + location = headers.get('Location', '') + return RedirectResponse(location, headers=headers, status_code=status_code) + + return Response(content=body, status_code=status_code) + + @app_router.post("/oauth/token") + async def token(request: Request): + uri = str(request.url) + http_method = request.method + headers = dict(request.headers) + body_bytes = await request.body() + body = body_bytes.decode("utf-8") + + headers, body, status_code = oauth2_server.create_token_response(uri, http_method, body, headers, {}) + + return Response(content=body, headers=headers, status_code=status_code) + + application.include_router(app_router) + return application diff --git a/tests/idp/backend.py b/tests/idp/backend.py new file mode 100644 index 0000000..d811758 --- /dev/null +++ b/tests/idp/backend.py @@ -0,0 +1,11 @@ +from social_core.backends.oauth import BaseOAuth2 + + +class TestOAuth2(BaseOAuth2): + name = "test" + AUTHORIZATION_URL = "http://idp/oauth/authorization" + ACCESS_TOKEN_URL = "http://idp/oauth/token" + ACCESS_TOKEN_METHOD = "POST" + REDIRECT_STATE = False + STATE_PARAMETER = True + SEND_USER_AGENT = True diff --git a/validator.py b/tests/idp/validator.py similarity index 91% rename from validator.py rename to tests/idp/validator.py index 87be649..db6813a 100644 --- a/validator.py +++ b/tests/idp/validator.py @@ -2,8 +2,7 @@ from oauthlib.oauth2 import RequestValidator -class MyRequestValidator(RequestValidator): - +class TestValidator(RequestValidator): def validate_client_id(self, client_id, request, *args, **kwargs): return True @@ -17,7 +16,7 @@ def get_default_scopes(self, client_id, request, *args, **kwargs): return [] def authenticate_client(self, request, *args, **kwargs): - request.client = Client(client_id="my_client", access_token="my_token") + request.client = Client(client_id="") return True def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, *args, **kwargs): From a328b9383476e9d0d359c2407d8e33c824e9325a Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 1 Sep 2023 17:11:37 +0400 Subject: [PATCH 08/28] Remove `httpx` as it already defined in setup.cfg --- tests/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/requirements.txt b/tests/requirements.txt index 57e15b9..9453211 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,5 +1,4 @@ tox==3.24.3 trio>=0.19.0 pytest==6.2.5 -httpx==0.23.0 appengine-python-standard # for loading the gae backend From 6a515cf4d0d3bb1be3cc3086c8e9f256e793e612 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 1 Sep 2023 17:14:40 +0400 Subject: [PATCH 09/28] Give option to provide `httpx_client_args` for testing purposes --- src/fastapi_oauth2/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index d6a9901..4703383 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -87,7 +87,7 @@ async def login_redirect(self, request: Request) -> RedirectResponse: redirect_uri=redirect_uri, )), 303) - async def token_redirect(self, request: Request) -> RedirectResponse: + async def token_redirect(self, request: Request, **httpx_client_args) -> 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"): @@ -109,7 +109,7 @@ async def token_redirect(self, request: Request) -> RedirectResponse: "Content-Type": "application/x-www-form-urlencoded", }) auth = httpx.BasicAuth(self.client_id, self.client_secret) - async with httpx.AsyncClient() as session: + async with httpx.AsyncClient(**httpx_client_args) as session: response = await session.post(token_url, headers=headers, content=content, auth=auth) try: self._oauth_client.parse_request_body_response(json.dumps(response.json())) From 9143f34e8c6e659414f5963ecae43b2422301118 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 1 Sep 2023 17:17:13 +0400 Subject: [PATCH 10/28] Configure a client for `TestOAuth2` and connect IDP --- tests/conftest.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ac6f86b..f044163 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,12 +9,14 @@ from fastapi import Request from social_core.backends.github import GithubOAuth2 from social_core.backends.oauth import BaseOAuth2 +from starlette.responses import RedirectResponse from starlette.responses import Response from fastapi_oauth2.client import OAuth2Client from fastapi_oauth2.middleware import OAuth2Middleware -from fastapi_oauth2.router import router as oauth2_router from fastapi_oauth2.security import OAuth2 +from tests.idp import TestOAuth2 +from tests.idp import get_idp package_path = backends.__path__[0] @@ -47,6 +49,20 @@ def fixture_wrapper(authentication: OAuth2 = None): application = FastAPI() app_router = APIRouter() + @app_router.get("/oauth2/{provider}/auth") + async def login(request: Request, provider: str): + return await request.auth.clients[provider].login_redirect(request) + + @app_router.get("/oauth2/{provider}/token") + async def token(request: Request, provider: str): + return await request.auth.clients[provider].token_redirect(request, app=get_idp()) + + @app_router.get("/oauth2/logout") + def logout(request: Request): + response = RedirectResponse(request.base_url) + response.delete_cookie("Authorization") + return response + @app_router.get("/user") def user(request: Request, _: str = Depends(oauth2)): return request.user @@ -73,10 +89,15 @@ def auth(request: Request): return response application.include_router(app_router) - application.include_router(oauth2_router) + application.mount("", get_idp()) application.add_middleware(OAuth2Middleware, config={ "allow_http": True, "clients": [ + OAuth2Client( + backend=TestOAuth2, + client_id="test_id", + client_secret="test_secret", + ), OAuth2Client( backend=GithubOAuth2, client_id="test_id", From c6272a0f38f41b1bc53d59798f82cd95ffe803ea Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 1 Sep 2023 17:18:23 +0400 Subject: [PATCH 11/28] Initiate OAuth2 tests --- tests/test_oauth2.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/test_oauth2.py diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py new file mode 100644 index 0000000..53943b3 --- /dev/null +++ b/tests/test_oauth2.py @@ -0,0 +1,14 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.anyio +async def test_oauth2_basic_flow(get_app): + async with AsyncClient(app=get_app(), base_url="http://test") as client: + response = await client.get("/user") + assert response.status_code == 403 + response = await client.get("/oauth2/test/auth") + response = await client.get(response.headers.get("location")) + await client.get(response.headers.get("location")) + response = await client.get("/user") + assert response.status_code == 200 From 181bf826ab29db45ae51b7e1092c2016114e792c Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 2 Sep 2023 20:18:46 +0400 Subject: [PATCH 12/28] Implement verification for the PKCE flow --- tests/idp/validator.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/idp/validator.py b/tests/idp/validator.py index db6813a..11c3c92 100644 --- a/tests/idp/validator.py +++ b/tests/idp/validator.py @@ -1,8 +1,13 @@ +import base64 +import hashlib + from oauthlib.oauth2 import Client from oauthlib.oauth2 import RequestValidator class TestValidator(RequestValidator): + pkce_codes = {} + def validate_client_id(self, client_id, request, *args, **kwargs): return True @@ -23,12 +28,30 @@ def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, * return True def validate_code(self, client_id, code, client, request, *args, **kwargs): - return True + stored_challenge = self.pkce_codes.get(code) + if not stored_challenge: + return False + + code_verifier = request.code_verifier + code_challenge = stored_challenge.get("code_challenge") + code_challenge_method = stored_challenge.get("code_challenge_method") + + computed_challenge = code_verifier + if code_challenge_method == "S256": + sha256 = hashlib.sha256() + sha256.update(code_verifier.encode("utf-8")) + computed_challenge = base64.urlsafe_b64encode(sha256.digest()).decode("utf-8").replace("=", "") + + return computed_challenge == code_challenge def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): return True def save_authorization_code(self, client_id, code, request, *args, **kwargs): + self.pkce_codes[code.get("code")] = dict( + code_challenge=request.code_challenge, + code_challenge_method=request.code_challenge_method, + ) return True def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): From 0be90fbbc5d0484171e975aaecc35b960a96acf0 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 2 Sep 2023 20:24:46 +0400 Subject: [PATCH 13/28] GH-18: Override default params with the provided ones --- src/fastapi_oauth2/core.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index 4703383..e138f07 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -79,12 +79,13 @@ def get_redirect_uri(self, request: Request) -> str: 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)]) + + oauth2_query_params = dict(state=state, scope=self.scope, redirect_uri=redirect_uri) + oauth2_query_params.update(request.query_params) + return RedirectResponse(str(self._oauth_client.prepare_request_uri( self.authorization_endpoint, - state=state, - scope=self.scope, - **request.query_params, - redirect_uri=redirect_uri, + **oauth2_query_params, )), 303) async def token_redirect(self, request: Request, **httpx_client_args) -> RedirectResponse: @@ -97,11 +98,12 @@ async def token_redirect(self, request: Request, **httpx_client_args) -> Redirec scheme = "http" if request.auth.http else "https" authorization_response = re.sub(r"^https?", scheme, str(request.url)) + oauth2_query_params = dict(redirect_url=redirect_uri, authorization_response=authorization_response) + oauth2_query_params.update(request.query_params) + token_url, headers, content = self._oauth_client.prepare_token_request( self.token_endpoint, - **request.query_params, - redirect_url=redirect_uri, - authorization_response=authorization_response, + **oauth2_query_params, ) headers.update({ From aed3faef7fa1adda07b1dfc4500dffc418a36eda Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 3 Sep 2023 17:00:41 +0400 Subject: [PATCH 14/28] Segregate the redirects from the results --- src/fastapi_oauth2/core.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index e138f07..94e84ec 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -54,8 +54,8 @@ class OAuth2Core: backend: BaseOAuth2 = None _oauth_client: Optional[WebApplicationClient] = None - authorization_endpoint: str = None - token_endpoint: str = None + _authorization_endpoint: str = None + _token_endpoint: str = None def __init__(self, client: OAuth2Client) -> None: self.client_id = client.client_id @@ -65,8 +65,8 @@ def __init__(self, client: OAuth2Client) -> None: 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 + self._authorization_endpoint = client.backend.AUTHORIZATION_URL + self._token_endpoint = client.backend.ACCESS_TOKEN_URL self._oauth_client = WebApplicationClient(self.client_id) @property @@ -76,19 +76,22 @@ def access_token(self) -> str: def get_redirect_uri(self, request: Request) -> str: return urljoin(str(request.base_url), "/oauth2/%s/token" % self.provider) - async def login_redirect(self, request: Request) -> RedirectResponse: + def authorization_url(self, request: Request) -> str: redirect_uri = self.get_redirect_uri(request) state = "".join([random.choice(string.ascii_letters) for _ in range(32)]) oauth2_query_params = dict(state=state, scope=self.scope, redirect_uri=redirect_uri) oauth2_query_params.update(request.query_params) - return RedirectResponse(str(self._oauth_client.prepare_request_uri( - self.authorization_endpoint, + return str(self._oauth_client.prepare_request_uri( + self._authorization_endpoint, **oauth2_query_params, - )), 303) + )) - async def token_redirect(self, request: Request, **httpx_client_args) -> RedirectResponse: + def authorization_redirect(self, request: Request) -> RedirectResponse: + return RedirectResponse(self.authorization_url(request), 303) + + async def token_data(self, request: Request, **httpx_client_args) -> dict: 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"): @@ -102,24 +105,22 @@ async def token_redirect(self, request: Request, **httpx_client_args) -> Redirec oauth2_query_params.update(request.query_params) token_url, headers, content = self._oauth_client.prepare_token_request( - self.token_endpoint, + self._token_endpoint, **oauth2_query_params, ) - headers.update({ - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }) + headers.update({"Accept": "application/json"}) auth = httpx.BasicAuth(self.client_id, self.client_secret) - async with httpx.AsyncClient(**httpx_client_args) as session: - response = await session.post(token_url, headers=headers, content=content, auth=auth) + async with httpx.AsyncClient(auth=auth, **httpx_client_args) as session: + response = await session.post(token_url, headers=headers, content=content) try: self._oauth_client.parse_request_body_response(json.dumps(response.json())) - token_data = self.standardize(self.backend.user_data(self.access_token)) - access_token = request.auth.jwt_create(token_data) + return self.standardize(self.backend.user_data(self.access_token)) except (CustomOAuth2Error, Exception) as e: raise OAuth2LoginError(400, str(e)) + async def token_redirect(self, request: Request, **kwargs) -> RedirectResponse: + access_token = request.auth.jwt_create(await self.token_data(request, **kwargs)) response = RedirectResponse(self.redirect_uri or request.base_url) response.set_cookie( "Authorization", From a438ad4ec07c62e665283abc9e1c704ab9a9ee05 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 3 Sep 2023 17:01:42 +0400 Subject: [PATCH 15/28] Add a new attribute for SSR mode --- src/fastapi_oauth2/config.py | 3 +++ src/fastapi_oauth2/middleware.py | 6 ++++++ src/fastapi_oauth2/router.py | 10 +++++++--- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/fastapi_oauth2/config.py b/src/fastapi_oauth2/config.py index 8eb4b85..6bdcac9 100644 --- a/src/fastapi_oauth2/config.py +++ b/src/fastapi_oauth2/config.py @@ -8,6 +8,7 @@ class OAuth2Config: """Configuration class of the authentication middleware.""" + enable_ssr: bool allow_http: bool jwt_secret: str jwt_expires: int @@ -17,6 +18,7 @@ class OAuth2Config: def __init__( self, *, + enable_ssr: bool = True, allow_http: bool = False, jwt_secret: str = "", jwt_expires: Union[int, str] = 900, @@ -25,6 +27,7 @@ def __init__( ) -> None: if allow_http: os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + self.enable_ssr = enable_ssr self.allow_http = allow_http self.jwt_secret = jwt_secret self.jwt_expires = int(jwt_expires) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index a9f6eca..905f559 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -31,6 +31,7 @@ class Auth(AuthCredentials): """Extended auth credentials schema based on Starlette AuthCredentials.""" + ssr: bool http: bool secret: str expires: int @@ -39,6 +40,10 @@ class Auth(AuthCredentials): provider: OAuth2Core = None clients: Dict[str, OAuth2Core] = {} + @classmethod + def set_ssr(cls, ssr: bool) -> None: + cls.ssr = ssr + @classmethod def set_http(cls, http: bool) -> None: cls.http = http @@ -117,6 +122,7 @@ def __init__( config: OAuth2Config, callback: Callable[[Auth, User], Union[Awaitable[None], None]] = None, ) -> None: + Auth.set_ssr(config.enable_ssr) Auth.set_http(config.allow_http) Auth.set_secret(config.jwt_secret) Auth.set_expires(config.jwt_expires) diff --git a/src/fastapi_oauth2/router.py b/src/fastapi_oauth2/router.py index 5d1a2ef..c891e51 100644 --- a/src/fastapi_oauth2/router.py +++ b/src/fastapi_oauth2/router.py @@ -6,13 +6,17 @@ @router.get("/{provider}/auth") -async def login(request: Request, provider: str): - return await request.auth.clients[provider].login_redirect(request) +def authorize(request: Request, provider: str): + if request.auth.ssr: + return request.auth.clients[provider].authorization_redirect(request) + return dict(url=request.auth.clients[provider].authorization_url(request)) @router.get("/{provider}/token") async def token(request: Request, provider: str): - return await request.auth.clients[provider].token_redirect(request) + if request.auth.ssr: + return await request.auth.clients[provider].token_redirect(request) + return await request.auth.clients[provider].token_data(request) @router.get("/logout") From e88d13057c52bb8b3e710f97ab206f7f239b39e4 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 3 Sep 2023 17:04:06 +0400 Subject: [PATCH 16/28] Refactor the `/auth` to `/authorize` --- docs/integration/integration.md | 4 ++-- docs/references/index.md | 2 +- docs/references/tutorials.md | 2 +- examples/demonstration/templates/base.html | 2 +- src/fastapi_oauth2/router.py | 2 +- tests/test_router.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/integration/integration.md b/docs/integration/integration.md index a33b48d..ca53dc1 100644 --- a/docs/integration/integration.md +++ b/docs/integration/integration.md @@ -50,8 +50,8 @@ choices, this kind of solution gives developers freedom. ## Router Router defines the endpoints that are used for the authentication and logout. The authentication is done by -the `/oauth2/{provider}/auth` endpoint and the logout is done by the `/oauth2/logout` endpoint. The `{provider}` is the -name of the provider that is going to be used for the authentication and coincides with the `name` attribute of +the `/oauth2/{provider}/authorize` endpoint and the logout is done by the `/oauth2/logout` endpoint. The `{provider}` is +the name of the provider that is going to be used for the authentication and coincides with the `name` attribute of the `backend` provided to the certain `OAuth2Client`. ```python diff --git a/docs/references/index.md b/docs/references/index.md index b81f60c..3fe15a0 100644 --- a/docs/references/index.md +++ b/docs/references/index.md @@ -22,7 +22,7 @@ the [#19](https://github.com/pysnippet/fastapi-oauth2/issues/19) issue. ## CSRF protection -CSRF protection is enabled by default which means when the user opens the `/oauth2/{provider}/auth` endpoint it +CSRF protection is enabled by default which means when the user opens the `/oauth2/{provider}/authorize` endpoint it redirects to the authorization endpoint of the IDP with an autogenerated `state` parameter and saves it in the session storage. After authorization, when the `/oauth2/{provider}/token` callback endpoint gets called with the provided `state`, the `oauthlib` validates it and then redirects to the `redirect_uri`. diff --git a/docs/references/tutorials.md b/docs/references/tutorials.md index e633281..e9cc216 100644 --- a/docs/references/tutorials.md +++ b/docs/references/tutorials.md @@ -22,7 +22,7 @@ generated the client ID and secret to configure your `OAuth2Middleware` with at 3. Set the `redirect_uri` of your application that you have also configured in the IDP. 4. Add the middleware and include the router to your application as shown in the [integration](/integration/integration) section. -5. Open the `/oauth2/{provider}/auth` endpoint on your browser and test the authentication flow. Check out +5. Open the `/oauth2/{provider}/authorize` endpoint on your browser and test the authentication flow. Check out the [router](/integration/integration#router) for the `{provider}` variable. Once the authentication is successful, the user will be redirected to the `redirect_uri` and the `request.user` will diff --git a/examples/demonstration/templates/base.html b/examples/demonstration/templates/base.html index 6754f5f..16d4044 100644 --- a/examples/demonstration/templates/base.html +++ b/examples/demonstration/templates/base.html @@ -22,7 +22,7 @@ Simulate Login {% for provider in request.auth.clients %} - + {{ provider }} icon Date: Sun, 3 Sep 2023 17:05:05 +0400 Subject: [PATCH 17/28] Extend the usability of `get_app` fixture --- tests/conftest.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f044163..2067a84 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,11 +9,11 @@ from fastapi import Request from social_core.backends.github import GithubOAuth2 from social_core.backends.oauth import BaseOAuth2 -from starlette.responses import RedirectResponse from starlette.responses import Response from fastapi_oauth2.client import OAuth2Client from fastapi_oauth2.middleware import OAuth2Middleware +from fastapi_oauth2.router import router as oauth2_router from fastapi_oauth2.security import OAuth2 from tests.idp import TestOAuth2 from tests.idp import get_idp @@ -41,7 +41,11 @@ def backends(): @pytest.fixture def get_app(): - def fixture_wrapper(authentication: OAuth2 = None): + def fixture_wrapper( + authentication: OAuth2 = None, # type of security + with_idp=False, # used to test oauth2 flow + with_ssr=True, # used to test oauth2 flow + ): if not authentication: authentication = OAuth2() @@ -49,20 +53,6 @@ def fixture_wrapper(authentication: OAuth2 = None): application = FastAPI() app_router = APIRouter() - @app_router.get("/oauth2/{provider}/auth") - async def login(request: Request, provider: str): - return await request.auth.clients[provider].login_redirect(request) - - @app_router.get("/oauth2/{provider}/token") - async def token(request: Request, provider: str): - return await request.auth.clients[provider].token_redirect(request, app=get_idp()) - - @app_router.get("/oauth2/logout") - def logout(request: Request): - response = RedirectResponse(request.base_url) - response.delete_cookie("Authorization") - return response - @app_router.get("/user") def user(request: Request, _: str = Depends(oauth2)): return request.user @@ -88,9 +78,21 @@ def auth(request: Request): ) return response + if with_idp: + @app_router.get("/oauth2/{provider}/token") + async def token(request: Request, provider: str): + if request.auth.ssr: + return await request.auth.clients[provider].token_redirect(request, app=get_idp()) + return await request.auth.clients[provider].token_data(request) + application.include_router(app_router) - application.mount("", get_idp()) + application.include_router(oauth2_router) + + if with_idp: + application.mount("", get_idp()) + application.add_middleware(OAuth2Middleware, config={ + "enable_ssr": with_ssr, "allow_http": True, "clients": [ OAuth2Client( From 0ffac70dc6d8a50377735918d073a17ea9e07223 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 3 Sep 2023 19:25:24 +0400 Subject: [PATCH 18/28] GH-18: Add tests for the PKCE workflow --- tests/conftest.py | 2 +- tests/test_oauth2.py | 49 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2067a84..26fc29b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -83,7 +83,7 @@ def auth(request: Request): async def token(request: Request, provider: str): if request.auth.ssr: return await request.auth.clients[provider].token_redirect(request, app=get_idp()) - return await request.auth.clients[provider].token_data(request) + return await request.auth.clients[provider].token_data(request, app=get_idp()) application.include_router(app_router) application.include_router(oauth2_router) diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py index 53943b3..24c411d 100644 --- a/tests/test_oauth2.py +++ b/tests/test_oauth2.py @@ -1,14 +1,45 @@ +from urllib.parse import urlencode + import pytest from httpx import AsyncClient +from jose.jwt import encode as jwt_encode +from oauthlib.oauth2 import WebApplicationClient -@pytest.mark.anyio -async def test_oauth2_basic_flow(get_app): - async with AsyncClient(app=get_app(), base_url="http://test") as client: - response = await client.get("/user") - assert response.status_code == 403 - response = await client.get("/oauth2/test/auth") - response = await client.get(response.headers.get("location")) - await client.get(response.headers.get("location")) +async def oauth2_basic_workflow(get_app, idp=False, ssr=True, authorize_query="", token_query="", use_header=False): + async with AsyncClient(app=get_app(with_idp=idp, with_ssr=ssr), base_url="http://test") as client: response = await client.get("/user") - assert response.status_code == 200 + assert response.status_code == 403 # Forbidden + + response = await client.get("/oauth2/test/authorize" + authorize_query) # Get authorization endpoint + authorization_endpoint = response.headers.get("location") if ssr else response.json().get("url") + response = await client.get(authorization_endpoint) # Authorize + response = await client.get(response.headers.get("location") + token_query) # Obtain token + + response = await client.get("/user", headers=dict( + Authorization=jwt_encode(response.json(), "") # Set token + ) if use_header else None) + assert response.status_code == 200 # OK + + +@pytest.mark.anyio +async def test_oauth2_basic_workflow(get_app): + await oauth2_basic_workflow(get_app, idp=True) + await oauth2_basic_workflow(get_app, idp=True, ssr=False, use_header=True) + + +@pytest.mark.anyio +async def test_oauth2_pkce_workflow(get_app): + for code_challenge_method in (None, "S256"): + # Generate the code verifier and challenge + oauth_client = WebApplicationClient("test_id") + code_verifier = oauth_client.create_code_verifier(128) + code_challenge = oauth_client.create_code_challenge(code_verifier, code_challenge_method) + + aq = dict(code_challenge=code_challenge) + if code_challenge_method: + aq["code_challenge_method"] = code_challenge_method + aq = "?" + urlencode(aq) + tq = "&" + urlencode(dict(code_verifier=code_verifier)) + await oauth2_basic_workflow(get_app, idp=True, authorize_query=aq, token_query=tq) + await oauth2_basic_workflow(get_app, idp=True, ssr=False, authorize_query=aq, token_query=tq, use_header=True) From 92467f0cc3be611cf2dc713aa7da4573eacb6a40 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 4 Sep 2023 20:27:13 +0400 Subject: [PATCH 19/28] Code coverage: group protected attributes together --- src/fastapi_oauth2/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index 94e84ec..fcff201 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -52,8 +52,8 @@ class OAuth2Core: provider: str = None redirect_uri: str = None backend: BaseOAuth2 = None - _oauth_client: Optional[WebApplicationClient] = None + _oauth_client: Optional[WebApplicationClient] = None _authorization_endpoint: str = None _token_endpoint: str = None From c794260566e4663d303200bc825fecc7ef97717a Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 4 Sep 2023 20:29:16 +0400 Subject: [PATCH 20/28] Code coverage: use direct assignments instead of setters --- src/fastapi_oauth2/middleware.py | 45 ++++++++------------------------ 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index 905f559..e43f338 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -23,7 +23,6 @@ from starlette.types import Send from .claims import Claims -from .client import OAuth2Client from .config import OAuth2Config from .core import OAuth2Core @@ -37,32 +36,8 @@ class Auth(AuthCredentials): expires: int algorithm: str scopes: List[str] - provider: OAuth2Core = None - clients: Dict[str, OAuth2Core] = {} - - @classmethod - def set_ssr(cls, ssr: bool) -> None: - cls.ssr = ssr - - @classmethod - def set_http(cls, http: bool) -> None: - cls.http = http - - @classmethod - def set_secret(cls, secret: str) -> None: - cls.secret = secret - - @classmethod - def set_expires(cls, expires: int) -> None: - cls.expires = expires - - @classmethod - def set_algorithm(cls, algorithm: str) -> None: - cls.algorithm = algorithm - - @classmethod - def register_client(cls, client: OAuth2Client) -> None: - cls.clients[client.backend.name] = OAuth2Core(client) + provider: OAuth2Core + clients: Dict[str, OAuth2Core] @classmethod def jwt_encode(cls, data: dict) -> str: @@ -122,13 +97,15 @@ def __init__( config: OAuth2Config, callback: Callable[[Auth, User], Union[Awaitable[None], None]] = None, ) -> None: - Auth.set_ssr(config.enable_ssr) - 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) + Auth.ssr = config.enable_ssr + Auth.http = config.allow_http + Auth.secret = config.jwt_secret + Auth.expires = config.jwt_expires + Auth.algorithm = config.jwt_algorithm + Auth.clients = { + client.backend.name: OAuth2Core(client) + for client in config.clients + } self.callback = callback async def authenticate(self, request: Request) -> Optional[Tuple[Auth, User]]: From 72fe36aa391a54043be65f0e94de341fb939358c Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 4 Sep 2023 20:32:26 +0400 Subject: [PATCH 21/28] Define user's permanent attributes in `__slots__` --- src/fastapi_oauth2/middleware.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index e43f338..bd7f2cd 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -56,26 +56,12 @@ def jwt_create(cls, token_data: dict) -> str: class User(BaseUser, dict): """Extended user schema based on Starlette BaseUser.""" + __slots__ = ("display_name", "identity", "picture", "email") + @property def is_authenticated(self) -> bool: return bool(self) - @property - def display_name(self) -> str: - return self.__getprop__("display_name") - - @property - def identity(self) -> str: - return self.__getprop__("identity") - - @property - def picture(self) -> str: - return self.__getprop__("picture") - - @property - def email(self) -> str: - return self.__getprop__("email") - def use_claims(self, claims: Claims) -> "User": for attr, item in claims.items(): self[attr] = self.__getprop__(item) From bc073ef8665749e7a0db18b2eaa74f118879baf4 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Wed, 6 Sep 2023 21:23:17 +0400 Subject: [PATCH 22/28] Fix the function naming convention --- tests/test_oauth2.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py index 24c411d..63071d5 100644 --- a/tests/test_oauth2.py +++ b/tests/test_oauth2.py @@ -6,7 +6,7 @@ from oauthlib.oauth2 import WebApplicationClient -async def oauth2_basic_workflow(get_app, idp=False, ssr=True, authorize_query="", token_query="", use_header=False): +async def oauth2_workflow(get_app, idp=False, ssr=True, authorize_query="", token_query="", use_header=False): async with AsyncClient(app=get_app(with_idp=idp, with_ssr=ssr), base_url="http://test") as client: response = await client.get("/user") assert response.status_code == 403 # Forbidden @@ -24,8 +24,8 @@ async def oauth2_basic_workflow(get_app, idp=False, ssr=True, authorize_query="" @pytest.mark.anyio async def test_oauth2_basic_workflow(get_app): - await oauth2_basic_workflow(get_app, idp=True) - await oauth2_basic_workflow(get_app, idp=True, ssr=False, use_header=True) + await oauth2_workflow(get_app, idp=True) + await oauth2_workflow(get_app, idp=True, ssr=False, use_header=True) @pytest.mark.anyio @@ -41,5 +41,5 @@ async def test_oauth2_pkce_workflow(get_app): aq["code_challenge_method"] = code_challenge_method aq = "?" + urlencode(aq) tq = "&" + urlencode(dict(code_verifier=code_verifier)) - await oauth2_basic_workflow(get_app, idp=True, authorize_query=aq, token_query=tq) - await oauth2_basic_workflow(get_app, idp=True, ssr=False, authorize_query=aq, token_query=tq, use_header=True) + await oauth2_workflow(get_app, idp=True, authorize_query=aq, token_query=tq) + await oauth2_workflow(get_app, idp=True, ssr=False, authorize_query=aq, token_query=tq, use_header=True) From 7b5457f0790c05cd8b5b5e1511efeed95e14b90c Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Wed, 6 Sep 2023 21:24:12 +0400 Subject: [PATCH 23/28] GH-18: Write up the "PKCE support" section --- docs/references/index.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/references/index.md b/docs/references/index.md index 3fe15a0..8371640 100644 --- a/docs/references/index.md +++ b/docs/references/index.md @@ -29,12 +29,9 @@ provided `state`, the `oauthlib` validates it and then redirects to the `redirec ## PKCE support -::: tip Ticket #18 - -PKCE support is under development and will be available in the next release. You can track the progress in -the [#18](https://github.com/pysnippet/fastapi-oauth2/issues/18) issue. - -::: +PKCE can be enabled by providing the `code_challenge` and `code_challenge_method` parameters to +the `/oauth2/{provider}/authorize` endpoint. Then, after the authorization passes, the `code_verifier` should be +provided to the `/oauth2/{provider}/token` endpoint to complete the authentication process.