diff --git a/aioauth/oidc/core/grant_type.py b/aioauth/oidc/core/grant_type.py new file mode 100644 index 0000000..45be00e --- /dev/null +++ b/aioauth/oidc/core/grant_type.py @@ -0,0 +1,96 @@ +""" +.. code-block:: python + + from aioauth.oidc.core import grant_type + +Different OAuth 2.0 grant types with OpenID Connect extensions. + +---- +""" +from typing import TYPE_CHECKING + +from aioauth.grant_type import ( + AuthorizationCodeGrantType as OAuth2AuthorizationCodeGrantType, +) +from aioauth.models import Client +from aioauth.oidc.core.responses import TokenResponse +from aioauth.oidc.core.requests import TRequest +from aioauth.storage import TStorage +from aioauth.utils import generate_token + + +class AuthorizationCodeGrantType(OAuth2AuthorizationCodeGrantType[TRequest, TStorage]): + """ + The Authorization Code grant type is used by confidential and public + clients to exchange an authorization code for an access token. After + the user returns to the client via the redirect URL, the application + will get the authorization code from the URL and use it to request + an access token. + It is recommended that all clients use `RFC 7636 `_ + Proof Key for Code Exchange extension with this flow as well to + provide better security. + + Note: + Note that ``aioauth`` implements RFC 7636 out-of-the-box. + See `RFC 6749 section 1.3.1 `_. + """ + + async def create_token_response( + self, request: TRequest, client: Client + ) -> TokenResponse: + """ + Creates token response to reply to client. + + Extends the OAuth2 authorization_code grant type such that an id_token + is always included with the access_token. + https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse + """ + if self.scope is None: + raise RuntimeError("validate_request() must be called first") + + token = await self.storage.create_token( + request, + client.client_id, + self.scope, + generate_token(42), + generate_token(48), + ) + + if TYPE_CHECKING: + # validate_request will have already ensured the request includes a code. + assert request.post.code is not None + + authorization_code = await self.storage.get_authorization_code( + request=request, + client_id=client.client_id, + code=request.post.code, + ) + + if TYPE_CHECKING: + # validate_request will have already ensured the code was valid. + assert authorization_code is not None + + id_token = await self.storage.get_id_token( + client_id=client.client_id, + nonce=authorization_code.nonce, + redirect_uri=request.query.redirect_uri, + request=request, + response_type="code", + scope=self.scope, + ) + + await self.storage.delete_authorization_code( + request, + client.client_id, + request.post.code, + ) + + return TokenResponse( + access_token=token.access_token, + expires_in=token.expires_in, + id_token=id_token, + refresh_token=token.refresh_token, + refresh_token_expires_in=token.refresh_token_expires_in, + scope=token.scope, + token_type=token.token_type, + ) diff --git a/aioauth/oidc/core/requests.py b/aioauth/oidc/core/requests.py index c79417a..2991462 100644 --- a/aioauth/oidc/core/requests.py +++ b/aioauth/oidc/core/requests.py @@ -1,19 +1,31 @@ from dataclasses import dataclass, field -from typing import Any, Optional +from typing import Any, Optional, TypeVar from aioauth.requests import BaseRequest, Query as BaseQuery, Post @dataclass class Query(BaseQuery): + # Space delimited, case sensitive list of ASCII string values that + # specifies whether the Authorization Server prompts the End-User for + # reauthentication and consent. The defined values are: none, login, + # consent, select_account. + # https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest prompt: Optional[str] = None @dataclass class Request(BaseRequest[Query, Post, Any]): - """Object that contains a client's complete request.""" + """ + Object that contains a client's complete request with extensions as defined + by OpenID Core. + https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ query: Query = field(default_factory=Query) post: Post = field(default_factory=Post) user: Optional[Any] = None + + +TRequest = TypeVar("TRequest", bound=Request) diff --git a/aioauth/oidc/core/responses.py b/aioauth/oidc/core/responses.py new file mode 100644 index 0000000..53c3d47 --- /dev/null +++ b/aioauth/oidc/core/responses.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from typing import Optional + +from aioauth.responses import TokenResponse as OAuthTokenResponse + + +@dataclass +class TokenResponse(OAuthTokenResponse): + id_token: Optional[str] = None diff --git a/aioauth/response_type.py b/aioauth/response_type.py index 94387d7..b4efad0 100644 --- a/aioauth/response_type.py +++ b/aioauth/response_type.py @@ -125,14 +125,15 @@ async def create_authorization_response( self, request: TRequest, client: Client ) -> AuthorizationCodeResponse: authorization_code = await self.storage.create_authorization_code( - request, - client.client_id, - request.query.scope, - request.query.response_type, # type: ignore - request.query.redirect_uri, - request.query.code_challenge_method, - request.query.code_challenge, - generate_token(42), + client_id=client.client_id, + code=generate_token(42), + code_challenge=request.query.code_challenge, + code_challenge_method=request.query.code_challenge_method, + nonce=request.query.nonce, + redirect_uri=request.query.redirect_uri, + request=request, + response_type=request.query.response_type, # type: ignore + scope=request.query.scope, ) return AuthorizationCodeResponse( code=authorization_code.code, @@ -161,7 +162,7 @@ async def create_authorization_response( request.query.scope, request.query.response_type, # type: ignore request.query.redirect_uri, - request.query.nonce, # type: ignore + nonce=request.query.nonce, # type: ignore ) return IdTokenResponse(id_token=id_token) diff --git a/aioauth/storage.py b/aioauth/storage.py index 5560705..d6d6c45 100644 --- a/aioauth/storage.py +++ b/aioauth/storage.py @@ -76,6 +76,7 @@ async def create_authorization_code( code_challenge_method: Optional[CodeChallengeMethod], code_challenge: Optional[str], code: str, + **kwargs, ) -> TAuthorizationCode: """Generates an authorization token and stores it in the database. @@ -106,13 +107,14 @@ async def get_id_token( scope: str, response_type: ResponseType, redirect_uri: str, - nonce: str, + **kwargs, ) -> str: """Returns an id_token. For more information see `OpenID Connect Core 1.0 incorporating errata set 1 section 2 `_. Note: Method is used by response type :py:class:`aioauth.response_type.ResponseTypeIdToken` + and :py:class:`aioauth.oidc.core.grant_type.AuthorizationCodeGrantType`. """ raise NotImplementedError("get_id_token must be implemented.") diff --git a/tests/classes.py b/tests/classes.py index 5d067bc..f36cdec 100644 --- a/tests/classes.py +++ b/tests/classes.py @@ -132,7 +132,9 @@ async def create_authorization_code( code_challenge_method: Optional[CodeChallengeMethod], code_challenge: Optional[str], code: str, + **kwargs, ): + nonce = kwargs.get("nonce") authorization_code = AuthorizationCode( code=code, client_id=client_id, @@ -143,6 +145,7 @@ async def create_authorization_code( code_challenge_method=code_challenge_method, code_challenge=code_challenge, expires_in=request.settings.AUTHORIZATION_CODE_EXPIRES_IN, + nonce=nonce, ) self.authorization_codes.append(authorization_code) @@ -180,6 +183,7 @@ async def get_id_token( response_type: str, redirect_uri: str, nonce: str, + **kwargs, ) -> str: return "generated id token"