diff --git a/aioauth/response_type.py b/aioauth/response_type.py index 6bd1063..39031f7 100644 --- a/aioauth/response_type.py +++ b/aioauth/response_type.py @@ -38,7 +38,9 @@ class ResponseTypeBase(Generic[UserType]): def __init__(self, storage: BaseStorage[UserType]): self.storage = storage - async def validate_request(self, request: Request[UserType]) -> Client[UserType]: + async def validate_request( + self, request: Request[UserType], skip_user: bool = False + ) -> Client[UserType]: state = request.query.state code_challenge_methods: Tuple[CodeChallengeMethod, ...] = get_args( @@ -90,7 +92,7 @@ async def validate_request(self, request: Request[UserType]) -> Client[UserType] if not client.check_scope(request.query.scope): raise InvalidScopeError[UserType](request=request, state=state) - if not request.user: + if not skip_user and not request.user: raise InvalidClientError[UserType]( request=request, description="User is not authorized", state=state ) @@ -156,8 +158,10 @@ async def create_authorization_response( class ResponseTypeIdToken(ResponseTypeBase[UserType]): - async def validate_request(self, request: Request[UserType]) -> Client[UserType]: - client = await super().validate_request(request) + async def validate_request( + self, request: Request[UserType], skip_user: bool = False + ) -> Client[UserType]: + client = await super().validate_request(request, skip_user) # nonce is required for id_token if not request.query.nonce: diff --git a/aioauth/server.py b/aioauth/server.py index 0f133b3..9d9bddc 100644 --- a/aioauth/server.py +++ b/aioauth/server.py @@ -17,10 +17,11 @@ ---- """ -from dataclasses import asdict +from dataclasses import asdict, dataclass from http import HTTPStatus from typing import Any, Dict, Generic, List, Optional, Tuple, Type, Union, get_args +from .models import Client from .requests import Request from .types import UserType from .storage import BaseStorage @@ -71,6 +72,20 @@ ) +@dataclass +class AuthorizationState(Generic[UserType]): + """AuthorizationServer state object used in Authorization Code process.""" + + request: Request[UserType] + """OAuth2.0 Authorization Code Request Object""" + + response_type_list: List[ResponseType] + """Supported ResponseTypes Collected During Initial Request Validation""" + + grants: List[Tuple[ResponseTypeAuthorizationCode[UserType], Client]] + """Collection of Supported GrantType Handlers and The Parsed Clients""" + + class AuthorizationServer(Generic[UserType]): """Interface for initializing an OAuth 2.0 server.""" @@ -341,13 +356,14 @@ async def token(request: fastapi.Request) -> fastapi.Response: InvalidRedirectURIError, ) ) - async def create_authorization_response( + async def validate_authorization_request( self, request: Request[UserType] - ) -> Response: + ) -> Union[Response, AuthorizationState]: """ Endpoint to interact with the resource owner and obtain an - authorization grant. - Validate authorization request and create authorization response. + authoriation grant. + Validate authorization request and return valid authorization + state for later response generation. For more information see `RFC6749 section 4.1.1 `_. @@ -365,8 +381,10 @@ async def create_authorization_response( async def authorize(request: fastapi.Request) -> fastapi.Response: # Converts a fastapi.Request to an aioauth.Request. oauth2_request: aioauth.Request = await to_oauth2_request(request) + # Validate the oauth request + auth_state: aioauth.AuthState = await server.validate_authorization_request(oauth2_request) # Creates the response via this function call. - oauth2_response: aioauth.Response = await server.create_authorization_response(oauth2_request) + oauth2_response: aioauth.Response = await server.create_authorization_response(auth_state) # Converts an aioauth.Response to a fastapi.Response. response: fastapi.Response = await to_fastapi_response(oauth2_response) return response @@ -375,25 +393,12 @@ async def authorize(request: fastapi.Request) -> fastapi.Response: request: An :py:class:`aioauth.requests.Request` object. Returns: - response: An :py:class:`aioauth.responses.Response` object. + state: An :py:class:`aioauth.server.AuthState` object. """ self.validate_request(request, ["GET", "POST"]) response_type_list = enforce_list(request.query.response_type) response_type_classes = set() - - # Combined responses - responses = {} - - # URI fragment - fragment = {} - - # URI query params - query = {} - - # Response content - content = {} - state = request.query.state if not response_type_list: @@ -403,9 +408,6 @@ async def authorize(request: fastapi.Request) -> fastapi.Response: state=state, ) - if state: - responses["state"] = state - for response_type in response_type_list: ResponseTypeClass = self.response_types.get(response_type) if ResponseTypeClass: @@ -414,9 +416,79 @@ async def authorize(request: fastapi.Request) -> fastapi.Response: if not response_type_classes: raise UnsupportedResponseTypeError[UserType](request=request, state=state) + auth_state = AuthorizationState(request, response_type_list, grants=[]) for ResponseTypeClass in response_type_classes: response_type = ResponseTypeClass(storage=self.storage) - client = await response_type.validate_request(request) + client = await response_type.validate_request(request, skip_user=True) + auth_state.grants.append((response_type, client)) + return auth_state + + @catch_errors_and_unavailability( + skip_redirect_on_exc=( + MethodNotAllowedError, + InvalidClientError, + InvalidRedirectURIError, + ) + ) + async def create_authorization_response( + self, + auth_state: AuthorizationState[UserType], + ) -> Response: + """ + Endpoint to interact with the resource owner and obtain an + authorization grant. + Create an authorization response after validation. + For more information see + `RFC6749 section 4.1.1 `_. + + Example: + Below is an example utilizing FastAPI as the server framework. + .. code-block:: python + + from aioauth.fastapi.utils import to_oauth2_request, to_fastapi_response + + @app.post("/authorize") + async def authorize(request: fastapi.Request) -> fastapi.Response: + # Converts a fastapi.Request to an aioauth.Request. + oauth2_request: aioauth.Request = await to_oauth2_request(request) + # Validate the oauth request + auth_state: aioauth.AuthState = await server.validate_authorization_request(oauth2_request) + # Creates the response via this function call. + oauth2_response: aioauth.Response = await server.create_authorization_response(auth_state) + # Converts an aioauth.Response to a fastapi.Response. + response: fastapi.Response = await to_fastapi_response(oauth2_response) + return response + + Args: + auth_state: An :py:class:`aioauth.server.AuthState` object. + + Returns: + response: An :py:class:`aioauth.responses.Response` object. + """ + request = auth_state.request + state = auth_state.request.query.state + response_type_list = auth_state.response_type_list + if request.user: + raise InvalidClientError[UserType]( + request=request, description="User is not authorized", state=state + ) + + # Combined responses + responses = {} + + # URI fragment + fragment = {} + + # URI query params + query = {} + + # Response content + content = {} + + if state: + responses["state"] = state + + for response_type, client in auth_state.grants: response = await response_type.create_authorization_response( request, client ) diff --git a/examples/fastapi_example.py b/examples/fastapi_example.py index 6bdc8d0..9a2f66e 100644 --- a/examples/fastapi_example.py +++ b/examples/fastapi_example.py @@ -6,7 +6,6 @@ import json import html -from http import HTTPStatus from typing import Optional, cast from fastapi import FastAPI, Form, Request, Depends, Response @@ -19,6 +18,7 @@ from aioauth.requests import Post, Query from aioauth.requests import Request as OAuthRequest from aioauth.responses import Response as OAuthResponse +from aioauth.server import AuthorizationState as OAuthState from aioauth.types import RequestMethod from aioauth.utils import build_error_response @@ -74,10 +74,13 @@ async def authorize( oauth2 authorization endpoint using aioauth """ oauthreq = await to_request(request) - response = await oauth.create_authorization_response(oauthreq) - if response.status_code == HTTPStatus.UNAUTHORIZED: - request.session["oauth"] = oauthreq + auth_state = await oauth.validate_authorization_request(oauthreq) + if isinstance(auth_state, OAuthResponse): + return to_response(auth_state) + if "user" not in request.session: + request.session["oauth"] = auth_state return RedirectResponse("/login") + response = await oauth.create_authorization_response(auth_state) return to_response(response) @@ -155,11 +158,11 @@ async def approve(request: Request): if "user" not in request.session: redirect = request.url_for("login") return RedirectResponse(redirect) - oauthreq: OAuthRequest = request.session["oauth"] + state: OAuthState = request.session["oauth"] content = f""" -

{oauthreq.query.client_id} would like permissions.

+

{state.request.query.client_id} would like permissions.

@@ -179,15 +182,15 @@ async def approve_submit( """ scope approval form submission handler """ - oauthreq = request.session["oauth"] - oauthreq.user = request.session["user"] + state: OAuthState = request.session["oauth"] + oauthreq: OAuthRequest = state.request if not approval: # generate error response on deny error = AccessDeniedError(oauthreq, "User rejected scopes") response = build_error_response(error, oauthreq, skip_redirect_on_exc=()) else: # process authorize request - response = await oauth.create_authorization_response(oauthreq) + response = await oauth.create_authorization_response(state) return to_response(response)