diff --git a/docs/oauth2/grants/device_code.rst b/docs/oauth2/grants/device_code.rst new file mode 100644 index 00000000..ff372b5c --- /dev/null +++ b/docs/oauth2/grants/device_code.rst @@ -0,0 +1,12 @@ +Device code Grant +----------------- + +.. autoclass:: oauthlib.oauth2.DeviceCodeGrant + :members: + :inherited-members: + + +An pseudocode/skeleton example of how the device flow can be implemented is +available in the `examples`_ folder on GitHub. + +.. _`examples`: https://github.com/oauthlib/oauthlib/blob/master/examples/device_code_flow.py diff --git a/docs/oauth2/grants/grants.rst b/docs/oauth2/grants/grants.rst index e1837617..d877bac6 100644 --- a/docs/oauth2/grants/grants.rst +++ b/docs/oauth2/grants/grants.rst @@ -10,6 +10,7 @@ Grant types password credentials refresh + device_code jwt custom_validators custom_grant @@ -26,6 +27,13 @@ degree of trust between the resource owner and the client, and when other authorization grant types are not available. This is also often used for legacy applications to incrementally transition to OAuth 2. +The device code grant(officially referred to as 'urn:ietf:params:oauth:grant-type:device_code') +is used when trying to authenticate device with limited or no input capabilities by getting +the user to approve the login on an external device (like a mobile phone or laptop) in their +possession that they're already logged into. Unlike the previously mentioned grants it is an extension grant, which is a type of grant +to address specific authorization scenarios. +:doc:`Device code grant ` + The main purpose of the grant types is to authorize access to protected resources in various ways with different security credentials. diff --git a/examples/device_code_flow.py b/examples/device_code_flow.py new file mode 100644 index 00000000..d281491f --- /dev/null +++ b/examples/device_code_flow.py @@ -0,0 +1,260 @@ +import enum +import json +import datetime +from datetime import timedelta + +from oauthlib.oauth2 import RequestValidator, Server, DeviceApplicationServer +from oauthlib.oauth2.rfc8628 import errors as device_flow_errors +from oauthlib.oauth2.rfc8628.errors import AccessDenied, AuthorizationPendingError, ExpiredTokenError, SlowDownError + + +""" +A pseudocode implementation of the device flow code under an Oauth2 provider. + +This example is not concerned with openid in any way. + +This example is also not a 1:1 pseudocode implementation. Please refer to the rfc +for the full details. +https://datatracker.ietf.org/doc/html/rfc8628 + +This module is just acting as a way to demonstrate the main pieces +needed in oauthlib to implement the flow + + +We also assume you already have the /token & /login endpoint in your provider. + +Your provider will also need the following endpoints(which will be discussed +in the example below): + - /device_authorization (part of rfc) + - /device (part of rfc) + - /approve-deny (up to your implementation, this is an example) +""" + + +""" +Device flow pseudocode implementation step by step: + 0. Providing some way to represent the device flow session + + Some python object to represent the current state of the device during + the device flow. This, for example, could be an object that persists + and represents the device in a database +""" + + +class Device: + class DeviceFlowStatus(enum.Enum): + AUTHORIZED = "Authorized" + AUTHORIZATION_PENDING = "Authorization_pending" + EXPIRED = "Expired" + DENIED = "Denied" + + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 + id = ... # if Device is representing a database object, this will be the id of that row + device_code = ... + user_code = ... + scope = ... + interval = ... # in seconds, default is 5 + expires = ... # seconds + status = ... # DeviceFlowStatus with AUTHORIZATION_PENDING as the default + + client_id = ... + last_checked = ... # datetime + + +""" + 1. User goes on their device(client) and the client sends a request to /device_authorization + against the provider: + https://datatracker.ietf.org/doc/html/rfc8628#section-3.1 + https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 + + + POST /device_authorization HTTP/1.1 + Host: server.example.com + Content-Type: application/x-www-form-urlencoded + + client_id=1406020730&scope=example_scope + + Response: + HTTP/1.1 200 OK + Content-Type: application/json + Cache-Control: no-store + + { + "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.com/device", + "verification_uri_complete": + "https://example.com/device?user_code=WDJB-MJHT", + "expires_in": 1800, + "interval": 5 + } +""" + + +class DeviceAuthorizationEndpoint: + @staticmethod + def create_device_authorization_response(request): + server = DeviceApplicationServer(interval=5, verification_uri="https://example.com/device") + return server.create_device_authorization_response(request) + + def post(self, request): + headers, data, status = self.create_device_authorization_response(request) + device_response = ... + + # Create an instance of examples.device_flow.Device` using `request` and `data`that encapsulates + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.1 & + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 + + return device_response + + +""" + 2. Client presents the information to the user + (There's a section on non visual capable devices as well + https://datatracker.ietf.org/doc/html/rfc8628#section-5.7) + +-------------------------------------------------+ + | | + | Scan the QR code or, using +------------+ | + | a browser on another device, |[_].. . [_]| | + | visit: | . .. . .| | + | https://example.com/device | . . . ....| | + | |. . . . | | + | And enter the code: |[_]. ... . | | + | WDJB-MJHT +------------+ | + | | + +-------------------------------------------------+ +""" +# The implementation for step 2 is up to the owner of device. + + +"""" + 3 (The browser flow). User goes to https://example.com/device where they're presented with a + form to fill in the user code. + + Implement that endpoint on your provider and follow the logic in the rfc. + + Making use of the errors in `oauthlib.oauth2.rfc8628.errors` + + raise AccessDenied/AuthorizationPendingError/ExpiredTokenError where appropriate making use of + `examples.device_flow.Device` to get and update current state of the device during the session + + If the user isn't logged in(after inputting the user-code), they should be redirected to the provider's /login + endpoint and redirected back to an /approve-deny endpoint(The name and implementation of /approve-deny is up + to the owner of the provider, this is just an example). + They should then see an "approve" or "deny" button to authorize the device. + + Again, using `examples.device_flow.Device` to update the status appropriately during the session. +""" +# /device and /approve-deny is up to the owner of the provider to implement. Again, make sure to +# keep referring to the rfc when implementing. + + +""" +4 (The polling flow) + https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 + https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 + + + Right after step 2, the device polls the /token endpoint every "interval" amount of seconds + to check if user has approved or denied the request. + + When grant type is `urn:ietf:params:oauth:grant-type:device_code`, + `oauthlib.oauth2.rfc8628.grant_types.device_code.DeviceCodeGrant` will be the handler + that handles token generation. +""" + + +# This is purely for illustrative purposes +# to demonstrate rate limiting on the token endpoint for the device flow. +# It is up to as the provider to decide how you want +# to rate limit the device during polling. +def rate_limit(func, rate="1/5s"): + def wrapper(): + # some logic to ensure client device is rate limited by a minimum + # of 1 request every 5 seconds during device polling + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 + + # use device_code to retrieve device + device = Device + + # get the time in seconds since the device polled the /token endpoint + now = datetime.datetime.now(tz=datetime.UTC) + diff = now - timedelta(device.last_checked) + total_seconds_since_last_device_poll = diff.total_seconds() + + device.last_checked = now + + # for illustrative purposes. 1/5s means 1 request every 5 seconds. + # so if `total_seconds_since_last_device_poll` is 4 seconds, this will + # raise an error + if total_seconds_since_last_device_poll < rate: + raise device_flow_errors.SlowDownError() + + result = func() + return result + + return wrapper + + +class ExampleRequestValidator(RequestValidator): + # All the other methods that need to be implemented... + # see examples.skeleton_oauth2_web_application_server.SkeletonValidator + # for a more complete example. + + # Here our main concern is this method: + def create_token_response(self): ... + + +class ServerSetupForTokenEndpoint: + def __init__(self): + validator = ExampleRequestValidator + self.server = Server(validator) + + +# You should already have the /token endpoint implemented in your provider. +class TokenEndpoint(ServerSetupForTokenEndpoint): + def default_flow_token_response(self, request): + url, headers, body, status = self.server.create_token_response(request) + access_token = json.loads(body).get("access_token") + + # return access_token in a http response + return access_token + + @rate_limit # this will raise the SlowDownError + def device_flow_token_response(self, request, device_code): + """ + Following the rfc, this will route the device request accordingly and raise + required errors. + + Remember that unlike other auth flows, the device if polling this endpoint once + every "interval" amount of seconds. + """ + # using device_code arg to retrieve the correct device object instance + device = Device + + if device.status == device.DeviceFlowStatus.AUTHORIZATION_PENDING: + raise AuthorizationPendingError() + + # If user clicked "deny" in the /approve-deny page endpoint. + # the device gets set to 'authorized' in /approve-deny and /device checks + # if someone tries to input a code for a user code that's already been authorized + if device.status == device.DeviceFlowStatus.DENIED: + raise AccessDenied() + + url, headers, body, status = self.server.create_token_response(request) + + access_token = json.loads(body).get("access_token") + + device.status = device.EXPIRED + + # return access_token in a http response + return access_token + + # Example of how token endpoint could handle the token creation depending on + # the grant type during a POST to /token. + def post(self, request): + params = request.POST + if params.get("grant_type") == "urn:ietf:params:oauth:grant-type:device_code": + return self.device_flow_token_response(request, params["device_code"]) + return self.default_flow_token_response(request) diff --git a/oauthlib/oauth2/__init__.py b/oauthlib/oauth2/__init__.py index da9ccf3d..3bb51021 100644 --- a/oauthlib/oauth2/__init__.py +++ b/oauthlib/oauth2/__init__.py @@ -66,4 +66,5 @@ from .rfc6749.tokens import BearerToken, OAuth2Token from .rfc6749.utils import is_secure_transport from .rfc8628.clients import DeviceClient -from .rfc8628.endpoints import DeviceAuthorizationEndpoint, DeviceApplicationServer +from oauthlib.oauth2.rfc8628.endpoints import DeviceAuthorizationEndpoint, DeviceApplicationServer +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant diff --git a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py index d64a1663..ef7db0c9 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py +++ b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py @@ -5,9 +5,13 @@ This module is an implementation of various endpoints needed for providing OAuth 2.0 RFC6749 servers. """ + from ..grant_types import ( - AuthorizationCodeGrant, ClientCredentialsGrant, ImplicitGrant, - RefreshTokenGrant, ResourceOwnerPasswordCredentialsGrant, + AuthorizationCodeGrant, + ClientCredentialsGrant, + ImplicitGrant, + RefreshTokenGrant, + ResourceOwnerPasswordCredentialsGrant, ) from ..tokens import BearerToken from .authorization import AuthorizationEndpoint @@ -15,16 +19,26 @@ from .resource import ResourceEndpoint from .revocation import RevocationEndpoint from .token import TokenEndpoint +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant -class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, - ResourceEndpoint, RevocationEndpoint): - - """An all-in-one endpoint featuring all four major grant types.""" +class Server( + AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, ResourceEndpoint, RevocationEndpoint +): + """ + An all-in-one endpoint featuring all four major grant types + and extension grants. + """ - def __init__(self, request_validator, token_expires_in=None, - token_generator=None, refresh_token_generator=None, - *args, **kwargs): + def __init__( + self, + request_validator, + token_expires_in=None, + token_generator=None, + refresh_token_generator=None, + *args, + **kwargs, + ): """Construct a new all-grants-in-one server. :param request_validator: An implementation of @@ -40,43 +54,58 @@ def __init__(self, request_validator, token_expires_in=None, """ self.auth_grant = AuthorizationCodeGrant(request_validator) self.implicit_grant = ImplicitGrant(request_validator) - self.password_grant = ResourceOwnerPasswordCredentialsGrant( - request_validator) + self.password_grant = ResourceOwnerPasswordCredentialsGrant(request_validator) self.credentials_grant = ClientCredentialsGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) + self.device_code_grant = DeviceCodeGrant(request_validator) + + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + + AuthorizationEndpoint.__init__( + self, + default_response_type="code", + response_types={ + "code": self.auth_grant, + "token": self.implicit_grant, + "none": self.auth_grant, + }, + default_token_type=self.bearer, + ) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - - AuthorizationEndpoint.__init__(self, default_response_type='code', - response_types={ - 'code': self.auth_grant, - 'token': self.implicit_grant, - 'none': self.auth_grant - }, - default_token_type=self.bearer) - - TokenEndpoint.__init__(self, default_grant_type='authorization_code', - grant_types={ - 'authorization_code': self.auth_grant, - 'password': self.password_grant, - 'client_credentials': self.credentials_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) + TokenEndpoint.__init__( + self, + default_grant_type="authorization_code", + grant_types={ + "authorization_code": self.auth_grant, + "password": self.password_grant, + "client_credentials": self.credentials_grant, + "refresh_token": self.refresh_grant, + "urn:ietf:params:oauth:grant-type:device_code": self.device_code_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) -class WebApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, - ResourceEndpoint, RevocationEndpoint): - +class WebApplicationServer( + AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Authorization code grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a new web application server. :param request_validator: An implementation of @@ -92,30 +121,44 @@ def __init__(self, request_validator, token_generator=None, """ self.auth_grant = AuthorizationCodeGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - AuthorizationEndpoint.__init__(self, default_response_type='code', - response_types={'code': self.auth_grant}, - default_token_type=self.bearer) - TokenEndpoint.__init__(self, default_grant_type='authorization_code', - grant_types={ - 'authorization_code': self.auth_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + AuthorizationEndpoint.__init__( + self, + default_response_type="code", + response_types={"code": self.auth_grant}, + default_token_type=self.bearer, + ) + TokenEndpoint.__init__( + self, + default_grant_type="authorization_code", + grant_types={ + "authorization_code": self.auth_grant, + "refresh_token": self.refresh_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) -class MobileApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, - ResourceEndpoint, RevocationEndpoint): - +class MobileApplicationServer( + AuthorizationEndpoint, IntrospectEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Implicit code grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a new implicit grant server. :param request_validator: An implementation of @@ -130,27 +173,39 @@ def __init__(self, request_validator, token_generator=None, token-, resource-, and revocation-endpoint constructors. """ self.implicit_grant = ImplicitGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - AuthorizationEndpoint.__init__(self, default_response_type='token', - response_types={ - 'token': self.implicit_grant}, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) - RevocationEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) - IntrospectEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) - - -class LegacyApplicationServer(TokenEndpoint, IntrospectEndpoint, - ResourceEndpoint, RevocationEndpoint): + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + AuthorizationEndpoint.__init__( + self, + default_response_type="token", + response_types={"token": self.implicit_grant}, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) + RevocationEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) + IntrospectEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) + +class LegacyApplicationServer( + TokenEndpoint, IntrospectEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Resource Owner Password Credentials grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a resource owner password credentials grant server. :param request_validator: An implementation of @@ -164,30 +219,40 @@ def __init__(self, request_validator, token_generator=None, :param kwargs: Extra parameters to pass to authorization-, token-, resource-, and revocation-endpoint constructors. """ - self.password_grant = ResourceOwnerPasswordCredentialsGrant( - request_validator) + self.password_grant = ResourceOwnerPasswordCredentialsGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - TokenEndpoint.__init__(self, default_grant_type='password', - grant_types={ - 'password': self.password_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + TokenEndpoint.__init__( + self, + default_grant_type="password", + grant_types={ + "password": self.password_grant, + "refresh_token": self.refresh_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) -class BackendApplicationServer(TokenEndpoint, IntrospectEndpoint, - ResourceEndpoint, RevocationEndpoint): - +class BackendApplicationServer( + TokenEndpoint, IntrospectEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Client Credentials grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a client credentials grant server. :param request_validator: An implementation of @@ -202,15 +267,21 @@ def __init__(self, request_validator, token_generator=None, token-, resource-, and revocation-endpoint constructors. """ self.credentials_grant = ClientCredentialsGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - TokenEndpoint.__init__(self, default_grant_type='client_credentials', - grant_types={ - 'client_credentials': self.credentials_grant}, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) - RevocationEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) - IntrospectEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + TokenEndpoint.__init__( + self, + default_grant_type="client_credentials", + grant_types={"client_credentials": self.credentials_grant}, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) + RevocationEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) + IntrospectEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) diff --git a/oauthlib/oauth2/rfc8628/__init__.py b/oauthlib/oauth2/rfc8628/__init__.py index 6c3d14af..65891445 100644 --- a/oauthlib/oauth2/rfc8628/__init__.py +++ b/oauthlib/oauth2/rfc8628/__init__.py @@ -6,6 +6,11 @@ for consuming and providing OAuth 2.0 Device Authorization RFC8628. """ +from oauthlib.oauth2.rfc8628.errors import ( + SlowDownError, + AuthorizationPendingError, + ExpiredTokenError, +) import logging log = logging.getLogger(__name__) diff --git a/oauthlib/oauth2/rfc8628/endpoints/pre_configured.py b/oauthlib/oauth2/rfc8628/endpoints/pre_configured.py index cdb6b313..bfc6c404 100644 --- a/oauthlib/oauth2/rfc8628/endpoints/pre_configured.py +++ b/oauthlib/oauth2/rfc8628/endpoints/pre_configured.py @@ -2,6 +2,7 @@ DeviceAuthorizationEndpoint, ) from typing import Callable +from oauthlib.openid.connect.core.request_validator import RequestValidator class DeviceApplicationServer(DeviceAuthorizationEndpoint): @@ -9,8 +10,9 @@ class DeviceApplicationServer(DeviceAuthorizationEndpoint): def __init__( self, - request_validator, - verification_uri, + request_validator: RequestValidator, + verification_uri: str, + interval: int = 5, user_code_generator: Callable[[None], str] = None, **kwargs, ): @@ -18,12 +20,14 @@ def __init__( :param request_validator: An implementation of oauthlib.oauth2.rfc8626.RequestValidator. + :param interval: How long the device needs to wait before polling the server :param verification_uri: the verification_uri to be send back. :param user_code_generator: a callable that allows the user code to be configured. """ DeviceAuthorizationEndpoint.__init__( self, request_validator, + interval=interval, verification_uri=verification_uri, user_code_generator=user_code_generator, ) diff --git a/oauthlib/oauth2/rfc8628/errors.py b/oauthlib/oauth2/rfc8628/errors.py new file mode 100644 index 00000000..a4359389 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/errors.py @@ -0,0 +1,55 @@ +from oauthlib.oauth2.rfc6749.errors import OAuth2Error + +""" +oauthlib.oauth2.rfc8628.errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Error used both by OAuth2 clients and providers to represent the spec +defined error responses specific to the the device grant +""" + + +class AuthorizationPendingError(OAuth2Error): + """ + For the device authorization grant; + The authorization request is still pending as the end user hasn't + yet completed the user-interaction steps (Section 3.3). The + client SHOULD repeat the access token request to the token + endpoint (a process known as polling). Before each new request, + the client MUST wait at least the number of seconds specified by + the "interval" parameter of the device authorization response, + or 5 seconds if none was provided, and respect any + increase in the polling interval required by the "slow_down" + error. + """ + + error = "authorization_pending" + + +class SlowDownError(OAuth2Error): + """ + A variant of "authorization_pending", the authorization request is + still pending and polling should continue, but the interval MUST + be increased by 5 seconds for this and all subsequent requests. + """ + + error = "slow_down" + + +class ExpiredTokenError(OAuth2Error): + """ + The "device_code" has expired, and the device authorization + session has concluded. The client MAY commence a new device + authorization request but SHOULD wait for user interaction before + restarting to avoid unnecessary polling. + """ + + error = "expired_token" + + +class AccessDenied(OAuth2Error): + """ + The authorization request was denied. + """ + + error = "access_denied" diff --git a/oauthlib/oauth2/rfc8628/grant_types/__init__.py b/oauthlib/oauth2/rfc8628/grant_types/__init__.py new file mode 100644 index 00000000..418dba77 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/grant_types/__init__.py @@ -0,0 +1 @@ +from oauthlib.oauth2.rfc8628.grant_types.device_code import DeviceCodeGrant diff --git a/oauthlib/oauth2/rfc8628/grant_types/device_code.py b/oauthlib/oauth2/rfc8628/grant_types/device_code.py new file mode 100644 index 00000000..8e4393d3 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/grant_types/device_code.py @@ -0,0 +1,111 @@ +from __future__ import annotations +import json + +from typing import Callable + +from oauthlib import common # noqa: TCH001 + +from oauthlib.oauth2.rfc6749 import errors as rfc6749_errors +from oauthlib.oauth2.rfc6749.grant_types.base import GrantTypeBase + + +class DeviceCodeGrant(GrantTypeBase): + def create_authorization_response( + self, request: common.Request, token_handler: Callable + ) -> tuple[dict, str, int]: + """ + Validate the device flow request -> create the access token + -> persist the token -> return the token. + """ + headers = self._get_default_headers() + try: + self.validate_token_request(request) + except rfc6749_errors.OAuth2Error as e: + headers.update(e.headers) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, refresh_token=False) + + for modifier in self._token_modifiers: + token = modifier(token) + + self.request_validator.save_token(token, request) + + return self.create_token_response(request, token_handler) + + def validate_token_request(self, request: common.Request) -> None: + """ + Performs the necessary check against the request to ensure + it's allowed to retrieve a token. + """ + for validator in self.custom_validators.pre_token: + validator(request) + + if not getattr(request, "grant_type", None): + raise rfc6749_errors.InvalidRequestError( + "Request is missing grant type.", request=request + ) + + if request.grant_type != "urn:ietf:params:oauth:grant-type:device_code": + raise rfc6749_errors.UnsupportedGrantTypeError(request=request) + + for param in ("grant_type", "scope"): + if param in request.duplicate_params: + raise rfc6749_errors.InvalidRequestError( + description=f"Duplicate {param} parameter.", request=request + ) + + if not self.request_validator.authenticate_client(request): + raise rfc6749_errors.InvalidClientError(request=request) + elif not hasattr(request.client, "client_id"): + raise NotImplementedError( + "Authenticate client must set the " + "request.client.client_id attribute " + "in authenticate_client." + ) + + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + request.client_id = request.client_id or request.client.client_id + self.validate_scopes(request) + + for validator in self.custom_validators.post_token: + validator(request) + + def create_token_response( + self, request: common.Request, token_handler: Callable + ) -> tuple[dict, str, int]: + """Return token or error in json format. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + + If the access token request is valid and authorized, the + authorization server issues an access token and optional refresh + token as described in `Section 5.1`_. If the request failed client + authentication or is invalid, the authorization server returns an + error response as described in `Section 5.2`_. + .. _`Section 5.1`: https://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: https://tools.ietf.org/html/rfc6749#section-5.2 + """ + headers = self._get_default_headers() + try: + if self.request_validator.client_authentication_required( + request + ) and not self.request_validator.authenticate_client(request): + raise rfc6749_errors.InvalidClientError(request=request) + + self.validate_token_request(request) + + except rfc6749_errors.OAuth2Error as e: + headers.update(e.headers) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, self.refresh_token) + + self.request_validator.save_token(token, request) + + return headers, json.dumps(token), 200 diff --git a/oauthlib/openid/connect/core/endpoints/pre_configured.py b/oauthlib/openid/connect/core/endpoints/pre_configured.py index 7c9393e6..62983014 100644 --- a/oauthlib/openid/connect/core/endpoints/pre_configured.py +++ b/oauthlib/openid/connect/core/endpoints/pre_configured.py @@ -19,8 +19,8 @@ ImplicitGrant as OAuth2ImplicitGrant, ResourceOwnerPasswordCredentialsGrant, ) +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant from oauthlib.oauth2.rfc6749.tokens import BearerToken -from oauthlib.oauth2.rfc8628.endpoints import DeviceAuthorizationEndpoint from ..grant_types import ( AuthorizationCodeGrant, @@ -45,7 +45,10 @@ class Server( RevocationEndpoint, UserInfoEndpoint, ): - """An all-in-one endpoint featuring all four major grant types.""" + """ + An all-in-one endpoint featuring all four major grant types + and extension grants. + """ def __init__( self, @@ -77,6 +80,7 @@ def __init__( self.openid_connect_auth = AuthorizationCodeGrant(request_validator) self.openid_connect_implicit = ImplicitGrant(request_validator) self.openid_connect_hybrid = HybridGrant(request_validator) + self.device_code_grant = DeviceCodeGrant(request_validator) self.bearer = BearerToken( request_validator, token_generator, token_expires_in, refresh_token_generator @@ -123,6 +127,7 @@ def __init__( "password": self.password_grant, "client_credentials": self.credentials_grant, "refresh_token": self.refresh_grant, + "urn:ietf:params:oauth:grant-type:device_code": self.device_code_grant, }, default_token_type=self.bearer, ) diff --git a/tests/oauth2/rfc6749/endpoints/test_metadata.py b/tests/oauth2/rfc6749/endpoints/test_metadata.py index bcb9c0f5..facf69d0 100644 --- a/tests/oauth2/rfc6749/endpoints/test_metadata.py +++ b/tests/oauth2/rfc6749/endpoints/test_metadata.py @@ -98,6 +98,7 @@ def test_server_metadata(self): "scopes_supported": ["email", "profile"], "grant_types_supported": [ "authorization_code", + "urn:ietf:params:oauth:grant-type:device_code", "password", "client_credentials", "refresh_token", diff --git a/tests/oauth2/rfc8628/grant_types/__init__.py b/tests/oauth2/rfc8628/grant_types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/oauth2/rfc8628/grant_types/test_device_code.py b/tests/oauth2/rfc8628/grant_types/test_device_code.py new file mode 100644 index 00000000..da0592f7 --- /dev/null +++ b/tests/oauth2/rfc8628/grant_types/test_device_code.py @@ -0,0 +1,172 @@ +import json +from unittest import mock +import pytest + +from oauthlib import common + +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant +from oauthlib.oauth2.rfc6749.tokens import BearerToken + +def create_request(body: str = "") -> common.Request: + request = common.Request("http://a.b/path", body=body or None) + request.scopes = ("hello", "world") + request.expires_in = 1800 + request.client = "batman" + request.client_id = "abcdef" + request.code = "1234" + request.response_type = "code" + request.grant_type = "urn:ietf:params:oauth:grant-type:device_code" + request.redirect_uri = "https://a.b/" + return request + + +def create_device_code_grant(mock_validator: mock.MagicMock) -> DeviceCodeGrant: + return DeviceCodeGrant(request_validator=mock_validator) + + +def test_custom_auth_validators_unsupported(): + custom_validator = mock.Mock() + validator = mock.MagicMock() + + expected = ( + "DeviceCodeGrant does not " + "support authorization validators. Use token validators instead." + ) + with pytest.raises(ValueError, match=expected): + DeviceCodeGrant(validator, pre_auth=[custom_validator]) + + with pytest.raises(ValueError, match=expected): + DeviceCodeGrant(validator, post_auth=[custom_validator]) + + expected = "'tuple' object has no attribute 'append'" + auth = DeviceCodeGrant(validator) + with pytest.raises(AttributeError, match=expected): + auth.custom_validators.pre_auth.append(custom_validator) + + +def test_custom_pre_and_post_token_validators(): + client = mock.MagicMock() + + validator = mock.MagicMock() + pre_token_validator = mock.Mock() + post_token_validator = mock.Mock() + + request: common.Request = create_request() + request.client = client + + auth = DeviceCodeGrant(validator) + + auth.custom_validators.pre_token.append(pre_token_validator) + auth.custom_validators.post_token.append(post_token_validator) + + bearer = BearerToken(validator) + auth.create_token_response(request, bearer) + + pre_token_validator.assert_called() + post_token_validator.assert_called() + + +def test_create_token_response(): + validator = mock.MagicMock() + request: common.Request = create_request() + request.client = mock.Mock() + + auth = DeviceCodeGrant(validator) + + bearer = BearerToken(validator) + + headers, body, status_code = auth.create_token_response(request, bearer) + token = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + } + + # when a custom token generator callable isn't used + # the random generator is used as default for the access token + assert token == { + "access_token": mock.ANY, + "expires_in": 3600, + "token_type": "Bearer", + "scope": "hello world", + "refresh_token": mock.ANY, + } + + assert status_code == 200 + + validator.save_token.assert_called_once() + + +def test_invalid_client_error(): + validator = mock.MagicMock() + request: common.Request = create_request() + request.client = mock.Mock() + + auth = DeviceCodeGrant(validator) + bearer = BearerToken(validator) + + validator.authenticate_client.return_value = False + + headers, body, status_code = auth.create_token_response(request, bearer) + body = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + "WWW-Authenticate": 'Bearer error="invalid_client"', + } + assert body == {"error": "invalid_client"} + assert status_code == 401 + + validator.save_token.assert_not_called() + + +def test_invalid_grant_type_error(): + validator = mock.MagicMock() + request: common.Request = create_request() + request.client = mock.Mock() + + request.grant_type = "not_device_code" + + auth = DeviceCodeGrant(validator) + bearer = BearerToken(validator) + + headers, body, status_code = auth.create_token_response(request, bearer) + body = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + } + assert body == {"error": "unsupported_grant_type"} + assert status_code == 400 + + validator.save_token.assert_not_called() + + +def test_duplicate_params_error(): + validator = mock.MagicMock() + request: common.Request = create_request( + "client_id=123&scope=openid&scope=openid" + ) + request.client = mock.Mock() + + auth = DeviceCodeGrant(validator) + bearer = BearerToken(validator) + + headers, body, status_code = auth.create_token_response(request, bearer) + body = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + } + assert body == {"error": "invalid_request", "error_description": "Duplicate scope parameter."} + assert status_code == 400 + + validator.save_token.assert_not_called()
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: