Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OIDC token response to authorization_code grant type should always include an id_token #76

Merged
merged 10 commits into from
Apr 22, 2023
96 changes: 96 additions & 0 deletions aioauth/oidc/core/grant_type.py
Original file line number Diff line number Diff line change
@@ -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 <https://tools.ietf.org/html/rfc7636>`_
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 <https://tools.ietf.org/html/rfc6749#section-1.3.1>`_.
"""

async def create_token_response(
tdg5 marked this conversation as resolved.
Show resolved Hide resolved
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,
)
16 changes: 14 additions & 2 deletions aioauth/oidc/core/requests.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about turning this comment into documentation for the prompt attribute?

Suggested change
prompt: Optional[str] = None
prompt: Optional[str] = None
"""
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
"""



@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)
9 changes: 9 additions & 0 deletions aioauth/oidc/core/responses.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 10 additions & 9 deletions aioauth/response_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking of allowing only positional arguments, in which case there will be an explicit indication of what arguments are required:

    async def create_authorization_code(
        self,
        request: TRequest,
        *,
        client_id: str,
        scope: str,
        response_type: ResponseType,
        redirect_uri: str,
        code_challenge_method: Optional[CodeChallengeMethod],
        code_challenge: Optional[str],
        code: str,
    ) -> TAuthorizationCode:

it's should not be done in scope of this PR, just as an idea. What do you think?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm flexible on the matter. I tend to feel like positional arguments can get unwieldy when there is a lot of them and I also like that keyword arguments help someone reading the code outside of an IDE (e.g. in Github for example) to better understand what the purpose of a given argument is without having to go look at the function that is being called to understand what it's positional arguments are.

To that end, I've updated the signature for create_authorization_code and get_id_token to include a tail **kwargs to allow for keyword args to be passed that may be used by a subclass of Storage. I think this achieves kind of the same thing in that the required arguments are still explicit, but other implementations can take additional arguments as needed.

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,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion aioauth/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 <https://openid.net/specs/openid-connect-core-1_0.html#IDToken>`_.

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.")

Expand Down
4 changes: 4 additions & 0 deletions tests/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,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,
Expand All @@ -135,6 +137,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)

Expand Down Expand Up @@ -172,6 +175,7 @@ async def get_id_token(
response_type: str,
redirect_uri: str,
nonce: str,
**kwargs,
) -> str:
return "generated id token"

Expand Down