venv added, updated
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
from .base import OAuth2Error
|
||||
from .auth import ClientAuth, TokenAuth
|
||||
from .client import OAuth2Client
|
||||
from .rfc6749 import (
|
||||
OAuth2Request,
|
||||
JsonRequest,
|
||||
AuthorizationServer,
|
||||
ClientAuthentication,
|
||||
ResourceProtector,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'OAuth2Error', 'ClientAuth', 'TokenAuth', 'OAuth2Client',
|
||||
'OAuth2Request', 'JsonRequest', 'AuthorizationServer',
|
||||
'ClientAuthentication', 'ResourceProtector',
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
105
myenv/lib/python3.12/site-packages/authlib/oauth2/auth.py
Normal file
105
myenv/lib/python3.12/site-packages/authlib/oauth2/auth.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import base64
|
||||
from authlib.common.urls import add_params_to_qs, add_params_to_uri
|
||||
from authlib.common.encoding import to_bytes, to_native
|
||||
from .rfc6749 import OAuth2Token
|
||||
from .rfc6750 import add_bearer_token
|
||||
|
||||
|
||||
def encode_client_secret_basic(client, method, uri, headers, body):
|
||||
text = f'{client.client_id}:{client.client_secret}'
|
||||
auth = to_native(base64.b64encode(to_bytes(text, 'latin1')))
|
||||
headers['Authorization'] = f'Basic {auth}'
|
||||
return uri, headers, body
|
||||
|
||||
|
||||
def encode_client_secret_post(client, method, uri, headers, body):
|
||||
body = add_params_to_qs(body or '', [
|
||||
('client_id', client.client_id),
|
||||
('client_secret', client.client_secret or '')
|
||||
])
|
||||
if 'Content-Length' in headers:
|
||||
headers['Content-Length'] = str(len(body))
|
||||
return uri, headers, body
|
||||
|
||||
|
||||
def encode_none(client, method, uri, headers, body):
|
||||
if method == 'GET':
|
||||
uri = add_params_to_uri(uri, [('client_id', client.client_id)])
|
||||
return uri, headers, body
|
||||
body = add_params_to_qs(body, [('client_id', client.client_id)])
|
||||
if 'Content-Length' in headers:
|
||||
headers['Content-Length'] = str(len(body))
|
||||
return uri, headers, body
|
||||
|
||||
|
||||
class ClientAuth:
|
||||
"""Attaches OAuth Client Information to HTTP requests.
|
||||
|
||||
:param client_id: Client ID, which you get from client registration.
|
||||
:param client_secret: Client Secret, which you get from registration.
|
||||
:param auth_method: Client auth method for token endpoint. The supported
|
||||
methods for now:
|
||||
|
||||
* client_secret_basic (default)
|
||||
* client_secret_post
|
||||
* none
|
||||
"""
|
||||
DEFAULT_AUTH_METHODS = {
|
||||
'client_secret_basic': encode_client_secret_basic,
|
||||
'client_secret_post': encode_client_secret_post,
|
||||
'none': encode_none,
|
||||
}
|
||||
|
||||
def __init__(self, client_id, client_secret, auth_method=None):
|
||||
if auth_method is None:
|
||||
auth_method = 'client_secret_basic'
|
||||
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
|
||||
if auth_method in self.DEFAULT_AUTH_METHODS:
|
||||
auth_method = self.DEFAULT_AUTH_METHODS[auth_method]
|
||||
|
||||
self.auth_method = auth_method
|
||||
|
||||
def prepare(self, method, uri, headers, body):
|
||||
return self.auth_method(self, method, uri, headers, body)
|
||||
|
||||
|
||||
class TokenAuth:
|
||||
"""Attach token information to HTTP requests.
|
||||
|
||||
:param token: A dict or OAuth2Token instance of an OAuth 2.0 token
|
||||
:param token_placement: The placement of the token, default is ``header``,
|
||||
available choices:
|
||||
|
||||
* header (default)
|
||||
* body
|
||||
* uri
|
||||
"""
|
||||
DEFAULT_TOKEN_TYPE = 'bearer'
|
||||
SIGN_METHODS = {
|
||||
'bearer': add_bearer_token
|
||||
}
|
||||
|
||||
def __init__(self, token, token_placement='header', client=None):
|
||||
self.token = OAuth2Token.from_dict(token)
|
||||
self.token_placement = token_placement
|
||||
self.client = client
|
||||
self.hooks = set()
|
||||
|
||||
def set_token(self, token):
|
||||
self.token = OAuth2Token.from_dict(token)
|
||||
|
||||
def prepare(self, uri, headers, body):
|
||||
token_type = self.token.get('token_type', self.DEFAULT_TOKEN_TYPE)
|
||||
sign = self.SIGN_METHODS[token_type.lower()]
|
||||
uri, headers, body = sign(
|
||||
self.token['access_token'],
|
||||
uri, headers, body,
|
||||
self.token_placement)
|
||||
|
||||
for hook in self.hooks:
|
||||
uri, headers, body = hook(uri, headers, body)
|
||||
|
||||
return uri, headers, body
|
||||
26
myenv/lib/python3.12/site-packages/authlib/oauth2/base.py
Normal file
26
myenv/lib/python3.12/site-packages/authlib/oauth2/base.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from authlib.common.errors import AuthlibHTTPError
|
||||
from authlib.common.urls import add_params_to_uri
|
||||
|
||||
|
||||
class OAuth2Error(AuthlibHTTPError):
|
||||
def __init__(self, description=None, uri=None,
|
||||
status_code=None, state=None,
|
||||
redirect_uri=None, redirect_fragment=False, error=None):
|
||||
super().__init__(error, description, uri, status_code)
|
||||
self.state = state
|
||||
self.redirect_uri = redirect_uri
|
||||
self.redirect_fragment = redirect_fragment
|
||||
|
||||
def get_body(self):
|
||||
"""Get a list of body."""
|
||||
error = super().get_body()
|
||||
if self.state:
|
||||
error.append(('state', self.state))
|
||||
return error
|
||||
|
||||
def __call__(self, uri=None):
|
||||
if self.redirect_uri:
|
||||
params = self.get_body()
|
||||
loc = add_params_to_uri(self.redirect_uri, params, self.redirect_fragment)
|
||||
return 302, '', [('Location', loc)]
|
||||
return super().__call__(uri=uri)
|
||||
449
myenv/lib/python3.12/site-packages/authlib/oauth2/client.py
Normal file
449
myenv/lib/python3.12/site-packages/authlib/oauth2/client.py
Normal file
@@ -0,0 +1,449 @@
|
||||
from authlib.common.security import generate_token
|
||||
from authlib.common.urls import url_decode
|
||||
from .rfc6749.parameters import (
|
||||
prepare_grant_uri,
|
||||
prepare_token_request,
|
||||
parse_authorization_code_response,
|
||||
parse_implicit_response,
|
||||
)
|
||||
from .rfc7009 import prepare_revoke_token_request
|
||||
from .rfc7636 import create_s256_code_challenge
|
||||
from .auth import TokenAuth, ClientAuth
|
||||
from .base import OAuth2Error
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
}
|
||||
|
||||
|
||||
class OAuth2Client:
|
||||
"""Construct a new OAuth 2 protocol client.
|
||||
|
||||
:param session: Requests session object to communicate with
|
||||
authorization server.
|
||||
:param client_id: Client ID, which you get from client registration.
|
||||
:param client_secret: Client Secret, which you get from registration.
|
||||
:param token_endpoint_auth_method: client authentication method for
|
||||
token endpoint.
|
||||
:param revocation_endpoint_auth_method: client authentication method for
|
||||
revocation endpoint.
|
||||
:param scope: Scope that you needed to access user resources.
|
||||
:param state: Shared secret to prevent CSRF attack.
|
||||
:param redirect_uri: Redirect URI you registered as callback.
|
||||
:param code_challenge_method: PKCE method name, only S256 is supported.
|
||||
:param token: A dict of token attributes such as ``access_token``,
|
||||
``token_type`` and ``expires_at``.
|
||||
:param token_placement: The place to put token in HTTP request. Available
|
||||
values: "header", "body", "uri".
|
||||
:param update_token: A function for you to update token. It accept a
|
||||
:class:`OAuth2Token` as parameter.
|
||||
:param leeway: Time window in seconds before the actual expiration of the
|
||||
authentication token, that the token is considered expired and will
|
||||
be refreshed.
|
||||
"""
|
||||
client_auth_class = ClientAuth
|
||||
token_auth_class = TokenAuth
|
||||
oauth_error_class = OAuth2Error
|
||||
|
||||
EXTRA_AUTHORIZE_PARAMS = (
|
||||
'response_mode', 'nonce', 'prompt', 'login_hint'
|
||||
)
|
||||
SESSION_REQUEST_PARAMS = []
|
||||
|
||||
def __init__(self, session, client_id=None, client_secret=None,
|
||||
token_endpoint_auth_method=None,
|
||||
revocation_endpoint_auth_method=None,
|
||||
scope=None, state=None, redirect_uri=None, code_challenge_method=None,
|
||||
token=None, token_placement='header', update_token=None, leeway=60,
|
||||
**metadata):
|
||||
|
||||
self.session = session
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.state = state
|
||||
|
||||
if token_endpoint_auth_method is None:
|
||||
if client_secret:
|
||||
token_endpoint_auth_method = 'client_secret_basic'
|
||||
else:
|
||||
token_endpoint_auth_method = 'none'
|
||||
|
||||
self.token_endpoint_auth_method = token_endpoint_auth_method
|
||||
|
||||
if revocation_endpoint_auth_method is None:
|
||||
if client_secret:
|
||||
revocation_endpoint_auth_method = 'client_secret_basic'
|
||||
else:
|
||||
revocation_endpoint_auth_method = 'none'
|
||||
|
||||
self.revocation_endpoint_auth_method = revocation_endpoint_auth_method
|
||||
|
||||
self.scope = scope
|
||||
self.redirect_uri = redirect_uri
|
||||
self.code_challenge_method = code_challenge_method
|
||||
|
||||
self.token_auth = self.token_auth_class(token, token_placement, self)
|
||||
self.update_token = update_token
|
||||
|
||||
token_updater = metadata.pop('token_updater', None)
|
||||
if token_updater:
|
||||
raise ValueError('update token has been redesigned, checkout the documentation')
|
||||
|
||||
self.metadata = metadata
|
||||
|
||||
self.compliance_hook = {
|
||||
'access_token_response': set(),
|
||||
'refresh_token_request': set(),
|
||||
'refresh_token_response': set(),
|
||||
'revoke_token_request': set(),
|
||||
'introspect_token_request': set(),
|
||||
}
|
||||
self._auth_methods = {}
|
||||
|
||||
self.leeway = leeway
|
||||
|
||||
def register_client_auth_method(self, auth):
|
||||
"""Extend client authenticate for token endpoint.
|
||||
|
||||
:param auth: an instance to sign the request
|
||||
"""
|
||||
if isinstance(auth, tuple):
|
||||
self._auth_methods[auth[0]] = auth[1]
|
||||
else:
|
||||
self._auth_methods[auth.name] = auth
|
||||
|
||||
def client_auth(self, auth_method):
|
||||
if isinstance(auth_method, str) and auth_method in self._auth_methods:
|
||||
auth_method = self._auth_methods[auth_method]
|
||||
return self.client_auth_class(
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
auth_method=auth_method,
|
||||
)
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
return self.token_auth.token
|
||||
|
||||
@token.setter
|
||||
def token(self, token):
|
||||
self.token_auth.set_token(token)
|
||||
|
||||
def create_authorization_url(self, url, state=None, code_verifier=None, **kwargs):
|
||||
"""Generate an authorization URL and state.
|
||||
|
||||
:param url: Authorization endpoint url, must be HTTPS.
|
||||
:param state: An optional state string for CSRF protection. If not
|
||||
given it will be generated for you.
|
||||
:param code_verifier: An optional code_verifier for code challenge.
|
||||
:param kwargs: Extra parameters to include.
|
||||
:return: authorization_url, state
|
||||
"""
|
||||
if state is None:
|
||||
state = generate_token()
|
||||
|
||||
response_type = self.metadata.get('response_type', 'code')
|
||||
response_type = kwargs.pop('response_type', response_type)
|
||||
if 'redirect_uri' not in kwargs:
|
||||
kwargs['redirect_uri'] = self.redirect_uri
|
||||
if 'scope' not in kwargs:
|
||||
kwargs['scope'] = self.scope
|
||||
|
||||
if code_verifier and response_type == 'code' and self.code_challenge_method == 'S256':
|
||||
kwargs['code_challenge'] = create_s256_code_challenge(code_verifier)
|
||||
kwargs['code_challenge_method'] = self.code_challenge_method
|
||||
|
||||
for k in self.EXTRA_AUTHORIZE_PARAMS:
|
||||
if k not in kwargs and k in self.metadata:
|
||||
kwargs[k] = self.metadata[k]
|
||||
|
||||
uri = prepare_grant_uri(
|
||||
url, client_id=self.client_id, response_type=response_type,
|
||||
state=state, **kwargs)
|
||||
return uri, state
|
||||
|
||||
def fetch_token(self, url=None, body='', method='POST', headers=None,
|
||||
auth=None, grant_type=None, state=None, **kwargs):
|
||||
"""Generic method for fetching an access token from the token endpoint.
|
||||
|
||||
:param url: Access Token endpoint URL, if not configured,
|
||||
``authorization_response`` is used to extract token from
|
||||
its fragment (implicit way).
|
||||
:param body: Optional application/x-www-form-urlencoded body to add the
|
||||
include in the token request. Prefer kwargs over body.
|
||||
:param method: The HTTP method used to make the request. Defaults
|
||||
to POST, but may also be GET. Other methods should
|
||||
be added as needed.
|
||||
:param headers: Dict to default request headers with.
|
||||
:param auth: An auth tuple or method as accepted by requests.
|
||||
:param grant_type: Use specified grant_type to fetch token
|
||||
:return: A :class:`OAuth2Token` object (a dict too).
|
||||
"""
|
||||
state = state or self.state
|
||||
# implicit grant_type
|
||||
authorization_response = kwargs.pop('authorization_response', None)
|
||||
if authorization_response and '#' in authorization_response:
|
||||
return self.token_from_fragment(authorization_response, state)
|
||||
|
||||
session_kwargs = self._extract_session_request_params(kwargs)
|
||||
|
||||
if authorization_response and 'code=' in authorization_response:
|
||||
grant_type = 'authorization_code'
|
||||
params = parse_authorization_code_response(
|
||||
authorization_response,
|
||||
state=state,
|
||||
)
|
||||
kwargs['code'] = params['code']
|
||||
|
||||
if grant_type is None:
|
||||
grant_type = self.metadata.get('grant_type')
|
||||
|
||||
if grant_type is None:
|
||||
grant_type = _guess_grant_type(kwargs)
|
||||
self.metadata['grant_type'] = grant_type
|
||||
|
||||
body = self._prepare_token_endpoint_body(body, grant_type, **kwargs)
|
||||
|
||||
if auth is None:
|
||||
auth = self.client_auth(self.token_endpoint_auth_method)
|
||||
|
||||
if headers is None:
|
||||
headers = DEFAULT_HEADERS
|
||||
|
||||
if url is None:
|
||||
url = self.metadata.get('token_endpoint')
|
||||
|
||||
return self._fetch_token(
|
||||
url, body=body, auth=auth, method=method,
|
||||
headers=headers, **session_kwargs
|
||||
)
|
||||
|
||||
def token_from_fragment(self, authorization_response, state=None):
|
||||
token = parse_implicit_response(authorization_response, state)
|
||||
if 'error' in token:
|
||||
raise self.oauth_error_class(
|
||||
error=token['error'],
|
||||
description=token.get('error_description')
|
||||
)
|
||||
self.token = token
|
||||
return token
|
||||
|
||||
def refresh_token(self, url=None, refresh_token=None, body='',
|
||||
auth=None, headers=None, **kwargs):
|
||||
"""Fetch a new access token using a refresh token.
|
||||
|
||||
:param url: Refresh Token endpoint, must be HTTPS.
|
||||
:param refresh_token: The refresh_token to use.
|
||||
:param body: Optional application/x-www-form-urlencoded body to add the
|
||||
include in the token request. Prefer kwargs over body.
|
||||
:param auth: An auth tuple or method as accepted by requests.
|
||||
:param headers: Dict to default request headers with.
|
||||
:return: A :class:`OAuth2Token` object (a dict too).
|
||||
"""
|
||||
session_kwargs = self._extract_session_request_params(kwargs)
|
||||
refresh_token = refresh_token or self.token.get('refresh_token')
|
||||
if 'scope' not in kwargs and self.scope:
|
||||
kwargs['scope'] = self.scope
|
||||
body = prepare_token_request(
|
||||
'refresh_token', body,
|
||||
refresh_token=refresh_token, **kwargs
|
||||
)
|
||||
|
||||
if headers is None:
|
||||
headers = DEFAULT_HEADERS.copy()
|
||||
|
||||
if url is None:
|
||||
url = self.metadata.get('token_endpoint')
|
||||
|
||||
for hook in self.compliance_hook['refresh_token_request']:
|
||||
url, headers, body = hook(url, headers, body)
|
||||
|
||||
if auth is None:
|
||||
auth = self.client_auth(self.token_endpoint_auth_method)
|
||||
|
||||
return self._refresh_token(
|
||||
url, refresh_token=refresh_token, body=body, headers=headers,
|
||||
auth=auth, **session_kwargs)
|
||||
|
||||
def ensure_active_token(self, token=None):
|
||||
if token is None:
|
||||
token = self.token
|
||||
if not token.is_expired(leeway=self.leeway):
|
||||
return True
|
||||
refresh_token = token.get('refresh_token')
|
||||
url = self.metadata.get('token_endpoint')
|
||||
if refresh_token and url:
|
||||
self.refresh_token(url, refresh_token=refresh_token)
|
||||
return True
|
||||
elif self.metadata.get('grant_type') == 'client_credentials':
|
||||
access_token = token['access_token']
|
||||
new_token = self.fetch_token(url, grant_type='client_credentials')
|
||||
if self.update_token:
|
||||
self.update_token(new_token, access_token=access_token)
|
||||
return True
|
||||
|
||||
def revoke_token(self, url, token=None, token_type_hint=None,
|
||||
body=None, auth=None, headers=None, **kwargs):
|
||||
"""Revoke token method defined via `RFC7009`_.
|
||||
|
||||
:param url: Revoke Token endpoint, must be HTTPS.
|
||||
:param token: The token to be revoked.
|
||||
:param token_type_hint: The type of the token that to be revoked.
|
||||
It can be "access_token" or "refresh_token".
|
||||
:param body: Optional application/x-www-form-urlencoded body to add the
|
||||
include in the token request. Prefer kwargs over body.
|
||||
:param auth: An auth tuple or method as accepted by requests.
|
||||
:param headers: Dict to default request headers with.
|
||||
:return: Revocation Response
|
||||
|
||||
.. _`RFC7009`: https://tools.ietf.org/html/rfc7009
|
||||
"""
|
||||
return self._handle_token_hint(
|
||||
'revoke_token_request', url,
|
||||
token=token, token_type_hint=token_type_hint,
|
||||
body=body, auth=auth, headers=headers, **kwargs)
|
||||
|
||||
def introspect_token(self, url, token=None, token_type_hint=None,
|
||||
body=None, auth=None, headers=None, **kwargs):
|
||||
"""Implementation of OAuth 2.0 Token Introspection defined via `RFC7662`_.
|
||||
|
||||
:param url: Introspection Endpoint, must be HTTPS.
|
||||
:param token: The token to be introspected.
|
||||
:param token_type_hint: The type of the token that to be revoked.
|
||||
It can be "access_token" or "refresh_token".
|
||||
:param body: Optional application/x-www-form-urlencoded body to add the
|
||||
include in the token request. Prefer kwargs over body.
|
||||
:param auth: An auth tuple or method as accepted by requests.
|
||||
:param headers: Dict to default request headers with.
|
||||
:return: Introspection Response
|
||||
|
||||
.. _`RFC7662`: https://tools.ietf.org/html/rfc7662
|
||||
"""
|
||||
return self._handle_token_hint(
|
||||
'introspect_token_request', url,
|
||||
token=token, token_type_hint=token_type_hint,
|
||||
body=body, auth=auth, headers=headers, **kwargs)
|
||||
|
||||
def register_compliance_hook(self, hook_type, hook):
|
||||
"""Register a hook for request/response tweaking.
|
||||
|
||||
Available hooks are:
|
||||
|
||||
* access_token_response: invoked before token parsing.
|
||||
* refresh_token_request: invoked before refreshing token.
|
||||
* refresh_token_response: invoked before refresh token parsing.
|
||||
* protected_request: invoked before making a request.
|
||||
* revoke_token_request: invoked before revoking a token.
|
||||
* introspect_token_request: invoked before introspecting a token.
|
||||
"""
|
||||
if hook_type == 'protected_request':
|
||||
self.token_auth.hooks.add(hook)
|
||||
return
|
||||
|
||||
if hook_type not in self.compliance_hook:
|
||||
raise ValueError('Hook type %s is not in %s.',
|
||||
hook_type, self.compliance_hook)
|
||||
self.compliance_hook[hook_type].add(hook)
|
||||
|
||||
def parse_response_token(self, resp):
|
||||
if resp.status_code >= 500:
|
||||
resp.raise_for_status()
|
||||
|
||||
token = resp.json()
|
||||
if 'error' in token:
|
||||
raise self.oauth_error_class(
|
||||
error=token['error'],
|
||||
description=token.get('error_description')
|
||||
)
|
||||
self.token = token
|
||||
return self.token
|
||||
|
||||
def _fetch_token(self, url, body='', headers=None, auth=None,
|
||||
method='POST', **kwargs):
|
||||
|
||||
if method.upper() == 'POST':
|
||||
resp = self.session.post(
|
||||
url, data=dict(url_decode(body)),
|
||||
headers=headers, auth=auth, **kwargs)
|
||||
else:
|
||||
if '?' in url:
|
||||
url = '&'.join([url, body])
|
||||
else:
|
||||
url = '?'.join([url, body])
|
||||
resp = self.session.request(method, url, headers=headers, auth=auth, **kwargs)
|
||||
|
||||
for hook in self.compliance_hook['access_token_response']:
|
||||
resp = hook(resp)
|
||||
|
||||
return self.parse_response_token(resp)
|
||||
|
||||
def _refresh_token(self, url, refresh_token=None, body='', headers=None,
|
||||
auth=None, **kwargs):
|
||||
resp = self._http_post(url, body=body, auth=auth, headers=headers, **kwargs)
|
||||
|
||||
for hook in self.compliance_hook['refresh_token_response']:
|
||||
resp = hook(resp)
|
||||
|
||||
token = self.parse_response_token(resp)
|
||||
if 'refresh_token' not in token:
|
||||
self.token['refresh_token'] = refresh_token
|
||||
|
||||
if callable(self.update_token):
|
||||
self.update_token(self.token, refresh_token=refresh_token)
|
||||
|
||||
return self.token
|
||||
|
||||
def _handle_token_hint(self, hook, url, token=None, token_type_hint=None,
|
||||
body=None, auth=None, headers=None, **kwargs):
|
||||
if token is None and self.token:
|
||||
token = self.token.get('refresh_token') or self.token.get('access_token')
|
||||
|
||||
if body is None:
|
||||
body = ''
|
||||
|
||||
body, headers = prepare_revoke_token_request(
|
||||
token, token_type_hint, body, headers)
|
||||
|
||||
for hook in self.compliance_hook[hook]:
|
||||
url, headers, body = hook(url, headers, body)
|
||||
|
||||
if auth is None:
|
||||
auth = self.client_auth(self.revocation_endpoint_auth_method)
|
||||
|
||||
session_kwargs = self._extract_session_request_params(kwargs)
|
||||
return self._http_post(
|
||||
url, body, auth=auth, headers=headers, **session_kwargs)
|
||||
|
||||
def _prepare_token_endpoint_body(self, body, grant_type, **kwargs):
|
||||
if grant_type == 'authorization_code':
|
||||
if 'redirect_uri' not in kwargs:
|
||||
kwargs['redirect_uri'] = self.redirect_uri
|
||||
return prepare_token_request(grant_type, body, **kwargs)
|
||||
|
||||
if 'scope' not in kwargs and self.scope:
|
||||
kwargs['scope'] = self.scope
|
||||
return prepare_token_request(grant_type, body, **kwargs)
|
||||
|
||||
def _extract_session_request_params(self, kwargs):
|
||||
"""Extract parameters for session object from the passing ``**kwargs``."""
|
||||
rv = {}
|
||||
for k in self.SESSION_REQUEST_PARAMS:
|
||||
if k in kwargs:
|
||||
rv[k] = kwargs.pop(k)
|
||||
return rv
|
||||
|
||||
def _http_post(self, url, body=None, auth=None, headers=None, **kwargs):
|
||||
return self.session.post(
|
||||
url, data=dict(url_decode(body)),
|
||||
headers=headers, auth=auth, **kwargs)
|
||||
|
||||
|
||||
def _guess_grant_type(kwargs):
|
||||
if 'code' in kwargs:
|
||||
grant_type = 'authorization_code'
|
||||
elif 'username' in kwargs and 'password' in kwargs:
|
||||
grant_type = 'password'
|
||||
else:
|
||||
grant_type = 'client_credentials'
|
||||
return grant_type
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
authlib.oauth2.rfc6749
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module represents a direct implementation of
|
||||
The OAuth 2.0 Authorization Framework.
|
||||
|
||||
https://tools.ietf.org/html/rfc6749
|
||||
"""
|
||||
|
||||
from .requests import OAuth2Request, JsonRequest
|
||||
from .wrappers import OAuth2Token
|
||||
from .errors import (
|
||||
OAuth2Error,
|
||||
AccessDeniedError,
|
||||
MissingAuthorizationError,
|
||||
InvalidGrantError,
|
||||
InvalidClientError,
|
||||
InvalidRequestError,
|
||||
InvalidScopeError,
|
||||
InsecureTransportError,
|
||||
UnauthorizedClientError,
|
||||
UnsupportedResponseTypeError,
|
||||
UnsupportedGrantTypeError,
|
||||
UnsupportedTokenTypeError,
|
||||
# exceptions for clients
|
||||
MissingCodeException,
|
||||
MissingTokenException,
|
||||
MissingTokenTypeException,
|
||||
MismatchingStateException,
|
||||
)
|
||||
from .models import ClientMixin, AuthorizationCodeMixin, TokenMixin
|
||||
from .authenticate_client import ClientAuthentication
|
||||
from .authorization_server import AuthorizationServer
|
||||
from .resource_protector import ResourceProtector, TokenValidator
|
||||
from .token_endpoint import TokenEndpoint
|
||||
from .grants import (
|
||||
BaseGrant,
|
||||
AuthorizationEndpointMixin,
|
||||
TokenEndpointMixin,
|
||||
AuthorizationCodeGrant,
|
||||
ImplicitGrant,
|
||||
ResourceOwnerPasswordCredentialsGrant,
|
||||
ClientCredentialsGrant,
|
||||
RefreshTokenGrant,
|
||||
)
|
||||
from .util import scope_to_list, list_to_scope
|
||||
|
||||
__all__ = [
|
||||
'OAuth2Token',
|
||||
'OAuth2Request', 'JsonRequest',
|
||||
'OAuth2Error',
|
||||
'AccessDeniedError',
|
||||
'MissingAuthorizationError',
|
||||
'InvalidGrantError',
|
||||
'InvalidClientError',
|
||||
'InvalidRequestError',
|
||||
'InvalidScopeError',
|
||||
'InsecureTransportError',
|
||||
'UnauthorizedClientError',
|
||||
'UnsupportedResponseTypeError',
|
||||
'UnsupportedGrantTypeError',
|
||||
'UnsupportedTokenTypeError',
|
||||
'MissingCodeException',
|
||||
'MissingTokenException',
|
||||
'MissingTokenTypeException',
|
||||
'MismatchingStateException',
|
||||
'ClientMixin', 'AuthorizationCodeMixin', 'TokenMixin',
|
||||
'ClientAuthentication',
|
||||
'AuthorizationServer',
|
||||
'ResourceProtector',
|
||||
'TokenValidator',
|
||||
'TokenEndpoint',
|
||||
'BaseGrant',
|
||||
'AuthorizationEndpointMixin',
|
||||
'TokenEndpointMixin',
|
||||
'AuthorizationCodeGrant',
|
||||
'ImplicitGrant',
|
||||
'ResourceOwnerPasswordCredentialsGrant',
|
||||
'ClientCredentialsGrant',
|
||||
'RefreshTokenGrant',
|
||||
'scope_to_list', 'list_to_scope',
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
authlib.oauth2.rfc6749.authenticate_client
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Registry of client authentication methods, with 3 built-in methods:
|
||||
|
||||
1. client_secret_basic
|
||||
2. client_secret_post
|
||||
3. none
|
||||
|
||||
The "client_secret_basic" method is used a lot in examples of `RFC6749`_,
|
||||
but the concept of naming are introduced in `RFC7591`_.
|
||||
|
||||
.. _`RFC6749`: https://tools.ietf.org/html/rfc6749
|
||||
.. _`RFC7591`: https://tools.ietf.org/html/rfc7591
|
||||
"""
|
||||
|
||||
import logging
|
||||
from .errors import InvalidClientError
|
||||
from .util import extract_basic_authorization
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ['ClientAuthentication']
|
||||
|
||||
|
||||
class ClientAuthentication:
|
||||
def __init__(self, query_client):
|
||||
self.query_client = query_client
|
||||
self._methods = {
|
||||
'none': authenticate_none,
|
||||
'client_secret_basic': authenticate_client_secret_basic,
|
||||
'client_secret_post': authenticate_client_secret_post,
|
||||
}
|
||||
|
||||
def register(self, method, func):
|
||||
self._methods[method] = func
|
||||
|
||||
def authenticate(self, request, methods, endpoint):
|
||||
for method in methods:
|
||||
func = self._methods[method]
|
||||
client = func(self.query_client, request)
|
||||
if client and client.check_endpoint_auth_method(method, endpoint):
|
||||
request.auth_method = method
|
||||
return client
|
||||
|
||||
if 'client_secret_basic' in methods:
|
||||
raise InvalidClientError(state=request.state, status_code=401)
|
||||
raise InvalidClientError(state=request.state)
|
||||
|
||||
def __call__(self, request, methods, endpoint='token'):
|
||||
return self.authenticate(request, methods, endpoint)
|
||||
|
||||
|
||||
def authenticate_client_secret_basic(query_client, request):
|
||||
"""Authenticate client by ``client_secret_basic`` method. The client
|
||||
uses HTTP Basic for authentication.
|
||||
"""
|
||||
client_id, client_secret = extract_basic_authorization(request.headers)
|
||||
if client_id and client_secret:
|
||||
client = _validate_client(query_client, client_id, request.state, 401)
|
||||
if client.check_client_secret(client_secret):
|
||||
log.debug(f'Authenticate {client_id} via "client_secret_basic" success')
|
||||
return client
|
||||
log.debug(f'Authenticate {client_id} via "client_secret_basic" failed')
|
||||
|
||||
|
||||
def authenticate_client_secret_post(query_client, request):
|
||||
"""Authenticate client by ``client_secret_post`` method. The client
|
||||
uses POST parameters for authentication.
|
||||
"""
|
||||
data = request.form
|
||||
client_id = data.get('client_id')
|
||||
client_secret = data.get('client_secret')
|
||||
if client_id and client_secret:
|
||||
client = _validate_client(query_client, client_id, request.state)
|
||||
if client.check_client_secret(client_secret):
|
||||
log.debug(f'Authenticate {client_id} via "client_secret_post" success')
|
||||
return client
|
||||
log.debug(f'Authenticate {client_id} via "client_secret_post" failed')
|
||||
|
||||
|
||||
def authenticate_none(query_client, request):
|
||||
"""Authenticate public client by ``none`` method. The client
|
||||
does not have a client secret.
|
||||
"""
|
||||
client_id = request.client_id
|
||||
if client_id and not request.data.get('client_secret'):
|
||||
client = _validate_client(query_client, client_id, request.state)
|
||||
log.debug(f'Authenticate {client_id} via "none" success')
|
||||
return client
|
||||
log.debug(f'Authenticate {client_id} via "none" failed')
|
||||
|
||||
|
||||
def _validate_client(query_client, client_id, state=None, status_code=400):
|
||||
if client_id is None:
|
||||
raise InvalidClientError(state=state, status_code=status_code)
|
||||
|
||||
client = query_client(client_id)
|
||||
if not client:
|
||||
raise InvalidClientError(state=state, status_code=status_code)
|
||||
|
||||
return client
|
||||
@@ -0,0 +1,302 @@
|
||||
from authlib.common.errors import ContinueIteration
|
||||
from .authenticate_client import ClientAuthentication
|
||||
from .requests import OAuth2Request, JsonRequest
|
||||
from .errors import (
|
||||
OAuth2Error,
|
||||
InvalidScopeError,
|
||||
UnsupportedResponseTypeError,
|
||||
UnsupportedGrantTypeError,
|
||||
)
|
||||
from .util import scope_to_list
|
||||
|
||||
|
||||
class AuthorizationServer:
|
||||
"""Authorization server that handles Authorization Endpoint and Token
|
||||
Endpoint.
|
||||
|
||||
:param scopes_supported: A list of supported scopes by this authorization server.
|
||||
"""
|
||||
def __init__(self, scopes_supported=None):
|
||||
self.scopes_supported = scopes_supported
|
||||
self._token_generators = {}
|
||||
self._client_auth = None
|
||||
self._authorization_grants = []
|
||||
self._token_grants = []
|
||||
self._endpoints = {}
|
||||
|
||||
def query_client(self, client_id):
|
||||
"""Query OAuth client by client_id. The client model class MUST
|
||||
implement the methods described by
|
||||
:class:`~authlib.oauth2.rfc6749.ClientMixin`.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def save_token(self, token, request):
|
||||
"""Define function to save the generated token into database."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_token(self, grant_type, client, user=None, scope=None,
|
||||
expires_in=None, include_refresh_token=True):
|
||||
"""Generate the token dict.
|
||||
|
||||
:param grant_type: current requested grant_type.
|
||||
:param client: the client that making the request.
|
||||
:param user: current authorized user.
|
||||
:param expires_in: if provided, use this value as expires_in.
|
||||
:param scope: current requested scope.
|
||||
:param include_refresh_token: should refresh_token be included.
|
||||
:return: Token dict
|
||||
"""
|
||||
# generator for a specified grant type
|
||||
func = self._token_generators.get(grant_type)
|
||||
if not func:
|
||||
# default generator for all grant types
|
||||
func = self._token_generators.get('default')
|
||||
if not func:
|
||||
raise RuntimeError('No configured token generator')
|
||||
|
||||
return func(
|
||||
grant_type=grant_type, client=client, user=user, scope=scope,
|
||||
expires_in=expires_in, include_refresh_token=include_refresh_token)
|
||||
|
||||
def register_token_generator(self, grant_type, func):
|
||||
"""Register a function as token generator for the given ``grant_type``.
|
||||
Developers MUST register a default token generator with a special
|
||||
``grant_type=default``::
|
||||
|
||||
def generate_bearer_token(grant_type, client, user=None, scope=None,
|
||||
expires_in=None, include_refresh_token=True):
|
||||
token = {'token_type': 'Bearer', 'access_token': ...}
|
||||
if include_refresh_token:
|
||||
token['refresh_token'] = ...
|
||||
...
|
||||
return token
|
||||
|
||||
authorization_server.register_token_generator('default', generate_bearer_token)
|
||||
|
||||
If you register a generator for a certain grant type, that generator will only works
|
||||
for the given grant type::
|
||||
|
||||
authorization_server.register_token_generator('client_credentials', generate_bearer_token)
|
||||
|
||||
:param grant_type: string name of the grant type
|
||||
:param func: a function to generate token
|
||||
"""
|
||||
self._token_generators[grant_type] = func
|
||||
|
||||
def authenticate_client(self, request, methods, endpoint='token'):
|
||||
"""Authenticate client via HTTP request information with the given
|
||||
methods, such as ``client_secret_basic``, ``client_secret_post``.
|
||||
"""
|
||||
if self._client_auth is None and self.query_client:
|
||||
self._client_auth = ClientAuthentication(self.query_client)
|
||||
return self._client_auth(request, methods, endpoint)
|
||||
|
||||
def register_client_auth_method(self, method, func):
|
||||
"""Add more client auth method. The default methods are:
|
||||
|
||||
* none: The client is a public client and does not have a client secret
|
||||
* client_secret_post: The client uses the HTTP POST parameters
|
||||
* client_secret_basic: The client uses HTTP Basic
|
||||
|
||||
:param method: Name of the Auth method
|
||||
:param func: Function to authenticate the client
|
||||
|
||||
The auth method accept two parameters: ``query_client`` and ``request``,
|
||||
an example for this method::
|
||||
|
||||
def authenticate_client_via_custom(query_client, request):
|
||||
client_id = request.headers['X-Client-Id']
|
||||
client = query_client(client_id)
|
||||
do_some_validation(client)
|
||||
return client
|
||||
|
||||
authorization_server.register_client_auth_method(
|
||||
'custom', authenticate_client_via_custom)
|
||||
"""
|
||||
if self._client_auth is None and self.query_client:
|
||||
self._client_auth = ClientAuthentication(self.query_client)
|
||||
|
||||
self._client_auth.register(method, func)
|
||||
|
||||
def get_error_uri(self, request, error):
|
||||
"""Return a URI for the given error, framework may implement this method."""
|
||||
return None
|
||||
|
||||
def send_signal(self, name, *args, **kwargs):
|
||||
"""Framework integration can re-implement this method to support
|
||||
signal system.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_oauth2_request(self, request) -> OAuth2Request:
|
||||
"""This method MUST be implemented in framework integrations. It is
|
||||
used to create an OAuth2Request instance.
|
||||
|
||||
:param request: the "request" instance in framework
|
||||
:return: OAuth2Request instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_json_request(self, request) -> JsonRequest:
|
||||
"""This method MUST be implemented in framework integrations. It is
|
||||
used to create an HttpRequest instance.
|
||||
|
||||
:param request: the "request" instance in framework
|
||||
:return: HttpRequest instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def handle_response(self, status, body, headers):
|
||||
"""Return HTTP response. Framework MUST implement this function."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def validate_requested_scope(self, scope, state=None):
|
||||
"""Validate if requested scope is supported by Authorization Server.
|
||||
Developers CAN re-write this method to meet your needs.
|
||||
"""
|
||||
if scope and self.scopes_supported:
|
||||
scopes = set(scope_to_list(scope))
|
||||
if not set(self.scopes_supported).issuperset(scopes):
|
||||
raise InvalidScopeError(state=state)
|
||||
|
||||
def register_grant(self, grant_cls, extensions=None):
|
||||
"""Register a grant class into the endpoint registry. Developers
|
||||
can implement the grants in ``authlib.oauth2.rfc6749.grants`` and
|
||||
register with this method::
|
||||
|
||||
class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||
def authenticate_user(self, credential):
|
||||
# ...
|
||||
|
||||
authorization_server.register_grant(AuthorizationCodeGrant)
|
||||
|
||||
:param grant_cls: a grant class.
|
||||
:param extensions: extensions for the grant class.
|
||||
"""
|
||||
if hasattr(grant_cls, 'check_authorization_endpoint'):
|
||||
self._authorization_grants.append((grant_cls, extensions))
|
||||
if hasattr(grant_cls, 'check_token_endpoint'):
|
||||
self._token_grants.append((grant_cls, extensions))
|
||||
|
||||
def register_endpoint(self, endpoint):
|
||||
"""Add extra endpoint to authorization server. e.g.
|
||||
RevocationEndpoint::
|
||||
|
||||
authorization_server.register_endpoint(RevocationEndpoint)
|
||||
|
||||
:param endpoint_cls: A endpoint class or instance.
|
||||
"""
|
||||
if isinstance(endpoint, type):
|
||||
endpoint = endpoint(self)
|
||||
else:
|
||||
endpoint.server = self
|
||||
|
||||
endpoints = self._endpoints.setdefault(endpoint.ENDPOINT_NAME, [])
|
||||
endpoints.append(endpoint)
|
||||
|
||||
def get_authorization_grant(self, request):
|
||||
"""Find the authorization grant for current request.
|
||||
|
||||
:param request: OAuth2Request instance.
|
||||
:return: grant instance
|
||||
"""
|
||||
for (grant_cls, extensions) in self._authorization_grants:
|
||||
if grant_cls.check_authorization_endpoint(request):
|
||||
return _create_grant(grant_cls, extensions, request, self)
|
||||
raise UnsupportedResponseTypeError(request.response_type)
|
||||
|
||||
def get_consent_grant(self, request=None, end_user=None):
|
||||
"""Validate current HTTP request for authorization page. This page
|
||||
is designed for resource owner to grant or deny the authorization.
|
||||
"""
|
||||
request = self.create_oauth2_request(request)
|
||||
request.user = end_user
|
||||
|
||||
grant = self.get_authorization_grant(request)
|
||||
grant.validate_no_multiple_request_parameter(request)
|
||||
grant.validate_consent_request()
|
||||
return grant
|
||||
|
||||
def get_token_grant(self, request):
|
||||
"""Find the token grant for current request.
|
||||
|
||||
:param request: OAuth2Request instance.
|
||||
:return: grant instance
|
||||
"""
|
||||
for (grant_cls, extensions) in self._token_grants:
|
||||
if grant_cls.check_token_endpoint(request):
|
||||
return _create_grant(grant_cls, extensions, request, self)
|
||||
raise UnsupportedGrantTypeError(request.grant_type)
|
||||
|
||||
def create_endpoint_response(self, name, request=None):
|
||||
"""Validate endpoint request and create endpoint response.
|
||||
|
||||
:param name: Endpoint name
|
||||
:param request: HTTP request instance.
|
||||
:return: Response
|
||||
"""
|
||||
if name not in self._endpoints:
|
||||
raise RuntimeError(f'There is no "{name}" endpoint.')
|
||||
|
||||
endpoints = self._endpoints[name]
|
||||
for endpoint in endpoints:
|
||||
request = endpoint.create_endpoint_request(request)
|
||||
try:
|
||||
return self.handle_response(*endpoint(request))
|
||||
except ContinueIteration:
|
||||
continue
|
||||
except OAuth2Error as error:
|
||||
return self.handle_error_response(request, error)
|
||||
|
||||
def create_authorization_response(self, request=None, grant_user=None):
|
||||
"""Validate authorization request and create authorization response.
|
||||
|
||||
:param request: HTTP request instance.
|
||||
:param grant_user: if granted, it is resource owner. If denied,
|
||||
it is None.
|
||||
:returns: Response
|
||||
"""
|
||||
if not isinstance(request, OAuth2Request):
|
||||
request = self.create_oauth2_request(request)
|
||||
|
||||
try:
|
||||
grant = self.get_authorization_grant(request)
|
||||
except UnsupportedResponseTypeError as error:
|
||||
return self.handle_error_response(request, error)
|
||||
|
||||
try:
|
||||
redirect_uri = grant.validate_authorization_request()
|
||||
args = grant.create_authorization_response(redirect_uri, grant_user)
|
||||
return self.handle_response(*args)
|
||||
except OAuth2Error as error:
|
||||
return self.handle_error_response(request, error)
|
||||
|
||||
def create_token_response(self, request=None):
|
||||
"""Validate token request and create token response.
|
||||
|
||||
:param request: HTTP request instance
|
||||
"""
|
||||
request = self.create_oauth2_request(request)
|
||||
try:
|
||||
grant = self.get_token_grant(request)
|
||||
except UnsupportedGrantTypeError as error:
|
||||
return self.handle_error_response(request, error)
|
||||
|
||||
try:
|
||||
grant.validate_token_request()
|
||||
args = grant.create_token_response()
|
||||
return self.handle_response(*args)
|
||||
except OAuth2Error as error:
|
||||
return self.handle_error_response(request, error)
|
||||
|
||||
def handle_error_response(self, request, error):
|
||||
return self.handle_response(*error(self.get_error_uri(request, error)))
|
||||
|
||||
|
||||
def _create_grant(grant_cls, extensions, request, server):
|
||||
grant = grant_cls(request, server)
|
||||
if extensions:
|
||||
for ext in extensions:
|
||||
ext(grant)
|
||||
return grant
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
authlib.oauth2.rfc6749.errors
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Implementation for OAuth 2 Error Response. A basic error has
|
||||
parameters:
|
||||
|
||||
error
|
||||
REQUIRED. A single ASCII [USASCII] error code.
|
||||
|
||||
error_description
|
||||
OPTIONAL. Human-readable ASCII [USASCII] text providing
|
||||
additional information, used to assist the client developer in
|
||||
understanding the error that occurred.
|
||||
|
||||
error_uri
|
||||
OPTIONAL. A URI identifying a human-readable web page with
|
||||
information about the error, used to provide the client
|
||||
developer with additional information about the error.
|
||||
Values for the "error_uri" parameter MUST conform to the
|
||||
URI-reference syntax and thus MUST NOT include characters
|
||||
outside the set %x21 / %x23-5B / %x5D-7E.
|
||||
|
||||
state
|
||||
REQUIRED if a "state" parameter was present in the client
|
||||
authorization request. The exact value received from the
|
||||
client.
|
||||
|
||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
|
||||
:copyright: (c) 2017 by Hsiaoming Yang.
|
||||
"""
|
||||
from authlib.oauth2.base import OAuth2Error
|
||||
from authlib.common.security import is_secure_transport
|
||||
|
||||
__all__ = [
|
||||
'OAuth2Error',
|
||||
'InsecureTransportError', 'InvalidRequestError',
|
||||
'InvalidClientError', 'UnauthorizedClientError', 'InvalidGrantError',
|
||||
'UnsupportedResponseTypeError', 'UnsupportedGrantTypeError',
|
||||
'InvalidScopeError', 'AccessDeniedError',
|
||||
'MissingAuthorizationError', 'UnsupportedTokenTypeError',
|
||||
'MissingCodeException', 'MissingTokenException',
|
||||
'MissingTokenTypeException', 'MismatchingStateException',
|
||||
]
|
||||
|
||||
|
||||
class InsecureTransportError(OAuth2Error):
|
||||
error = 'insecure_transport'
|
||||
description = 'OAuth 2 MUST utilize https.'
|
||||
|
||||
@classmethod
|
||||
def check(cls, uri):
|
||||
"""Check and raise InsecureTransportError with the given URI."""
|
||||
if not is_secure_transport(uri):
|
||||
raise cls()
|
||||
|
||||
|
||||
class InvalidRequestError(OAuth2Error):
|
||||
"""The request is missing a required parameter, includes an
|
||||
unsupported parameter value (other than grant type),
|
||||
repeats a parameter, includes multiple credentials,
|
||||
utilizes more than one mechanism for authenticating the
|
||||
client, or is otherwise malformed.
|
||||
|
||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
"""
|
||||
error = 'invalid_request'
|
||||
|
||||
|
||||
class InvalidClientError(OAuth2Error):
|
||||
"""Client authentication failed (e.g., unknown client, no
|
||||
client authentication included, or unsupported
|
||||
authentication method). The authorization server MAY
|
||||
return an HTTP 401 (Unauthorized) status code to indicate
|
||||
which HTTP authentication schemes are supported. If the
|
||||
client attempted to authenticate via the "Authorization"
|
||||
request header field, the authorization server MUST
|
||||
respond with an HTTP 401 (Unauthorized) status code and
|
||||
include the "WWW-Authenticate" response header field
|
||||
matching the authentication scheme used by the client.
|
||||
|
||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
"""
|
||||
error = 'invalid_client'
|
||||
status_code = 400
|
||||
|
||||
def get_headers(self):
|
||||
headers = super().get_headers()
|
||||
if self.status_code == 401:
|
||||
error_description = self.get_error_description()
|
||||
# safe escape
|
||||
error_description = error_description.replace('"', '|')
|
||||
extras = [
|
||||
f'error="{self.error}"',
|
||||
f'error_description="{error_description}"'
|
||||
]
|
||||
headers.append(
|
||||
('WWW-Authenticate', 'Basic ' + ', '.join(extras))
|
||||
)
|
||||
return headers
|
||||
|
||||
|
||||
class InvalidGrantError(OAuth2Error):
|
||||
"""The provided authorization grant (e.g., authorization
|
||||
code, resource owner credentials) or refresh token is
|
||||
invalid, expired, revoked, does not match the redirection
|
||||
URI used in the authorization request, or was issued to
|
||||
another client.
|
||||
|
||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
"""
|
||||
error = 'invalid_grant'
|
||||
|
||||
|
||||
class UnauthorizedClientError(OAuth2Error):
|
||||
""" The authenticated client is not authorized to use this
|
||||
authorization grant type.
|
||||
|
||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
"""
|
||||
error = 'unauthorized_client'
|
||||
|
||||
|
||||
class UnsupportedResponseTypeError(OAuth2Error):
|
||||
"""The authorization server does not support obtaining
|
||||
an access token using this method."""
|
||||
error = 'unsupported_response_type'
|
||||
|
||||
def __init__(self, response_type):
|
||||
super().__init__()
|
||||
self.response_type = response_type
|
||||
|
||||
def get_error_description(self):
|
||||
return f'response_type={self.response_type} is not supported'
|
||||
|
||||
|
||||
class UnsupportedGrantTypeError(OAuth2Error):
|
||||
"""The authorization grant type is not supported by the
|
||||
authorization server.
|
||||
|
||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
"""
|
||||
error = 'unsupported_grant_type'
|
||||
|
||||
def __init__(self, grant_type):
|
||||
super().__init__()
|
||||
self.grant_type = grant_type
|
||||
|
||||
def get_error_description(self):
|
||||
return f'grant_type={self.grant_type} is not supported'
|
||||
|
||||
|
||||
class InvalidScopeError(OAuth2Error):
|
||||
"""The requested scope is invalid, unknown, malformed, or
|
||||
exceeds the scope granted by the resource owner.
|
||||
|
||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
"""
|
||||
error = 'invalid_scope'
|
||||
description = 'The requested scope is invalid, unknown, or malformed.'
|
||||
|
||||
|
||||
class AccessDeniedError(OAuth2Error):
|
||||
"""The resource owner or authorization server denied the request.
|
||||
|
||||
Used in authorization endpoint for "code" and "implicit". Defined in
|
||||
`Section 4.1.2.1`_.
|
||||
|
||||
.. _`Section 4.1.2.1`: https://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
"""
|
||||
error = 'access_denied'
|
||||
description = 'The resource owner or authorization server denied the request'
|
||||
|
||||
|
||||
# -- below are extended errors -- #
|
||||
|
||||
|
||||
class ForbiddenError(OAuth2Error):
|
||||
status_code = 401
|
||||
|
||||
def __init__(self, auth_type=None, realm=None):
|
||||
super().__init__()
|
||||
self.auth_type = auth_type
|
||||
self.realm = realm
|
||||
|
||||
def get_headers(self):
|
||||
headers = super().get_headers()
|
||||
if not self.auth_type:
|
||||
return headers
|
||||
|
||||
extras = []
|
||||
if self.realm:
|
||||
extras.append(f'realm="{self.realm}"')
|
||||
extras.append(f'error="{self.error}"')
|
||||
error_description = self.description
|
||||
extras.append(f'error_description="{error_description}"')
|
||||
headers.append(
|
||||
('WWW-Authenticate', f'{self.auth_type} ' + ', '.join(extras))
|
||||
)
|
||||
return headers
|
||||
|
||||
|
||||
class MissingAuthorizationError(ForbiddenError):
|
||||
error = 'missing_authorization'
|
||||
description = 'Missing "Authorization" in headers.'
|
||||
|
||||
|
||||
class UnsupportedTokenTypeError(ForbiddenError):
|
||||
error = 'unsupported_token_type'
|
||||
|
||||
|
||||
# -- exceptions for clients -- #
|
||||
|
||||
|
||||
class MissingCodeException(OAuth2Error):
|
||||
error = 'missing_code'
|
||||
description = 'Missing "code" in response.'
|
||||
|
||||
|
||||
class MissingTokenException(OAuth2Error):
|
||||
error = 'missing_token'
|
||||
description = 'Missing "access_token" in response.'
|
||||
|
||||
|
||||
class MissingTokenTypeException(OAuth2Error):
|
||||
error = 'missing_token_type'
|
||||
description = 'Missing "token_type" in response.'
|
||||
|
||||
|
||||
class MismatchingStateException(OAuth2Error):
|
||||
error = 'mismatching_state'
|
||||
description = 'CSRF Warning! State not equal in request and response.'
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
authlib.oauth2.rfc6749.grants
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Implementation for `Section 4`_ of "Obtaining Authorization".
|
||||
|
||||
To request an access token, the client obtains authorization from the
|
||||
resource owner. The authorization is expressed in the form of an
|
||||
authorization grant, which the client uses to request the access
|
||||
token. OAuth defines four grant types:
|
||||
|
||||
1. authorization code
|
||||
2. implicit
|
||||
3. resource owner password credentials
|
||||
4. client credentials.
|
||||
|
||||
It also provides an extension mechanism for defining additional grant
|
||||
types. Authlib defines refresh_token as a grant type too.
|
||||
|
||||
.. _`Section 4`: https://tools.ietf.org/html/rfc6749#section-4
|
||||
"""
|
||||
|
||||
# flake8: noqa
|
||||
|
||||
from .base import BaseGrant, AuthorizationEndpointMixin, TokenEndpointMixin
|
||||
from .authorization_code import AuthorizationCodeGrant
|
||||
from .implicit import ImplicitGrant
|
||||
from .resource_owner_password_credentials import ResourceOwnerPasswordCredentialsGrant
|
||||
from .client_credentials import ClientCredentialsGrant
|
||||
from .refresh_token import RefreshTokenGrant
|
||||
|
||||
__all__ = [
|
||||
'BaseGrant', 'AuthorizationEndpointMixin', 'TokenEndpointMixin',
|
||||
'AuthorizationCodeGrant', 'ImplicitGrant',
|
||||
'ResourceOwnerPasswordCredentialsGrant',
|
||||
'ClientCredentialsGrant', 'RefreshTokenGrant',
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,378 @@
|
||||
import logging
|
||||
from authlib.common.urls import add_params_to_uri
|
||||
from authlib.common.security import generate_token
|
||||
from .base import BaseGrant, AuthorizationEndpointMixin, TokenEndpointMixin
|
||||
from ..errors import (
|
||||
OAuth2Error,
|
||||
UnauthorizedClientError,
|
||||
InvalidClientError,
|
||||
InvalidGrantError,
|
||||
InvalidRequestError,
|
||||
AccessDeniedError,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthorizationCodeGrant(BaseGrant, AuthorizationEndpointMixin, TokenEndpointMixin):
|
||||
"""The authorization code grant type is used to obtain both access
|
||||
tokens and refresh tokens and is optimized for confidential clients.
|
||||
Since this is a redirection-based flow, the client must be capable of
|
||||
interacting with the resource owner's user-agent (typically a web
|
||||
browser) and capable of receiving incoming requests (via redirection)
|
||||
from the authorization server::
|
||||
|
||||
+----------+
|
||||
| Resource |
|
||||
| Owner |
|
||||
| |
|
||||
+----------+
|
||||
^
|
||||
|
|
||||
(B)
|
||||
+----|-----+ Client Identifier +---------------+
|
||||
| -+----(A)-- & Redirection URI ---->| |
|
||||
| User- | | Authorization |
|
||||
| Agent -+----(B)-- User authenticates --->| Server |
|
||||
| | | |
|
||||
| -+----(C)-- Authorization Code ---<| |
|
||||
+-|----|---+ +---------------+
|
||||
| | ^ v
|
||||
(A) (C) | |
|
||||
| | | |
|
||||
^ v | |
|
||||
+---------+ | |
|
||||
| |>---(D)-- Authorization Code ---------' |
|
||||
| Client | & Redirection URI |
|
||||
| | |
|
||||
| |<---(E)----- Access Token -------------------'
|
||||
+---------+ (w/ Optional Refresh Token)
|
||||
"""
|
||||
#: Allowed client auth methods for token endpoint
|
||||
TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post']
|
||||
|
||||
#: Generated "code" length
|
||||
AUTHORIZATION_CODE_LENGTH = 48
|
||||
|
||||
RESPONSE_TYPES = {'code'}
|
||||
GRANT_TYPE = 'authorization_code'
|
||||
|
||||
def validate_authorization_request(self):
|
||||
"""The client constructs the request URI by adding the following
|
||||
parameters to the query component of the authorization endpoint URI
|
||||
using the "application/x-www-form-urlencoded" format.
|
||||
Per `Section 4.1.1`_.
|
||||
|
||||
response_type
|
||||
REQUIRED. Value MUST be set to "code".
|
||||
|
||||
client_id
|
||||
REQUIRED. The client identifier as described in Section 2.2.
|
||||
|
||||
redirect_uri
|
||||
OPTIONAL. As described in Section 3.1.2.
|
||||
|
||||
scope
|
||||
OPTIONAL. The scope of the access request as described by
|
||||
Section 3.3.
|
||||
|
||||
state
|
||||
RECOMMENDED. An opaque value used by the client to maintain
|
||||
state between the request and callback. The authorization
|
||||
server includes this value when redirecting the user-agent back
|
||||
to the client. The parameter SHOULD be used for preventing
|
||||
cross-site request forgery as described in Section 10.12.
|
||||
|
||||
The client directs the resource owner to the constructed URI using an
|
||||
HTTP redirection response, or by other means available to it via the
|
||||
user-agent.
|
||||
|
||||
For example, the client directs the user-agent to make the following
|
||||
HTTP request using TLS (with extra line breaks for display purposes
|
||||
only):
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
|
||||
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
|
||||
Host: server.example.com
|
||||
|
||||
The authorization server validates the request to ensure that all
|
||||
required parameters are present and valid. If the request is valid,
|
||||
the authorization server authenticates the resource owner and obtains
|
||||
an authorization decision (by asking the resource owner or by
|
||||
establishing approval via other means).
|
||||
|
||||
.. _`Section 4.1.1`: https://tools.ietf.org/html/rfc6749#section-4.1.1
|
||||
"""
|
||||
return validate_code_authorization_request(self)
|
||||
|
||||
def create_authorization_response(self, redirect_uri: str, grant_user):
|
||||
"""If the resource owner grants the access request, the authorization
|
||||
server issues an authorization code and delivers it to the client by
|
||||
adding the following parameters to the query component of the
|
||||
redirection URI using the "application/x-www-form-urlencoded" format.
|
||||
Per `Section 4.1.2`_.
|
||||
|
||||
code
|
||||
REQUIRED. The authorization code generated by the
|
||||
authorization server. The authorization code MUST expire
|
||||
shortly after it is issued to mitigate the risk of leaks. A
|
||||
maximum authorization code lifetime of 10 minutes is
|
||||
RECOMMENDED. The client MUST NOT use the authorization code
|
||||
more than once. If an authorization code is used more than
|
||||
once, the authorization server MUST deny the request and SHOULD
|
||||
revoke (when possible) all tokens previously issued based on
|
||||
that authorization code. The authorization code is bound to
|
||||
the client identifier and redirection URI.
|
||||
state
|
||||
REQUIRED if the "state" parameter was present in the client
|
||||
authorization request. The exact value received from the
|
||||
client.
|
||||
|
||||
For example, the authorization server redirects the user-agent by
|
||||
sending the following HTTP response.
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
HTTP/1.1 302 Found
|
||||
Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA
|
||||
&state=xyz
|
||||
|
||||
.. _`Section 4.1.2`: https://tools.ietf.org/html/rfc6749#section-4.1.2
|
||||
|
||||
:param redirect_uri: Redirect to the given URI for the authorization
|
||||
:param grant_user: if resource owner granted the request, pass this
|
||||
resource owner, otherwise pass None.
|
||||
:returns: (status_code, body, headers)
|
||||
"""
|
||||
if not grant_user:
|
||||
raise AccessDeniedError(state=self.request.state, redirect_uri=redirect_uri)
|
||||
|
||||
self.request.user = grant_user
|
||||
|
||||
code = self.generate_authorization_code()
|
||||
self.save_authorization_code(code, self.request)
|
||||
|
||||
params = [('code', code)]
|
||||
if self.request.state:
|
||||
params.append(('state', self.request.state))
|
||||
uri = add_params_to_uri(redirect_uri, params)
|
||||
headers = [('Location', uri)]
|
||||
return 302, '', headers
|
||||
|
||||
def validate_token_request(self):
|
||||
"""The client makes a request to the token endpoint by sending the
|
||||
following parameters using the "application/x-www-form-urlencoded"
|
||||
format per `Section 4.1.3`_:
|
||||
|
||||
grant_type
|
||||
REQUIRED. Value MUST be set to "authorization_code".
|
||||
|
||||
code
|
||||
REQUIRED. The authorization code received from the
|
||||
authorization server.
|
||||
|
||||
redirect_uri
|
||||
REQUIRED, if the "redirect_uri" parameter was included in the
|
||||
authorization request as described in Section 4.1.1, and their
|
||||
values MUST be identical.
|
||||
|
||||
client_id
|
||||
REQUIRED, if the client is not authenticating with the
|
||||
authorization server as described in Section 3.2.1.
|
||||
|
||||
If the client type is confidential or the client was issued client
|
||||
credentials (or assigned other authentication requirements), the
|
||||
client MUST authenticate with the authorization server as described
|
||||
in Section 3.2.1.
|
||||
|
||||
For example, the client makes the following HTTP request using TLS:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
POST /token HTTP/1.1
|
||||
Host: server.example.com
|
||||
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
|
||||
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
|
||||
|
||||
.. _`Section 4.1.3`: https://tools.ietf.org/html/rfc6749#section-4.1.3
|
||||
"""
|
||||
# ignore validate for grant_type, since it is validated by
|
||||
# check_token_endpoint
|
||||
|
||||
# authenticate the client if client authentication is included
|
||||
client = self.authenticate_token_endpoint_client()
|
||||
|
||||
log.debug('Validate token request of %r', client)
|
||||
if not client.check_grant_type(self.GRANT_TYPE):
|
||||
raise UnauthorizedClientError(
|
||||
f'The client is not authorized to use "grant_type={self.GRANT_TYPE}"')
|
||||
|
||||
code = self.request.form.get('code')
|
||||
if code is None:
|
||||
raise InvalidRequestError('Missing "code" in request.')
|
||||
|
||||
# ensure that the authorization code was issued to the authenticated
|
||||
# confidential client, or if the client is public, ensure that the
|
||||
# code was issued to "client_id" in the request
|
||||
authorization_code = self.query_authorization_code(code, client)
|
||||
if not authorization_code:
|
||||
raise InvalidGrantError('Invalid "code" in request.')
|
||||
|
||||
# validate redirect_uri parameter
|
||||
log.debug('Validate token redirect_uri of %r', client)
|
||||
redirect_uri = self.request.redirect_uri
|
||||
original_redirect_uri = authorization_code.get_redirect_uri()
|
||||
if original_redirect_uri and redirect_uri != original_redirect_uri:
|
||||
raise InvalidGrantError('Invalid "redirect_uri" in request.')
|
||||
|
||||
# save for create_token_response
|
||||
self.request.client = client
|
||||
self.request.authorization_code = authorization_code
|
||||
self.execute_hook('after_validate_token_request')
|
||||
|
||||
def create_token_response(self):
|
||||
"""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 client
|
||||
authentication failed or is invalid, the authorization server returns
|
||||
an error response as described in Section 5.2. Per `Section 4.1.4`_.
|
||||
|
||||
An example successful response:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Cache-Control: no-store
|
||||
Pragma: no-cache
|
||||
|
||||
{
|
||||
"access_token":"2YotnFZFEjr1zCsicMWpAA",
|
||||
"token_type":"example",
|
||||
"expires_in":3600,
|
||||
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
|
||||
"example_parameter":"example_value"
|
||||
}
|
||||
|
||||
:returns: (status_code, body, headers)
|
||||
|
||||
.. _`Section 4.1.4`: https://tools.ietf.org/html/rfc6749#section-4.1.4
|
||||
"""
|
||||
client = self.request.client
|
||||
authorization_code = self.request.authorization_code
|
||||
|
||||
user = self.authenticate_user(authorization_code)
|
||||
if not user:
|
||||
raise InvalidGrantError('There is no "user" for this code.')
|
||||
self.request.user = user
|
||||
|
||||
scope = authorization_code.get_scope()
|
||||
token = self.generate_token(
|
||||
user=user,
|
||||
scope=scope,
|
||||
include_refresh_token=client.check_grant_type('refresh_token'),
|
||||
)
|
||||
log.debug('Issue token %r to %r', token, client)
|
||||
|
||||
self.save_token(token)
|
||||
self.execute_hook('process_token', token=token)
|
||||
self.delete_authorization_code(authorization_code)
|
||||
return 200, token, self.TOKEN_RESPONSE_HEADER
|
||||
|
||||
def generate_authorization_code(self):
|
||||
""""The method to generate "code" value for authorization code data.
|
||||
Developers may rewrite this method, or customize the code length with::
|
||||
|
||||
class MyAuthorizationCodeGrant(AuthorizationCodeGrant):
|
||||
AUTHORIZATION_CODE_LENGTH = 32 # default is 48
|
||||
"""
|
||||
return generate_token(self.AUTHORIZATION_CODE_LENGTH)
|
||||
|
||||
def save_authorization_code(self, code, request):
|
||||
"""Save authorization_code for later use. Developers MUST implement
|
||||
it in subclass. Here is an example::
|
||||
|
||||
def save_authorization_code(self, code, request):
|
||||
client = request.client
|
||||
item = AuthorizationCode(
|
||||
code=code,
|
||||
client_id=client.client_id,
|
||||
redirect_uri=request.redirect_uri,
|
||||
scope=request.scope,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
item.save()
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def query_authorization_code(self, code, client): # pragma: no cover
|
||||
"""Get authorization_code from previously savings. Developers MUST
|
||||
implement it in subclass::
|
||||
|
||||
def query_authorization_code(self, code, client):
|
||||
return Authorization.get(code=code, client_id=client.client_id)
|
||||
|
||||
:param code: a string represent the code.
|
||||
:param client: client related to this code.
|
||||
:return: authorization_code object
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def delete_authorization_code(self, authorization_code):
|
||||
"""Delete authorization code from database or cache. Developers MUST
|
||||
implement it in subclass, e.g.::
|
||||
|
||||
def delete_authorization_code(self, authorization_code):
|
||||
authorization_code.delete()
|
||||
|
||||
:param authorization_code: the instance of authorization_code
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def authenticate_user(self, authorization_code):
|
||||
"""Authenticate the user related to this authorization_code. Developers
|
||||
MUST implement this method in subclass, e.g.::
|
||||
|
||||
def authenticate_user(self, authorization_code):
|
||||
return User.get(authorization_code.user_id)
|
||||
|
||||
:param authorization_code: AuthorizationCode object
|
||||
:return: user
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
def validate_code_authorization_request(grant):
|
||||
request = grant.request
|
||||
client_id = request.client_id
|
||||
log.debug('Validate authorization request of %r', client_id)
|
||||
|
||||
if client_id is None:
|
||||
raise InvalidClientError(state=request.state)
|
||||
|
||||
client = grant.server.query_client(client_id)
|
||||
if not client:
|
||||
raise InvalidClientError(state=request.state)
|
||||
|
||||
redirect_uri = grant.validate_authorization_redirect_uri(request, client)
|
||||
response_type = request.response_type
|
||||
if not client.check_response_type(response_type):
|
||||
raise UnauthorizedClientError(
|
||||
f'The client is not authorized to use "response_type={response_type}"',
|
||||
state=grant.request.state,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
try:
|
||||
grant.request.client = client
|
||||
grant.validate_requested_scope()
|
||||
grant.execute_hook('after_validate_authorization_request')
|
||||
except OAuth2Error as error:
|
||||
error.redirect_uri = redirect_uri
|
||||
raise error
|
||||
return redirect_uri
|
||||
@@ -0,0 +1,162 @@
|
||||
from authlib.consts import default_json_headers
|
||||
from authlib.common.urls import urlparse
|
||||
from ..requests import OAuth2Request
|
||||
from ..errors import InvalidRequestError
|
||||
|
||||
|
||||
class BaseGrant:
|
||||
#: Allowed client auth methods for token endpoint
|
||||
TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic']
|
||||
|
||||
#: Designed for which "grant_type"
|
||||
GRANT_TYPE = None
|
||||
|
||||
# NOTE: there is no charset for application/json, since
|
||||
# application/json should always in UTF-8.
|
||||
# The example on RFC is incorrect.
|
||||
# https://tools.ietf.org/html/rfc4627
|
||||
TOKEN_RESPONSE_HEADER = default_json_headers
|
||||
|
||||
def __init__(self, request: OAuth2Request, server):
|
||||
self.prompt = None
|
||||
self.redirect_uri = None
|
||||
self.request = request
|
||||
self.server = server
|
||||
self._hooks = {
|
||||
'after_validate_authorization_request': set(),
|
||||
'after_validate_consent_request': set(),
|
||||
'after_validate_token_request': set(),
|
||||
'process_token': set(),
|
||||
}
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
return self.request.client
|
||||
|
||||
def generate_token(self, user=None, scope=None, grant_type=None,
|
||||
expires_in=None, include_refresh_token=True):
|
||||
if grant_type is None:
|
||||
grant_type = self.GRANT_TYPE
|
||||
return self.server.generate_token(
|
||||
client=self.request.client,
|
||||
grant_type=grant_type,
|
||||
user=user,
|
||||
scope=scope,
|
||||
expires_in=expires_in,
|
||||
include_refresh_token=include_refresh_token,
|
||||
)
|
||||
|
||||
def authenticate_token_endpoint_client(self):
|
||||
"""Authenticate client with the given methods for token endpoint.
|
||||
|
||||
For example, the client makes the following HTTP request using TLS:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
POST /token HTTP/1.1
|
||||
Host: server.example.com
|
||||
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
|
||||
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
|
||||
|
||||
Default available methods are: "none", "client_secret_basic" and
|
||||
"client_secret_post".
|
||||
|
||||
:return: client
|
||||
"""
|
||||
client = self.server.authenticate_client(
|
||||
self.request, self.TOKEN_ENDPOINT_AUTH_METHODS)
|
||||
self.server.send_signal(
|
||||
'after_authenticate_client',
|
||||
client=client, grant=self)
|
||||
return client
|
||||
|
||||
def save_token(self, token):
|
||||
"""A method to save token into database."""
|
||||
return self.server.save_token(token, self.request)
|
||||
|
||||
def validate_requested_scope(self):
|
||||
"""Validate if requested scope is supported by Authorization Server."""
|
||||
scope = self.request.scope
|
||||
state = self.request.state
|
||||
return self.server.validate_requested_scope(scope, state)
|
||||
|
||||
def register_hook(self, hook_type, hook):
|
||||
if hook_type not in self._hooks:
|
||||
raise ValueError('Hook type %s is not in %s.',
|
||||
hook_type, self._hooks)
|
||||
self._hooks[hook_type].add(hook)
|
||||
|
||||
def execute_hook(self, hook_type, *args, **kwargs):
|
||||
for hook in self._hooks[hook_type]:
|
||||
hook(self, *args, **kwargs)
|
||||
|
||||
|
||||
class TokenEndpointMixin:
|
||||
#: Allowed HTTP methods of this token endpoint
|
||||
TOKEN_ENDPOINT_HTTP_METHODS = ['POST']
|
||||
|
||||
#: Designed for which "grant_type"
|
||||
GRANT_TYPE = None
|
||||
|
||||
@classmethod
|
||||
def check_token_endpoint(cls, request: OAuth2Request):
|
||||
return request.grant_type == cls.GRANT_TYPE and \
|
||||
request.method in cls.TOKEN_ENDPOINT_HTTP_METHODS
|
||||
|
||||
def validate_token_request(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_token_response(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class AuthorizationEndpointMixin:
|
||||
RESPONSE_TYPES = set()
|
||||
ERROR_RESPONSE_FRAGMENT = False
|
||||
|
||||
@classmethod
|
||||
def check_authorization_endpoint(cls, request: OAuth2Request):
|
||||
return request.response_type in cls.RESPONSE_TYPES
|
||||
|
||||
@staticmethod
|
||||
def validate_authorization_redirect_uri(request: OAuth2Request, client):
|
||||
if request.redirect_uri:
|
||||
if not client.check_redirect_uri(request.redirect_uri):
|
||||
raise InvalidRequestError(
|
||||
f'Redirect URI {request.redirect_uri} is not supported by client.',
|
||||
state=request.state)
|
||||
return request.redirect_uri
|
||||
else:
|
||||
redirect_uri = client.get_default_redirect_uri()
|
||||
if not redirect_uri:
|
||||
raise InvalidRequestError(
|
||||
'Missing "redirect_uri" in request.',
|
||||
state=request.state)
|
||||
return redirect_uri
|
||||
|
||||
@staticmethod
|
||||
def validate_no_multiple_request_parameter(request: OAuth2Request):
|
||||
"""For the Authorization Endpoint, request and response parameters MUST NOT be included
|
||||
more than once. Per `Section 3.1`_.
|
||||
|
||||
.. _`Section 3.1`: https://tools.ietf.org/html/rfc6749#section-3.1
|
||||
"""
|
||||
datalist = request.datalist
|
||||
parameters = ["response_type", "client_id", "redirect_uri", "scope", "state"]
|
||||
for param in parameters:
|
||||
if len(datalist.get(param, [])) > 1:
|
||||
raise InvalidRequestError(f'Multiple "{param}" in request.', state=request.state)
|
||||
|
||||
def validate_consent_request(self):
|
||||
redirect_uri = self.validate_authorization_request()
|
||||
self.execute_hook('after_validate_consent_request', redirect_uri)
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
def validate_authorization_request(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_authorization_response(self, redirect_uri: str, grant_user):
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
from .base import BaseGrant, TokenEndpointMixin
|
||||
from ..errors import UnauthorizedClientError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientCredentialsGrant(BaseGrant, TokenEndpointMixin):
|
||||
"""The client can request an access token using only its client
|
||||
credentials (or other supported means of authentication) when the
|
||||
client is requesting access to the protected resources under its
|
||||
control, or those of another resource owner that have been previously
|
||||
arranged with the authorization server.
|
||||
|
||||
The client credentials grant type MUST only be used by confidential
|
||||
clients::
|
||||
|
||||
+---------+ +---------------+
|
||||
| | | |
|
||||
| |>--(A)- Client Authentication --->| Authorization |
|
||||
| Client | | Server |
|
||||
| |<--(B)---- Access Token ---------<| |
|
||||
| | | |
|
||||
+---------+ +---------------+
|
||||
|
||||
https://tools.ietf.org/html/rfc6749#section-4.4
|
||||
"""
|
||||
GRANT_TYPE = 'client_credentials'
|
||||
|
||||
def validate_token_request(self):
|
||||
"""The client makes a request to the token endpoint by adding the
|
||||
following parameters using the "application/x-www-form-urlencoded"
|
||||
format per Appendix B with a character encoding of UTF-8 in the HTTP
|
||||
request entity-body:
|
||||
|
||||
grant_type
|
||||
REQUIRED. Value MUST be set to "client_credentials".
|
||||
|
||||
scope
|
||||
OPTIONAL. The scope of the access request as described by
|
||||
Section 3.3.
|
||||
|
||||
The client MUST authenticate with the authorization server as
|
||||
described in Section 3.2.1.
|
||||
|
||||
For example, the client makes the following HTTP request using
|
||||
transport-layer security (with extra line breaks for display purposes
|
||||
only):
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
POST /token HTTP/1.1
|
||||
Host: server.example.com
|
||||
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=client_credentials
|
||||
|
||||
The authorization server MUST authenticate the client.
|
||||
"""
|
||||
|
||||
# ignore validate for grant_type, since it is validated by
|
||||
# check_token_endpoint
|
||||
client = self.authenticate_token_endpoint_client()
|
||||
log.debug('Validate token request of %r', client)
|
||||
|
||||
if not client.check_grant_type(self.GRANT_TYPE):
|
||||
raise UnauthorizedClientError()
|
||||
|
||||
self.request.client = client
|
||||
self.validate_requested_scope()
|
||||
|
||||
def create_token_response(self):
|
||||
"""If the access token request is valid and authorized, the
|
||||
authorization server issues an access token as described in
|
||||
Section 5.1. A refresh token SHOULD NOT be included. If the request
|
||||
failed client authentication or is invalid, the authorization server
|
||||
returns an error response as described in Section 5.2.
|
||||
|
||||
An example successful response:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Cache-Control: no-store
|
||||
Pragma: no-cache
|
||||
|
||||
{
|
||||
"access_token":"2YotnFZFEjr1zCsicMWpAA",
|
||||
"token_type":"example",
|
||||
"expires_in":3600,
|
||||
"example_parameter":"example_value"
|
||||
}
|
||||
|
||||
:returns: (status_code, body, headers)
|
||||
"""
|
||||
token = self.generate_token(scope=self.request.scope, include_refresh_token=False)
|
||||
log.debug('Issue token %r to %r', token, self.client)
|
||||
self.save_token(token)
|
||||
self.execute_hook('process_token', self, token=token)
|
||||
return 200, token, self.TOKEN_RESPONSE_HEADER
|
||||
@@ -0,0 +1,229 @@
|
||||
import logging
|
||||
from authlib.common.urls import add_params_to_uri
|
||||
from .base import BaseGrant, AuthorizationEndpointMixin
|
||||
from ..errors import (
|
||||
OAuth2Error,
|
||||
UnauthorizedClientError,
|
||||
AccessDeniedError,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImplicitGrant(BaseGrant, AuthorizationEndpointMixin):
|
||||
"""The implicit grant type is used to obtain access tokens (it does not
|
||||
support the issuance of refresh tokens) and is optimized for public
|
||||
clients known to operate a particular redirection URI. These clients
|
||||
are typically implemented in a browser using a scripting language
|
||||
such as JavaScript.
|
||||
|
||||
Since this is a redirection-based flow, the client must be capable of
|
||||
interacting with the resource owner's user-agent (typically a web
|
||||
browser) and capable of receiving incoming requests (via redirection)
|
||||
from the authorization server.
|
||||
|
||||
Unlike the authorization code grant type, in which the client makes
|
||||
separate requests for authorization and for an access token, the
|
||||
client receives the access token as the result of the authorization
|
||||
request.
|
||||
|
||||
The implicit grant type does not include client authentication, and
|
||||
relies on the presence of the resource owner and the registration of
|
||||
the redirection URI. Because the access token is encoded into the
|
||||
redirection URI, it may be exposed to the resource owner and other
|
||||
applications residing on the same device::
|
||||
|
||||
+----------+
|
||||
| Resource |
|
||||
| Owner |
|
||||
| |
|
||||
+----------+
|
||||
^
|
||||
|
|
||||
(B)
|
||||
+----|-----+ Client Identifier +---------------+
|
||||
| -+----(A)-- & Redirection URI --->| |
|
||||
| User- | | Authorization |
|
||||
| Agent -|----(B)-- User authenticates -->| Server |
|
||||
| | | |
|
||||
| |<---(C)--- Redirection URI ----<| |
|
||||
| | with Access Token +---------------+
|
||||
| | in Fragment
|
||||
| | +---------------+
|
||||
| |----(D)--- Redirection URI ---->| Web-Hosted |
|
||||
| | without Fragment | Client |
|
||||
| | | Resource |
|
||||
| (F) |<---(E)------- Script ---------<| |
|
||||
| | +---------------+
|
||||
+-|--------+
|
||||
| |
|
||||
(A) (G) Access Token
|
||||
| |
|
||||
^ v
|
||||
+---------+
|
||||
| |
|
||||
| Client |
|
||||
| |
|
||||
+---------+
|
||||
"""
|
||||
#: authorization_code grant type has authorization endpoint
|
||||
AUTHORIZATION_ENDPOINT = True
|
||||
#: Allowed client auth methods for token endpoint
|
||||
TOKEN_ENDPOINT_AUTH_METHODS = ['none']
|
||||
|
||||
RESPONSE_TYPES = {'token'}
|
||||
GRANT_TYPE = 'implicit'
|
||||
ERROR_RESPONSE_FRAGMENT = True
|
||||
|
||||
def validate_authorization_request(self):
|
||||
"""The client constructs the request URI by adding the following
|
||||
parameters to the query component of the authorization endpoint URI
|
||||
using the "application/x-www-form-urlencoded" format.
|
||||
Per `Section 4.2.1`_.
|
||||
|
||||
response_type
|
||||
REQUIRED. Value MUST be set to "token".
|
||||
|
||||
client_id
|
||||
REQUIRED. The client identifier as described in Section 2.2.
|
||||
|
||||
redirect_uri
|
||||
OPTIONAL. As described in Section 3.1.2.
|
||||
|
||||
scope
|
||||
OPTIONAL. The scope of the access request as described by
|
||||
Section 3.3.
|
||||
|
||||
state
|
||||
RECOMMENDED. An opaque value used by the client to maintain
|
||||
state between the request and callback. The authorization
|
||||
server includes this value when redirecting the user-agent back
|
||||
to the client. The parameter SHOULD be used for preventing
|
||||
cross-site request forgery as described in Section 10.12.
|
||||
|
||||
The client directs the resource owner to the constructed URI using an
|
||||
HTTP redirection response, or by other means available to it via the
|
||||
user-agent.
|
||||
|
||||
For example, the client directs the user-agent to make the following
|
||||
HTTP request using TLS:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
GET /authorize?response_type=token&client_id=s6BhdRkqt3&state=xyz
|
||||
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
|
||||
Host: server.example.com
|
||||
|
||||
.. _`Section 4.2.1`: https://tools.ietf.org/html/rfc6749#section-4.2.1
|
||||
"""
|
||||
# ignore validate for response_type, since it is validated by
|
||||
# check_authorization_endpoint
|
||||
|
||||
# The implicit grant type is optimized for public clients
|
||||
client = self.authenticate_token_endpoint_client()
|
||||
log.debug('Validate authorization request of %r', client)
|
||||
|
||||
redirect_uri = self.validate_authorization_redirect_uri(
|
||||
self.request, client)
|
||||
|
||||
response_type = self.request.response_type
|
||||
if not client.check_response_type(response_type):
|
||||
raise UnauthorizedClientError(
|
||||
'The client is not authorized to use '
|
||||
'"response_type={}"'.format(response_type),
|
||||
state=self.request.state,
|
||||
redirect_uri=redirect_uri,
|
||||
redirect_fragment=True,
|
||||
)
|
||||
|
||||
try:
|
||||
self.request.client = client
|
||||
self.validate_requested_scope()
|
||||
self.execute_hook('after_validate_authorization_request')
|
||||
except OAuth2Error as error:
|
||||
error.redirect_uri = redirect_uri
|
||||
error.redirect_fragment = True
|
||||
raise error
|
||||
return redirect_uri
|
||||
|
||||
def create_authorization_response(self, redirect_uri, grant_user):
|
||||
"""If the resource owner grants the access request, the authorization
|
||||
server issues an access token and delivers it to the client by adding
|
||||
the following parameters to the fragment component of the redirection
|
||||
URI using the "application/x-www-form-urlencoded" format.
|
||||
Per `Section 4.2.2`_.
|
||||
|
||||
access_token
|
||||
REQUIRED. The access token issued by the authorization server.
|
||||
|
||||
token_type
|
||||
REQUIRED. The type of the token issued as described in
|
||||
Section 7.1. Value is case insensitive.
|
||||
|
||||
expires_in
|
||||
RECOMMENDED. The lifetime in seconds of the access token. For
|
||||
example, the value "3600" denotes that the access token will
|
||||
expire in one hour from the time the response was generated.
|
||||
If omitted, the authorization server SHOULD provide the
|
||||
expiration time via other means or document the default value.
|
||||
|
||||
scope
|
||||
OPTIONAL, if identical to the scope requested by the client;
|
||||
otherwise, REQUIRED. The scope of the access token as
|
||||
described by Section 3.3.
|
||||
|
||||
state
|
||||
REQUIRED if the "state" parameter was present in the client
|
||||
authorization request. The exact value received from the
|
||||
client.
|
||||
|
||||
The authorization server MUST NOT issue a refresh token.
|
||||
|
||||
For example, the authorization server redirects the user-agent by
|
||||
sending the following HTTP response:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
HTTP/1.1 302 Found
|
||||
Location: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA
|
||||
&state=xyz&token_type=example&expires_in=3600
|
||||
|
||||
Developers should note that some user-agents do not support the
|
||||
inclusion of a fragment component in the HTTP "Location" response
|
||||
header field. Such clients will require using other methods for
|
||||
redirecting the client than a 3xx redirection response -- for
|
||||
example, returning an HTML page that includes a 'continue' button
|
||||
with an action linked to the redirection URI.
|
||||
|
||||
.. _`Section 4.2.2`: https://tools.ietf.org/html/rfc6749#section-4.2.2
|
||||
|
||||
:param redirect_uri: Redirect to the given URI for the authorization
|
||||
:param grant_user: if resource owner granted the request, pass this
|
||||
resource owner, otherwise pass None.
|
||||
:returns: (status_code, body, headers)
|
||||
"""
|
||||
state = self.request.state
|
||||
if grant_user:
|
||||
self.request.user = grant_user
|
||||
token = self.generate_token(
|
||||
user=grant_user,
|
||||
scope=self.request.scope,
|
||||
include_refresh_token=False,
|
||||
)
|
||||
log.debug('Grant token %r to %r', token, self.request.client)
|
||||
|
||||
self.save_token(token)
|
||||
self.execute_hook('process_token', token=token)
|
||||
params = [(k, token[k]) for k in token]
|
||||
if state:
|
||||
params.append(('state', state))
|
||||
|
||||
uri = add_params_to_uri(redirect_uri, params, fragment=True)
|
||||
headers = [('Location', uri)]
|
||||
return 302, '', headers
|
||||
else:
|
||||
raise AccessDeniedError(
|
||||
state=state,
|
||||
redirect_uri=redirect_uri,
|
||||
redirect_fragment=True
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
authlib.oauth2.rfc6749.grants.refresh_token
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A special grant endpoint for refresh_token grant_type. Refreshing an
|
||||
Access Token per `Section 6`_.
|
||||
|
||||
.. _`Section 6`: https://tools.ietf.org/html/rfc6749#section-6
|
||||
"""
|
||||
|
||||
import logging
|
||||
from .base import BaseGrant, TokenEndpointMixin
|
||||
from ..util import scope_to_list
|
||||
from ..errors import (
|
||||
InvalidRequestError,
|
||||
InvalidScopeError,
|
||||
InvalidGrantError,
|
||||
UnauthorizedClientError,
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RefreshTokenGrant(BaseGrant, TokenEndpointMixin):
|
||||
"""A special grant endpoint for refresh_token grant_type. Refreshing an
|
||||
Access Token per `Section 6`_.
|
||||
|
||||
.. _`Section 6`: https://tools.ietf.org/html/rfc6749#section-6
|
||||
"""
|
||||
GRANT_TYPE = 'refresh_token'
|
||||
|
||||
#: The authorization server MAY issue a new refresh token
|
||||
INCLUDE_NEW_REFRESH_TOKEN = False
|
||||
|
||||
def _validate_request_client(self):
|
||||
# require client authentication for confidential clients or for any
|
||||
# client that was issued client credentials (or with other
|
||||
# authentication requirements)
|
||||
client = self.authenticate_token_endpoint_client()
|
||||
log.debug('Validate token request of %r', client)
|
||||
|
||||
if not client.check_grant_type(self.GRANT_TYPE):
|
||||
raise UnauthorizedClientError()
|
||||
|
||||
return client
|
||||
|
||||
def _validate_request_token(self, client):
|
||||
refresh_token = self.request.form.get('refresh_token')
|
||||
if refresh_token is None:
|
||||
raise InvalidRequestError('Missing "refresh_token" in request.')
|
||||
|
||||
token = self.authenticate_refresh_token(refresh_token)
|
||||
if not token or not token.check_client(client):
|
||||
raise InvalidGrantError()
|
||||
return token
|
||||
|
||||
def _validate_token_scope(self, token):
|
||||
scope = self.request.scope
|
||||
if not scope:
|
||||
return
|
||||
|
||||
original_scope = token.get_scope()
|
||||
if not original_scope:
|
||||
raise InvalidScopeError()
|
||||
|
||||
original_scope = set(scope_to_list(original_scope))
|
||||
if not original_scope.issuperset(set(scope_to_list(scope))):
|
||||
raise InvalidScopeError()
|
||||
|
||||
def validate_token_request(self):
|
||||
"""If the authorization server issued a refresh token to the client, the
|
||||
client makes a refresh request to the token endpoint by adding the
|
||||
following parameters using the "application/x-www-form-urlencoded"
|
||||
format per Appendix B with a character encoding of UTF-8 in the HTTP
|
||||
request entity-body, per Section 6:
|
||||
|
||||
grant_type
|
||||
REQUIRED. Value MUST be set to "refresh_token".
|
||||
|
||||
refresh_token
|
||||
REQUIRED. The refresh token issued to the client.
|
||||
|
||||
scope
|
||||
OPTIONAL. The scope of the access request as described by
|
||||
Section 3.3. The requested scope MUST NOT include any scope
|
||||
not originally granted by the resource owner, and if omitted is
|
||||
treated as equal to the scope originally granted by the
|
||||
resource owner.
|
||||
|
||||
|
||||
For example, the client makes the following HTTP request using
|
||||
transport-layer security (with extra line breaks for display purposes
|
||||
only):
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
POST /token HTTP/1.1
|
||||
Host: server.example.com
|
||||
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA
|
||||
"""
|
||||
client = self._validate_request_client()
|
||||
self.request.client = client
|
||||
refresh_token = self._validate_request_token(client)
|
||||
self._validate_token_scope(refresh_token)
|
||||
self.request.refresh_token = refresh_token
|
||||
|
||||
def create_token_response(self):
|
||||
"""If valid and authorized, the authorization server issues an access
|
||||
token as described in Section 5.1. If the request failed
|
||||
verification or is invalid, the authorization server returns an error
|
||||
response as described in Section 5.2.
|
||||
"""
|
||||
refresh_token = self.request.refresh_token
|
||||
user = self.authenticate_user(refresh_token)
|
||||
if not user:
|
||||
raise InvalidRequestError('There is no "user" for this token.')
|
||||
|
||||
client = self.request.client
|
||||
token = self.issue_token(user, refresh_token)
|
||||
log.debug('Issue token %r to %r', token, client)
|
||||
|
||||
self.request.user = user
|
||||
self.save_token(token)
|
||||
self.execute_hook('process_token', token=token)
|
||||
self.revoke_old_credential(refresh_token)
|
||||
return 200, token, self.TOKEN_RESPONSE_HEADER
|
||||
|
||||
def issue_token(self, user, refresh_token):
|
||||
scope = self.request.scope
|
||||
if not scope:
|
||||
scope = refresh_token.get_scope()
|
||||
|
||||
token = self.generate_token(
|
||||
user=user,
|
||||
scope=scope,
|
||||
include_refresh_token=self.INCLUDE_NEW_REFRESH_TOKEN,
|
||||
)
|
||||
return token
|
||||
|
||||
def authenticate_refresh_token(self, refresh_token):
|
||||
"""Get token information with refresh_token string. Developers MUST
|
||||
implement this method in subclass::
|
||||
|
||||
def authenticate_refresh_token(self, refresh_token):
|
||||
token = Token.get(refresh_token=refresh_token)
|
||||
if token and not token.refresh_token_revoked:
|
||||
return token
|
||||
|
||||
:param refresh_token: The refresh token issued to the client
|
||||
:return: token
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def authenticate_user(self, refresh_token):
|
||||
"""Authenticate the user related to this credential. Developers MUST
|
||||
implement this method in subclass::
|
||||
|
||||
def authenticate_user(self, credential):
|
||||
return User.get(credential.user_id)
|
||||
|
||||
:param refresh_token: Token object
|
||||
:return: user
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def revoke_old_credential(self, refresh_token):
|
||||
"""The authorization server MAY revoke the old refresh token after
|
||||
issuing a new refresh token to the client. Developers MUST implement
|
||||
this method in subclass::
|
||||
|
||||
def revoke_old_credential(self, refresh_token):
|
||||
credential.revoked = True
|
||||
credential.save()
|
||||
|
||||
:param refresh_token: Token object
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,154 @@
|
||||
import logging
|
||||
from .base import BaseGrant, TokenEndpointMixin
|
||||
from ..errors import (
|
||||
UnauthorizedClientError,
|
||||
InvalidRequestError,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceOwnerPasswordCredentialsGrant(BaseGrant, TokenEndpointMixin):
|
||||
"""The resource owner password credentials grant type is suitable in
|
||||
cases where the resource owner has a trust relationship with the
|
||||
client, such as the device operating system or a highly privileged
|
||||
|
||||
application. The authorization server should take special care when
|
||||
enabling this grant type and only allow it when other flows are not
|
||||
viable.
|
||||
|
||||
This grant type is suitable for clients capable of obtaining the
|
||||
resource owner's credentials (username and password, typically using
|
||||
an interactive form). It is also used to migrate existing clients
|
||||
using direct authentication schemes such as HTTP Basic or Digest
|
||||
authentication to OAuth by converting the stored credentials to an
|
||||
access token::
|
||||
|
||||
+----------+
|
||||
| Resource |
|
||||
| Owner |
|
||||
| |
|
||||
+----------+
|
||||
v
|
||||
| Resource Owner
|
||||
(A) Password Credentials
|
||||
|
|
||||
v
|
||||
+---------+ +---------------+
|
||||
| |>--(B)---- Resource Owner ------->| |
|
||||
| | Password Credentials | Authorization |
|
||||
| Client | | Server |
|
||||
| |<--(C)---- Access Token ---------<| |
|
||||
| | (w/ Optional Refresh Token) | |
|
||||
+---------+ +---------------+
|
||||
"""
|
||||
GRANT_TYPE = 'password'
|
||||
|
||||
def validate_token_request(self):
|
||||
"""The client makes a request to the token endpoint by adding the
|
||||
following parameters using the "application/x-www-form-urlencoded"
|
||||
format per Appendix B with a character encoding of UTF-8 in the HTTP
|
||||
request entity-body:
|
||||
|
||||
grant_type
|
||||
REQUIRED. Value MUST be set to "password".
|
||||
|
||||
username
|
||||
REQUIRED. The resource owner username.
|
||||
|
||||
password
|
||||
REQUIRED. The resource owner password.
|
||||
|
||||
scope
|
||||
OPTIONAL. The scope of the access request as described by
|
||||
Section 3.3.
|
||||
|
||||
If the client type is confidential or the client was issued client
|
||||
credentials (or assigned other authentication requirements), the
|
||||
client MUST authenticate with the authorization server as described
|
||||
in Section 3.2.1.
|
||||
|
||||
For example, the client makes the following HTTP request using
|
||||
transport-layer security (with extra line breaks for display purposes
|
||||
only):
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
POST /token HTTP/1.1
|
||||
Host: server.example.com
|
||||
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=password&username=johndoe&password=A3ddj3w
|
||||
"""
|
||||
# ignore validate for grant_type, since it is validated by
|
||||
# check_token_endpoint
|
||||
client = self.authenticate_token_endpoint_client()
|
||||
log.debug('Validate token request of %r', client)
|
||||
|
||||
if not client.check_grant_type(self.GRANT_TYPE):
|
||||
raise UnauthorizedClientError()
|
||||
|
||||
params = self.request.form
|
||||
if 'username' not in params:
|
||||
raise InvalidRequestError('Missing "username" in request.')
|
||||
if 'password' not in params:
|
||||
raise InvalidRequestError('Missing "password" in request.')
|
||||
|
||||
log.debug('Authenticate user of %r', params['username'])
|
||||
user = self.authenticate_user(
|
||||
params['username'],
|
||||
params['password']
|
||||
)
|
||||
if not user:
|
||||
raise InvalidRequestError(
|
||||
'Invalid "username" or "password" in request.',
|
||||
)
|
||||
self.request.client = client
|
||||
self.request.user = user
|
||||
self.validate_requested_scope()
|
||||
|
||||
def create_token_response(self):
|
||||
"""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.
|
||||
|
||||
An example successful response:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Cache-Control: no-store
|
||||
Pragma: no-cache
|
||||
|
||||
{
|
||||
"access_token":"2YotnFZFEjr1zCsicMWpAA",
|
||||
"token_type":"example",
|
||||
"expires_in":3600,
|
||||
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
|
||||
"example_parameter":"example_value"
|
||||
}
|
||||
|
||||
:returns: (status_code, body, headers)
|
||||
"""
|
||||
user = self.request.user
|
||||
scope = self.request.scope
|
||||
token = self.generate_token(user=user, scope=scope)
|
||||
log.debug('Issue token %r to %r', token, self.client)
|
||||
self.save_token(token)
|
||||
self.execute_hook('process_token', token=token)
|
||||
return 200, token, self.TOKEN_RESPONSE_HEADER
|
||||
|
||||
def authenticate_user(self, username, password):
|
||||
"""validate the resource owner password credentials using its
|
||||
existing password validation algorithm::
|
||||
|
||||
def authenticate_user(self, username, password):
|
||||
user = get_user_by_username(username)
|
||||
if user.check_password(password):
|
||||
return user
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
authlib.oauth2.rfc6749.models
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module defines how to construct Client, AuthorizationCode and Token.
|
||||
"""
|
||||
from authlib.deprecate import deprecate
|
||||
|
||||
|
||||
class ClientMixin:
|
||||
"""Implementation of OAuth 2 Client described in `Section 2`_ with
|
||||
some methods to help validation. A client has at least these information:
|
||||
|
||||
* client_id: A string represents client identifier.
|
||||
* client_secret: A string represents client password.
|
||||
* token_endpoint_auth_method: A way to authenticate client at token
|
||||
endpoint.
|
||||
|
||||
.. _`Section 2`: https://tools.ietf.org/html/rfc6749#section-2
|
||||
"""
|
||||
|
||||
def get_client_id(self):
|
||||
"""A method to return client_id of the client. For instance, the value
|
||||
in database is saved in a column called ``client_id``::
|
||||
|
||||
def get_client_id(self):
|
||||
return self.client_id
|
||||
|
||||
:return: string
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_default_redirect_uri(self):
|
||||
"""A method to get client default redirect_uri. For instance, the
|
||||
database table for client has a column called ``default_redirect_uri``::
|
||||
|
||||
def get_default_redirect_uri(self):
|
||||
return self.default_redirect_uri
|
||||
|
||||
:return: A URL string
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_allowed_scope(self, scope):
|
||||
"""A method to return a list of requested scopes which are supported by
|
||||
this client. For instance, there is a ``scope`` column::
|
||||
|
||||
def get_allowed_scope(self, scope):
|
||||
if not scope:
|
||||
return ''
|
||||
allowed = set(scope_to_list(self.scope))
|
||||
return list_to_scope([s for s in scope.split() if s in allowed])
|
||||
|
||||
:param scope: the requested scope.
|
||||
:return: string of scope
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def check_redirect_uri(self, redirect_uri):
|
||||
"""Validate redirect_uri parameter in Authorization Endpoints. For
|
||||
instance, in the client table, there is an ``allowed_redirect_uris``
|
||||
column::
|
||||
|
||||
def check_redirect_uri(self, redirect_uri):
|
||||
return redirect_uri in self.allowed_redirect_uris
|
||||
|
||||
:param redirect_uri: A URL string for redirecting.
|
||||
:return: bool
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def check_client_secret(self, client_secret):
|
||||
"""Check client_secret matching with the client. For instance, in
|
||||
the client table, the column is called ``client_secret``::
|
||||
|
||||
import secrets
|
||||
|
||||
def check_client_secret(self, client_secret):
|
||||
return secrets.compare_digest(self.client_secret, client_secret)
|
||||
|
||||
:param client_secret: A string of client secret
|
||||
:return: bool
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def check_endpoint_auth_method(self, method, endpoint):
|
||||
"""Check if client support the given method for the given endpoint.
|
||||
There is a ``token_endpoint_auth_method`` defined via `RFC7591`_.
|
||||
Developers MAY re-implement this method with::
|
||||
|
||||
def check_endpoint_auth_method(self, method, endpoint):
|
||||
if endpoint == 'token':
|
||||
# if client table has ``token_endpoint_auth_method``
|
||||
return self.token_endpoint_auth_method == method
|
||||
return True
|
||||
|
||||
Method values defined by this specification are:
|
||||
|
||||
* "none": The client is a public client as defined in OAuth 2.0,
|
||||
and does not have a client secret.
|
||||
|
||||
* "client_secret_post": The client uses the HTTP POST parameters
|
||||
as defined in OAuth 2.0
|
||||
|
||||
* "client_secret_basic": The client uses HTTP Basic as defined in
|
||||
OAuth 2.0
|
||||
|
||||
.. _`RFC7591`: https://tools.ietf.org/html/rfc7591
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def check_token_endpoint_auth_method(self, method):
|
||||
deprecate('Please implement ``check_endpoint_auth_method`` instead.')
|
||||
return self.check_endpoint_auth_method(method, 'token')
|
||||
|
||||
def check_response_type(self, response_type):
|
||||
"""Validate if the client can handle the given response_type. There
|
||||
are two response types defined by RFC6749: code and token. For
|
||||
instance, there is a ``allowed_response_types`` column in your client::
|
||||
|
||||
def check_response_type(self, response_type):
|
||||
return response_type in self.response_types
|
||||
|
||||
:param response_type: the requested response_type string.
|
||||
:return: bool
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def check_grant_type(self, grant_type):
|
||||
"""Validate if the client can handle the given grant_type. There are
|
||||
four grant types defined by RFC6749:
|
||||
|
||||
* authorization_code
|
||||
* implicit
|
||||
* client_credentials
|
||||
* password
|
||||
|
||||
For instance, there is a ``allowed_grant_types`` column in your client::
|
||||
|
||||
def check_grant_type(self, grant_type):
|
||||
return grant_type in self.grant_types
|
||||
|
||||
:param grant_type: the requested grant_type string.
|
||||
:return: bool
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class AuthorizationCodeMixin:
|
||||
def get_redirect_uri(self):
|
||||
"""A method to get authorization code's ``redirect_uri``.
|
||||
For instance, the database table for authorization code has a
|
||||
column called ``redirect_uri``::
|
||||
|
||||
def get_redirect_uri(self):
|
||||
return self.redirect_uri
|
||||
|
||||
:return: A URL string
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_scope(self):
|
||||
"""A method to get scope of the authorization code. For instance,
|
||||
the column is called ``scope``::
|
||||
|
||||
def get_scope(self):
|
||||
return self.scope
|
||||
|
||||
:return: scope string
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class TokenMixin:
|
||||
def check_client(self, client):
|
||||
"""A method to check if this token is issued to the given client.
|
||||
For instance, ``client_id`` is saved on token table::
|
||||
|
||||
def check_client(self, client):
|
||||
return self.client_id == client.client_id
|
||||
|
||||
:return: bool
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_scope(self):
|
||||
"""A method to get scope of the authorization code. For instance,
|
||||
the column is called ``scope``::
|
||||
|
||||
def get_scope(self):
|
||||
return self.scope
|
||||
|
||||
:return: scope string
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_expires_in(self):
|
||||
"""A method to get the ``expires_in`` value of the token. e.g.
|
||||
the column is called ``expires_in``::
|
||||
|
||||
def get_expires_in(self):
|
||||
return self.expires_in
|
||||
|
||||
:return: timestamp int
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def is_expired(self):
|
||||
"""A method to define if this token is expired. For instance,
|
||||
there is a column ``expired_at`` in the table::
|
||||
|
||||
def is_expired(self):
|
||||
return self.expired_at < now
|
||||
|
||||
:return: boolean
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def is_revoked(self):
|
||||
"""A method to define if this token is revoked. For instance,
|
||||
there is a boolean column ``revoked`` in the table::
|
||||
|
||||
def is_revoked(self):
|
||||
return self.revoked
|
||||
|
||||
:return: boolean
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,214 @@
|
||||
from authlib.common.urls import (
|
||||
urlparse,
|
||||
add_params_to_uri,
|
||||
add_params_to_qs,
|
||||
)
|
||||
from authlib.common.encoding import to_unicode
|
||||
from .errors import (
|
||||
MissingCodeException,
|
||||
MissingTokenException,
|
||||
MissingTokenTypeException,
|
||||
MismatchingStateException,
|
||||
)
|
||||
from .util import list_to_scope
|
||||
|
||||
|
||||
def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None,
|
||||
scope=None, state=None, **kwargs):
|
||||
"""Prepare the authorization grant request URI.
|
||||
|
||||
The client constructs the request URI by adding the following
|
||||
parameters to the query component of the authorization endpoint URI
|
||||
using the ``application/x-www-form-urlencoded`` format:
|
||||
|
||||
:param uri: The authorize endpoint to fetch "code" or "token".
|
||||
:param client_id: The client identifier as described in `Section 2.2`_.
|
||||
:param response_type: To indicate which OAuth 2 grant/flow is required,
|
||||
"code" and "token".
|
||||
:param redirect_uri: The client provided URI to redirect back to after
|
||||
authorization as described in `Section 3.1.2`_.
|
||||
:param scope: The scope of the access request as described by
|
||||
`Section 3.3`_.
|
||||
:param state: An opaque value used by the client to maintain
|
||||
state between the request and callback. The authorization
|
||||
server includes this value when redirecting the user-agent
|
||||
back to the client. The parameter SHOULD be used for
|
||||
preventing cross-site request forgery as described in
|
||||
`Section 10.12`_.
|
||||
:param kwargs: Extra arguments to embed in the grant/authorization URL.
|
||||
|
||||
An example of an authorization code grant authorization URL::
|
||||
|
||||
/authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
|
||||
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
|
||||
|
||||
.. _`Section 2.2`: https://tools.ietf.org/html/rfc6749#section-2.2
|
||||
.. _`Section 3.1.2`: https://tools.ietf.org/html/rfc6749#section-3.1.2
|
||||
.. _`Section 3.3`: https://tools.ietf.org/html/rfc6749#section-3.3
|
||||
.. _`section 10.12`: https://tools.ietf.org/html/rfc6749#section-10.12
|
||||
"""
|
||||
params = [
|
||||
('response_type', response_type),
|
||||
('client_id', client_id)
|
||||
]
|
||||
|
||||
if redirect_uri:
|
||||
params.append(('redirect_uri', redirect_uri))
|
||||
if scope:
|
||||
params.append(('scope', list_to_scope(scope)))
|
||||
if state:
|
||||
params.append(('state', state))
|
||||
|
||||
for k in kwargs:
|
||||
if kwargs[k] is not None:
|
||||
params.append((to_unicode(k), kwargs[k]))
|
||||
|
||||
return add_params_to_uri(uri, params)
|
||||
|
||||
|
||||
def prepare_token_request(grant_type, body='', redirect_uri=None, **kwargs):
|
||||
"""Prepare the access token request. Per `Section 4.1.3`_.
|
||||
|
||||
The client makes a request to the token endpoint by adding the
|
||||
following parameters using the ``application/x-www-form-urlencoded``
|
||||
format in the HTTP request entity-body:
|
||||
|
||||
:param grant_type: To indicate grant type being used, i.e. "password",
|
||||
"authorization_code" or "client_credentials".
|
||||
:param body: Existing request body to embed parameters in.
|
||||
:param redirect_uri: If the "redirect_uri" parameter was included in the
|
||||
authorization request as described in
|
||||
`Section 4.1.1`_, and their values MUST be identical.
|
||||
:param kwargs: Extra arguments to embed in the request body.
|
||||
|
||||
An example of an authorization code token request body::
|
||||
|
||||
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
|
||||
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
|
||||
|
||||
.. _`Section 4.1.1`: https://tools.ietf.org/html/rfc6749#section-4.1.1
|
||||
.. _`Section 4.1.3`: https://tools.ietf.org/html/rfc6749#section-4.1.3
|
||||
"""
|
||||
params = [('grant_type', grant_type)]
|
||||
|
||||
if redirect_uri:
|
||||
params.append(('redirect_uri', redirect_uri))
|
||||
|
||||
if 'scope' in kwargs:
|
||||
kwargs['scope'] = list_to_scope(kwargs['scope'])
|
||||
|
||||
if grant_type == 'authorization_code' and 'code' not in kwargs:
|
||||
raise MissingCodeException()
|
||||
|
||||
for k in kwargs:
|
||||
if kwargs[k]:
|
||||
params.append((to_unicode(k), kwargs[k]))
|
||||
|
||||
return add_params_to_qs(body, params)
|
||||
|
||||
|
||||
def parse_authorization_code_response(uri, state=None):
|
||||
"""Parse authorization grant response URI into a dict.
|
||||
|
||||
If the resource owner grants the access request, the authorization
|
||||
server issues an authorization code and delivers it to the client by
|
||||
adding the following parameters to the query component of the
|
||||
redirection URI using the ``application/x-www-form-urlencoded`` format:
|
||||
|
||||
**code**
|
||||
REQUIRED. The authorization code generated by the
|
||||
authorization server. The authorization code MUST expire
|
||||
shortly after it is issued to mitigate the risk of leaks. A
|
||||
maximum authorization code lifetime of 10 minutes is
|
||||
RECOMMENDED. The client MUST NOT use the authorization code
|
||||
more than once. If an authorization code is used more than
|
||||
once, the authorization server MUST deny the request and SHOULD
|
||||
revoke (when possible) all tokens previously issued based on
|
||||
that authorization code. The authorization code is bound to
|
||||
the client identifier and redirection URI.
|
||||
|
||||
**state**
|
||||
REQUIRED if the "state" parameter was present in the client
|
||||
authorization request. The exact value received from the
|
||||
client.
|
||||
|
||||
:param uri: The full redirect URL back to the client.
|
||||
:param state: The state parameter from the authorization request.
|
||||
|
||||
For example, the authorization server redirects the user-agent by
|
||||
sending the following HTTP response:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
HTTP/1.1 302 Found
|
||||
Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA
|
||||
&state=xyz
|
||||
|
||||
"""
|
||||
query = urlparse.urlparse(uri).query
|
||||
params = dict(urlparse.parse_qsl(query))
|
||||
|
||||
if 'code' not in params:
|
||||
raise MissingCodeException()
|
||||
|
||||
params_state = params.get('state')
|
||||
if state and params_state != state:
|
||||
raise MismatchingStateException()
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def parse_implicit_response(uri, state=None):
|
||||
"""Parse the implicit token response URI into a dict.
|
||||
|
||||
If the resource owner grants the access request, the authorization
|
||||
server issues an access token and delivers it to the client by adding
|
||||
the following parameters to the fragment component of the redirection
|
||||
URI using the ``application/x-www-form-urlencoded`` format:
|
||||
|
||||
**access_token**
|
||||
REQUIRED. The access token issued by the authorization server.
|
||||
|
||||
**token_type**
|
||||
REQUIRED. The type of the token issued as described in
|
||||
Section 7.1. Value is case insensitive.
|
||||
|
||||
**expires_in**
|
||||
RECOMMENDED. The lifetime in seconds of the access token. For
|
||||
example, the value "3600" denotes that the access token will
|
||||
expire in one hour from the time the response was generated.
|
||||
If omitted, the authorization server SHOULD provide the
|
||||
expiration time via other means or document the default value.
|
||||
|
||||
**scope**
|
||||
OPTIONAL, if identical to the scope requested by the client,
|
||||
otherwise REQUIRED. The scope of the access token as described
|
||||
by Section 3.3.
|
||||
|
||||
**state**
|
||||
REQUIRED if the "state" parameter was present in the client
|
||||
authorization request. The exact value received from the
|
||||
client.
|
||||
|
||||
Similar to the authorization code response, but with a full token provided
|
||||
in the URL fragment:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
HTTP/1.1 302 Found
|
||||
Location: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA
|
||||
&state=xyz&token_type=example&expires_in=3600
|
||||
"""
|
||||
fragment = urlparse.urlparse(uri).fragment
|
||||
params = dict(urlparse.parse_qsl(fragment, keep_blank_values=True))
|
||||
|
||||
if 'access_token' not in params:
|
||||
raise MissingTokenException()
|
||||
|
||||
if 'token_type' not in params:
|
||||
raise MissingTokenTypeException()
|
||||
|
||||
if state and params.get('state', None) != state:
|
||||
raise MismatchingStateException()
|
||||
|
||||
return params
|
||||
@@ -0,0 +1,103 @@
|
||||
from collections import defaultdict
|
||||
from typing import DefaultDict
|
||||
|
||||
from authlib.common.encoding import json_loads
|
||||
from authlib.common.urls import urlparse, url_decode
|
||||
from .errors import InsecureTransportError
|
||||
|
||||
|
||||
class OAuth2Request:
|
||||
def __init__(self, method: str, uri: str, body=None, headers=None):
|
||||
InsecureTransportError.check(uri)
|
||||
#: HTTP method
|
||||
self.method = method
|
||||
self.uri = uri
|
||||
self.body = body
|
||||
#: HTTP headers
|
||||
self.headers = headers or {}
|
||||
|
||||
self.client = None
|
||||
self.auth_method = None
|
||||
self.user = None
|
||||
self.authorization_code = None
|
||||
self.refresh_token = None
|
||||
self.credential = None
|
||||
|
||||
self._parsed_query = None
|
||||
|
||||
@property
|
||||
def args(self):
|
||||
if self._parsed_query is None:
|
||||
self._parsed_query = url_decode(urlparse.urlparse(self.uri).query)
|
||||
return dict(self._parsed_query)
|
||||
|
||||
@property
|
||||
def form(self):
|
||||
return self.body or {}
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
data = {}
|
||||
data.update(self.args)
|
||||
data.update(self.form)
|
||||
return data
|
||||
|
||||
@property
|
||||
def datalist(self) -> DefaultDict[str, list]:
|
||||
""" Return all the data in query parameters and the body of the request as a dictionary with all the values
|
||||
in lists. """
|
||||
if self._parsed_query is None:
|
||||
self._parsed_query = url_decode(urlparse.urlparse(self.uri).query)
|
||||
values = defaultdict(list)
|
||||
for k, v in self._parsed_query:
|
||||
values[k].append(v)
|
||||
for k, v in self.form.items():
|
||||
values[k].append(v)
|
||||
return values
|
||||
|
||||
@property
|
||||
def client_id(self) -> str:
|
||||
"""The authorization server issues the registered client a client
|
||||
identifier -- a unique string representing the registration
|
||||
information provided by the client. The value is extracted from
|
||||
request.
|
||||
|
||||
:return: string
|
||||
"""
|
||||
return self.data.get('client_id')
|
||||
|
||||
@property
|
||||
def response_type(self) -> str:
|
||||
rt = self.data.get('response_type')
|
||||
if rt and ' ' in rt:
|
||||
# sort multiple response types
|
||||
return ' '.join(sorted(rt.split()))
|
||||
return rt
|
||||
|
||||
@property
|
||||
def grant_type(self) -> str:
|
||||
return self.form.get('grant_type')
|
||||
|
||||
@property
|
||||
def redirect_uri(self):
|
||||
return self.data.get('redirect_uri')
|
||||
|
||||
@property
|
||||
def scope(self) -> str:
|
||||
return self.data.get('scope')
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self.data.get('state')
|
||||
|
||||
|
||||
class JsonRequest:
|
||||
def __init__(self, method, uri, body=None, headers=None):
|
||||
self.method = method
|
||||
self.uri = uri
|
||||
self.body = body
|
||||
self.headers = headers or {}
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return json_loads(self.body)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
authlib.oauth2.rfc6749.resource_protector
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Implementation of Accessing Protected Resources per `Section 7`_.
|
||||
|
||||
.. _`Section 7`: https://tools.ietf.org/html/rfc6749#section-7
|
||||
"""
|
||||
from .util import scope_to_list
|
||||
from .errors import MissingAuthorizationError, UnsupportedTokenTypeError
|
||||
|
||||
|
||||
class TokenValidator:
|
||||
"""Base token validator class. Subclass this validator to register
|
||||
into ResourceProtector instance.
|
||||
"""
|
||||
TOKEN_TYPE = 'bearer'
|
||||
|
||||
def __init__(self, realm=None, **extra_attributes):
|
||||
self.realm = realm
|
||||
self.extra_attributes = extra_attributes
|
||||
|
||||
@staticmethod
|
||||
def scope_insufficient(token_scopes, required_scopes):
|
||||
if not required_scopes:
|
||||
return False
|
||||
|
||||
token_scopes = scope_to_list(token_scopes)
|
||||
if not token_scopes:
|
||||
return True
|
||||
|
||||
token_scopes = set(token_scopes)
|
||||
for scope in required_scopes:
|
||||
resource_scopes = set(scope_to_list(scope))
|
||||
if token_scopes.issuperset(resource_scopes):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def authenticate_token(self, token_string):
|
||||
"""A method to query token from database with the given token string.
|
||||
Developers MUST re-implement this method. For instance::
|
||||
|
||||
def authenticate_token(self, token_string):
|
||||
return get_token_from_database(token_string)
|
||||
|
||||
:param token_string: A string to represent the access_token.
|
||||
:return: token
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def validate_request(self, request):
|
||||
"""A method to validate if the HTTP request is valid or not. Developers MUST
|
||||
re-implement this method. For instance, your server requires a
|
||||
"X-Device-Version" in the header::
|
||||
|
||||
def validate_request(self, request):
|
||||
if 'X-Device-Version' not in request.headers:
|
||||
raise InvalidRequestError()
|
||||
|
||||
Usually, you don't have to detect if the request is valid or not. If you have
|
||||
to, you MUST re-implement this method.
|
||||
|
||||
:param request: instance of HttpRequest
|
||||
:raise: InvalidRequestError
|
||||
"""
|
||||
|
||||
def validate_token(self, token, scopes, request):
|
||||
"""A method to validate if the authorized token is valid, if it has the
|
||||
permission on the given scopes. Developers MUST re-implement this method.
|
||||
e.g, check if token is expired, revoked::
|
||||
|
||||
def validate_token(self, token, scopes, request):
|
||||
if not token:
|
||||
raise InvalidTokenError()
|
||||
if token.is_expired() or token.is_revoked():
|
||||
raise InvalidTokenError()
|
||||
if not match_token_scopes(token, scopes):
|
||||
raise InsufficientScopeError()
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class ResourceProtector:
|
||||
def __init__(self):
|
||||
self._token_validators = {}
|
||||
self._default_realm = None
|
||||
self._default_auth_type = None
|
||||
|
||||
def register_token_validator(self, validator: TokenValidator):
|
||||
"""Register a token validator for a given Authorization type.
|
||||
Authlib has a built-in BearerTokenValidator per rfc6750.
|
||||
"""
|
||||
if not self._default_auth_type:
|
||||
self._default_realm = validator.realm
|
||||
self._default_auth_type = validator.TOKEN_TYPE
|
||||
|
||||
if validator.TOKEN_TYPE not in self._token_validators:
|
||||
self._token_validators[validator.TOKEN_TYPE] = validator
|
||||
|
||||
def get_token_validator(self, token_type):
|
||||
"""Get token validator from registry for the given token type."""
|
||||
validator = self._token_validators.get(token_type.lower())
|
||||
if not validator:
|
||||
raise UnsupportedTokenTypeError(self._default_auth_type, self._default_realm)
|
||||
return validator
|
||||
|
||||
def parse_request_authorization(self, request):
|
||||
"""Parse the token and token validator from request Authorization header.
|
||||
Here is an example of Authorization header::
|
||||
|
||||
Authorization: Bearer a-token-string
|
||||
|
||||
This method will parse this header, if it can find the validator for
|
||||
``Bearer``, it will return the validator and ``a-token-string``.
|
||||
|
||||
:return: validator, token_string
|
||||
:raise: MissingAuthorizationError
|
||||
:raise: UnsupportedTokenTypeError
|
||||
"""
|
||||
auth = request.headers.get('Authorization')
|
||||
if not auth:
|
||||
raise MissingAuthorizationError(self._default_auth_type, self._default_realm)
|
||||
|
||||
# https://tools.ietf.org/html/rfc6749#section-7.1
|
||||
token_parts = auth.split(None, 1)
|
||||
if len(token_parts) != 2:
|
||||
raise UnsupportedTokenTypeError(self._default_auth_type, self._default_realm)
|
||||
|
||||
token_type, token_string = token_parts
|
||||
validator = self.get_token_validator(token_type)
|
||||
return validator, token_string
|
||||
|
||||
def validate_request(self, scopes, request, **kwargs):
|
||||
"""Validate the request and return a token."""
|
||||
validator, token_string = self.parse_request_authorization(request)
|
||||
validator.validate_request(request)
|
||||
token = validator.authenticate_token(token_string)
|
||||
validator.validate_token(token, scopes, request, **kwargs)
|
||||
return token
|
||||
@@ -0,0 +1,32 @@
|
||||
class TokenEndpoint:
|
||||
#: Endpoint name to be registered
|
||||
ENDPOINT_NAME = None
|
||||
#: Supported token types
|
||||
SUPPORTED_TOKEN_TYPES = ('access_token', 'refresh_token')
|
||||
#: Allowed client authenticate methods
|
||||
CLIENT_AUTH_METHODS = ['client_secret_basic']
|
||||
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
|
||||
def __call__(self, request):
|
||||
# make it callable for authorization server
|
||||
# ``create_endpoint_response``
|
||||
return self.create_endpoint_response(request)
|
||||
|
||||
def create_endpoint_request(self, request):
|
||||
return self.server.create_oauth2_request(request)
|
||||
|
||||
def authenticate_endpoint_client(self, request):
|
||||
"""Authentication client for endpoint with ``CLIENT_AUTH_METHODS``.
|
||||
"""
|
||||
client = self.server.authenticate_client(
|
||||
request, self.CLIENT_AUTH_METHODS, self.ENDPOINT_NAME)
|
||||
request.client = client
|
||||
return client
|
||||
|
||||
def authenticate_token(self, request, client):
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_endpoint_response(self, request):
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,41 @@
|
||||
import base64
|
||||
import binascii
|
||||
from urllib.parse import unquote
|
||||
from authlib.common.encoding import to_unicode
|
||||
|
||||
|
||||
def list_to_scope(scope):
|
||||
"""Convert a list of scopes to a space separated string."""
|
||||
if isinstance(scope, (set, tuple, list)):
|
||||
return " ".join([to_unicode(s) for s in scope])
|
||||
if scope is None:
|
||||
return scope
|
||||
return to_unicode(scope)
|
||||
|
||||
|
||||
def scope_to_list(scope):
|
||||
"""Convert a space separated string to a list of scopes."""
|
||||
if isinstance(scope, (tuple, list, set)):
|
||||
return [to_unicode(s) for s in scope]
|
||||
elif scope is None:
|
||||
return None
|
||||
return scope.strip().split()
|
||||
|
||||
|
||||
def extract_basic_authorization(headers):
|
||||
auth = headers.get('Authorization')
|
||||
if not auth or ' ' not in auth:
|
||||
return None, None
|
||||
|
||||
auth_type, auth_token = auth.split(None, 1)
|
||||
if auth_type.lower() != 'basic':
|
||||
return None, None
|
||||
|
||||
try:
|
||||
query = to_unicode(base64.b64decode(auth_token))
|
||||
except (binascii.Error, TypeError):
|
||||
return None, None
|
||||
if ':' in query:
|
||||
username, password = query.split(':', 1)
|
||||
return unquote(username), unquote(password)
|
||||
return query, None
|
||||
@@ -0,0 +1,25 @@
|
||||
import time
|
||||
|
||||
|
||||
class OAuth2Token(dict):
|
||||
def __init__(self, params):
|
||||
if params.get('expires_at'):
|
||||
params['expires_at'] = int(params['expires_at'])
|
||||
elif params.get('expires_in'):
|
||||
params['expires_at'] = int(time.time()) + \
|
||||
int(params['expires_in'])
|
||||
super().__init__(params)
|
||||
|
||||
def is_expired(self, leeway=60):
|
||||
expires_at = self.get('expires_at')
|
||||
if not expires_at:
|
||||
return None
|
||||
# small timedelta to consider token as expired before it actually expires
|
||||
expiration_threshold = expires_at - leeway
|
||||
return expiration_threshold < time.time()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, token):
|
||||
if isinstance(token, dict) and not isinstance(token, cls):
|
||||
token = cls(token)
|
||||
return token
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
authlib.oauth2.rfc6750
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module represents a direct implementation of
|
||||
The OAuth 2.0 Authorization Framework: Bearer Token Usage.
|
||||
|
||||
https://tools.ietf.org/html/rfc6750
|
||||
"""
|
||||
|
||||
from .errors import InvalidTokenError, InsufficientScopeError
|
||||
from .parameters import add_bearer_token
|
||||
from .token import BearerTokenGenerator
|
||||
from .validator import BearerTokenValidator
|
||||
|
||||
# TODO: add deprecation
|
||||
BearerToken = BearerTokenGenerator
|
||||
|
||||
|
||||
__all__ = [
|
||||
'InvalidTokenError', 'InsufficientScopeError',
|
||||
'add_bearer_token',
|
||||
'BearerToken',
|
||||
'BearerTokenGenerator',
|
||||
'BearerTokenValidator',
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
authlib.rfc6750.errors
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
OAuth Extensions Error Registration. When a request fails,
|
||||
the resource server responds using the appropriate HTTP
|
||||
status code and includes one of the following error codes
|
||||
in the response.
|
||||
|
||||
https://tools.ietf.org/html/rfc6750#section-6.2
|
||||
|
||||
:copyright: (c) 2017 by Hsiaoming Yang.
|
||||
"""
|
||||
from ..base import OAuth2Error
|
||||
|
||||
__all__ = [
|
||||
'InvalidTokenError', 'InsufficientScopeError'
|
||||
]
|
||||
|
||||
|
||||
class InvalidTokenError(OAuth2Error):
|
||||
"""The access token provided is expired, revoked, malformed, or
|
||||
invalid for other reasons. The resource SHOULD respond with
|
||||
the HTTP 401 (Unauthorized) status code. The client MAY
|
||||
request a new access token and retry the protected resource
|
||||
request.
|
||||
|
||||
https://tools.ietf.org/html/rfc6750#section-3.1
|
||||
"""
|
||||
error = 'invalid_token'
|
||||
description = (
|
||||
'The access token provided is expired, revoked, malformed, '
|
||||
'or invalid for other reasons.'
|
||||
)
|
||||
status_code = 401
|
||||
|
||||
def __init__(self, description=None, uri=None, status_code=None,
|
||||
state=None, realm=None, **extra_attributes):
|
||||
super().__init__(
|
||||
description, uri, status_code, state)
|
||||
self.realm = realm
|
||||
self.extra_attributes = extra_attributes
|
||||
|
||||
def get_headers(self):
|
||||
"""If the protected resource request does not include authentication
|
||||
credentials or does not contain an access token that enables access
|
||||
to the protected resource, the resource server MUST include the HTTP
|
||||
"WWW-Authenticate" response header field; it MAY include it in
|
||||
response to other conditions as well.
|
||||
|
||||
https://tools.ietf.org/html/rfc6750#section-3
|
||||
"""
|
||||
headers = super().get_headers()
|
||||
|
||||
extras = []
|
||||
if self.realm:
|
||||
extras.append(f'realm="{self.realm}"')
|
||||
if self.extra_attributes:
|
||||
extras.extend([f'{k}="{self.extra_attributes[k]}"' for k in self.extra_attributes])
|
||||
extras.append(f'error="{self.error}"')
|
||||
error_description = self.get_error_description()
|
||||
extras.append(f'error_description="{error_description}"')
|
||||
headers.append(
|
||||
('WWW-Authenticate', 'Bearer ' + ', '.join(extras))
|
||||
)
|
||||
return headers
|
||||
|
||||
|
||||
class InsufficientScopeError(OAuth2Error):
|
||||
"""The request requires higher privileges than provided by the
|
||||
access token. The resource server SHOULD respond with the HTTP
|
||||
403 (Forbidden) status code and MAY include the "scope"
|
||||
attribute with the scope necessary to access the protected
|
||||
resource.
|
||||
|
||||
https://tools.ietf.org/html/rfc6750#section-3.1
|
||||
"""
|
||||
error = 'insufficient_scope'
|
||||
description = 'The request requires higher privileges than provided by the access token.'
|
||||
status_code = 403
|
||||
@@ -0,0 +1,41 @@
|
||||
from authlib.common.urls import add_params_to_qs, add_params_to_uri
|
||||
|
||||
|
||||
def add_to_uri(token, uri):
|
||||
"""Add a Bearer Token to the request URI.
|
||||
Not recommended, use only if client can't use authorization header or body.
|
||||
|
||||
http://www.example.com/path?access_token=h480djs93hd8
|
||||
"""
|
||||
return add_params_to_uri(uri, [('access_token', token)])
|
||||
|
||||
|
||||
def add_to_headers(token, headers=None):
|
||||
"""Add a Bearer Token to the request URI.
|
||||
Recommended method of passing bearer tokens.
|
||||
|
||||
Authorization: Bearer h480djs93hd8
|
||||
"""
|
||||
headers = headers or {}
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
return headers
|
||||
|
||||
|
||||
def add_to_body(token, body=None):
|
||||
"""Add a Bearer Token to the request body.
|
||||
|
||||
access_token=h480djs93hd8
|
||||
"""
|
||||
if body is None:
|
||||
body = ''
|
||||
return add_params_to_qs(body, [('access_token', token)])
|
||||
|
||||
|
||||
def add_bearer_token(token, uri, headers, body, placement='header'):
|
||||
if placement in ('uri', 'url', 'query'):
|
||||
uri = add_to_uri(token, uri)
|
||||
elif placement in ('header', 'headers'):
|
||||
headers = add_to_headers(token, headers)
|
||||
elif placement == 'body':
|
||||
body = add_to_body(token, body)
|
||||
return uri, headers, body
|
||||
@@ -0,0 +1,88 @@
|
||||
class BearerTokenGenerator:
|
||||
"""Bearer token generator which can create the payload for token response
|
||||
by OAuth 2 server. A typical token response would be:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
Cache-Control: no-store
|
||||
Pragma: no-cache
|
||||
|
||||
{
|
||||
"access_token":"mF_9.B5f-4.1JqM",
|
||||
"token_type":"Bearer",
|
||||
"expires_in":3600,
|
||||
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA"
|
||||
}
|
||||
"""
|
||||
|
||||
#: default expires_in value
|
||||
DEFAULT_EXPIRES_IN = 3600
|
||||
#: default expires_in value differentiate by grant_type
|
||||
GRANT_TYPES_EXPIRES_IN = {
|
||||
'authorization_code': 864000,
|
||||
'implicit': 3600,
|
||||
'password': 864000,
|
||||
'client_credentials': 864000
|
||||
}
|
||||
|
||||
def __init__(self, access_token_generator,
|
||||
refresh_token_generator=None,
|
||||
expires_generator=None):
|
||||
self.access_token_generator = access_token_generator
|
||||
self.refresh_token_generator = refresh_token_generator
|
||||
self.expires_generator = expires_generator
|
||||
|
||||
def _get_expires_in(self, client, grant_type):
|
||||
if self.expires_generator is None:
|
||||
expires_in = self.GRANT_TYPES_EXPIRES_IN.get(
|
||||
grant_type, self.DEFAULT_EXPIRES_IN)
|
||||
elif callable(self.expires_generator):
|
||||
expires_in = self.expires_generator(client, grant_type)
|
||||
elif isinstance(self.expires_generator, int):
|
||||
expires_in = self.expires_generator
|
||||
else:
|
||||
expires_in = self.DEFAULT_EXPIRES_IN
|
||||
return expires_in
|
||||
|
||||
@staticmethod
|
||||
def get_allowed_scope(client, scope):
|
||||
if scope:
|
||||
scope = client.get_allowed_scope(scope)
|
||||
return scope
|
||||
|
||||
def generate(self, grant_type, client, user=None, scope=None,
|
||||
expires_in=None, include_refresh_token=True):
|
||||
"""Generate a bearer token for OAuth 2.0 authorization token endpoint.
|
||||
|
||||
:param client: the client that making the request.
|
||||
:param grant_type: current requested grant_type.
|
||||
:param user: current authorized user.
|
||||
:param expires_in: if provided, use this value as expires_in.
|
||||
:param scope: current requested scope.
|
||||
:param include_refresh_token: should refresh_token be included.
|
||||
:return: Token dict
|
||||
"""
|
||||
scope = self.get_allowed_scope(client, scope)
|
||||
access_token = self.access_token_generator(
|
||||
client=client, grant_type=grant_type, user=user, scope=scope)
|
||||
if expires_in is None:
|
||||
expires_in = self._get_expires_in(client, grant_type)
|
||||
|
||||
token = {
|
||||
'token_type': 'Bearer',
|
||||
'access_token': access_token,
|
||||
}
|
||||
if expires_in:
|
||||
token['expires_in'] = expires_in
|
||||
if include_refresh_token and self.refresh_token_generator:
|
||||
token['refresh_token'] = self.refresh_token_generator(
|
||||
client=client, grant_type=grant_type, user=user, scope=scope)
|
||||
if scope:
|
||||
token['scope'] = scope
|
||||
return token
|
||||
|
||||
def __call__(self, grant_type, client, user=None, scope=None,
|
||||
expires_in=None, include_refresh_token=True):
|
||||
return self.generate(grant_type, client, user, scope, expires_in, include_refresh_token)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
authlib.oauth2.rfc6750.validator
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Validate Bearer Token for in request, scope and token.
|
||||
"""
|
||||
|
||||
from ..rfc6749 import TokenValidator
|
||||
from .errors import (
|
||||
InvalidTokenError,
|
||||
InsufficientScopeError
|
||||
)
|
||||
|
||||
|
||||
class BearerTokenValidator(TokenValidator):
|
||||
TOKEN_TYPE = 'bearer'
|
||||
|
||||
def authenticate_token(self, token_string):
|
||||
"""A method to query token from database with the given token string.
|
||||
Developers MUST re-implement this method. For instance::
|
||||
|
||||
def authenticate_token(self, token_string):
|
||||
return get_token_from_database(token_string)
|
||||
|
||||
:param token_string: A string to represent the access_token.
|
||||
:return: token
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def validate_token(self, token, scopes, request):
|
||||
"""Check if token is active and matches the requested scopes."""
|
||||
if not token:
|
||||
raise InvalidTokenError(realm=self.realm, extra_attributes=self.extra_attributes)
|
||||
if token.is_expired():
|
||||
raise InvalidTokenError(realm=self.realm, extra_attributes=self.extra_attributes)
|
||||
if token.is_revoked():
|
||||
raise InvalidTokenError(realm=self.realm, extra_attributes=self.extra_attributes)
|
||||
if self.scope_insufficient(token.get_scope(), scopes):
|
||||
raise InsufficientScopeError()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
authlib.oauth2.rfc7009
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module represents a direct implementation of
|
||||
OAuth 2.0 Token Revocation.
|
||||
|
||||
https://tools.ietf.org/html/rfc7009
|
||||
"""
|
||||
|
||||
from .parameters import prepare_revoke_token_request
|
||||
from .revocation import RevocationEndpoint
|
||||
|
||||
__all__ = ['prepare_revoke_token_request', 'RevocationEndpoint']
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
from authlib.common.urls import add_params_to_qs
|
||||
|
||||
|
||||
def prepare_revoke_token_request(token, token_type_hint=None,
|
||||
body=None, headers=None):
|
||||
"""Construct request body and headers for revocation endpoint.
|
||||
|
||||
:param token: access_token or refresh_token string.
|
||||
:param token_type_hint: Optional, `access_token` or `refresh_token`.
|
||||
:param body: current request body.
|
||||
:param headers: current request headers.
|
||||
:return: tuple of (body, headers)
|
||||
|
||||
https://tools.ietf.org/html/rfc7009#section-2.1
|
||||
"""
|
||||
params = [('token', token)]
|
||||
if token_type_hint:
|
||||
params.append(('token_type_hint', token_type_hint))
|
||||
|
||||
body = add_params_to_qs(body or '', params)
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
return body, headers
|
||||
@@ -0,0 +1,109 @@
|
||||
from authlib.consts import default_json_headers
|
||||
from ..rfc6749 import TokenEndpoint, InvalidGrantError
|
||||
from ..rfc6749 import (
|
||||
InvalidRequestError,
|
||||
UnsupportedTokenTypeError,
|
||||
)
|
||||
|
||||
|
||||
class RevocationEndpoint(TokenEndpoint):
|
||||
"""Implementation of revocation endpoint which is described in
|
||||
`RFC7009`_.
|
||||
|
||||
.. _RFC7009: https://tools.ietf.org/html/rfc7009
|
||||
"""
|
||||
#: Endpoint name to be registered
|
||||
ENDPOINT_NAME = 'revocation'
|
||||
|
||||
def authenticate_token(self, request, client):
|
||||
"""The client constructs the request by including the following
|
||||
parameters using the "application/x-www-form-urlencoded" format in
|
||||
the HTTP request entity-body:
|
||||
|
||||
token
|
||||
REQUIRED. The token that the client wants to get revoked.
|
||||
|
||||
token_type_hint
|
||||
OPTIONAL. A hint about the type of the token submitted for
|
||||
revocation.
|
||||
"""
|
||||
self.check_params(request, client)
|
||||
token = self.query_token(request.form['token'], request.form.get('token_type_hint'))
|
||||
if token and not token.check_client(client):
|
||||
raise InvalidGrantError()
|
||||
return token
|
||||
|
||||
def check_params(self, request, client):
|
||||
if 'token' not in request.form:
|
||||
raise InvalidRequestError()
|
||||
|
||||
hint = request.form.get('token_type_hint')
|
||||
if hint and hint not in self.SUPPORTED_TOKEN_TYPES:
|
||||
raise UnsupportedTokenTypeError()
|
||||
|
||||
def create_endpoint_response(self, request):
|
||||
"""Validate revocation request and create the response for revocation.
|
||||
For example, a client may request the revocation of a refresh token
|
||||
with the following request::
|
||||
|
||||
POST /revoke HTTP/1.1
|
||||
Host: server.example.com
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
|
||||
|
||||
token=45ghiukldjahdnhzdauz&token_type_hint=refresh_token
|
||||
|
||||
:returns: (status_code, body, headers)
|
||||
"""
|
||||
# The authorization server first validates the client credentials
|
||||
client = self.authenticate_endpoint_client(request)
|
||||
|
||||
# then verifies whether the token was issued to the client making
|
||||
# the revocation request
|
||||
token = self.authenticate_token(request, client)
|
||||
|
||||
# the authorization server invalidates the token
|
||||
if token:
|
||||
self.revoke_token(token, request)
|
||||
self.server.send_signal(
|
||||
'after_revoke_token',
|
||||
token=token,
|
||||
client=client,
|
||||
)
|
||||
return 200, {}, default_json_headers
|
||||
|
||||
def query_token(self, token_string, token_type_hint):
|
||||
"""Get the token from database/storage by the given token string.
|
||||
Developers should implement this method::
|
||||
|
||||
def query_token(self, token_string, token_type_hint):
|
||||
if token_type_hint == 'access_token':
|
||||
return Token.query_by_access_token(token_string)
|
||||
if token_type_hint == 'refresh_token':
|
||||
return Token.query_by_refresh_token(token_string)
|
||||
return Token.query_by_access_token(token_string) or \
|
||||
Token.query_by_refresh_token(token_string)
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def revoke_token(self, token, request):
|
||||
"""Mark token as revoked. Since token MUST be unique, it would be
|
||||
dangerous to delete it. Consider this situation:
|
||||
|
||||
1. Jane obtained a token XYZ
|
||||
2. Jane revoked (deleted) token XYZ
|
||||
3. Bob generated a new token XYZ
|
||||
4. Jane can use XYZ to access Bob's resource
|
||||
|
||||
It would be secure to mark a token as revoked::
|
||||
|
||||
def revoke_token(self, token, request):
|
||||
hint = request.form.get('token_type_hint')
|
||||
if hint == 'access_token':
|
||||
token.access_token_revoked = True
|
||||
else:
|
||||
token.access_token_revoked = True
|
||||
token.refresh_token_revoked = True
|
||||
token.save()
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,3 @@
|
||||
from .client import AssertionClient
|
||||
|
||||
__all__ = ['AssertionClient']
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
from authlib.common.encoding import to_native
|
||||
from authlib.oauth2.base import OAuth2Error
|
||||
|
||||
|
||||
class AssertionClient:
|
||||
"""Constructs a new Assertion Framework for OAuth 2.0 Authorization Grants
|
||||
per RFC7521_.
|
||||
|
||||
.. _RFC7521: https://tools.ietf.org/html/rfc7521
|
||||
"""
|
||||
DEFAULT_GRANT_TYPE = None
|
||||
ASSERTION_METHODS = {}
|
||||
token_auth_class = None
|
||||
oauth_error_class = OAuth2Error
|
||||
|
||||
def __init__(self, session, token_endpoint, issuer, subject,
|
||||
audience=None, grant_type=None, claims=None,
|
||||
token_placement='header', scope=None, leeway=60, **kwargs):
|
||||
|
||||
self.session = session
|
||||
|
||||
if audience is None:
|
||||
audience = token_endpoint
|
||||
|
||||
self.token_endpoint = token_endpoint
|
||||
|
||||
if grant_type is None:
|
||||
grant_type = self.DEFAULT_GRANT_TYPE
|
||||
|
||||
self.grant_type = grant_type
|
||||
|
||||
# https://tools.ietf.org/html/rfc7521#section-5.1
|
||||
self.issuer = issuer
|
||||
self.subject = subject
|
||||
self.audience = audience
|
||||
self.claims = claims
|
||||
self.scope = scope
|
||||
if self.token_auth_class is not None:
|
||||
self.token_auth = self.token_auth_class(None, token_placement, self)
|
||||
self._kwargs = kwargs
|
||||
self.leeway = leeway
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
return self.token_auth.token
|
||||
|
||||
@token.setter
|
||||
def token(self, token):
|
||||
self.token_auth.set_token(token)
|
||||
|
||||
def refresh_token(self):
|
||||
"""Using Assertions as Authorization Grants to refresh token as
|
||||
described in `Section 4.1`_.
|
||||
|
||||
.. _`Section 4.1`: https://tools.ietf.org/html/rfc7521#section-4.1
|
||||
"""
|
||||
generate_assertion = self.ASSERTION_METHODS[self.grant_type]
|
||||
assertion = generate_assertion(
|
||||
issuer=self.issuer,
|
||||
subject=self.subject,
|
||||
audience=self.audience,
|
||||
claims=self.claims,
|
||||
**self._kwargs
|
||||
)
|
||||
data = {
|
||||
'assertion': to_native(assertion),
|
||||
'grant_type': self.grant_type,
|
||||
}
|
||||
if self.scope:
|
||||
data['scope'] = self.scope
|
||||
|
||||
return self._refresh_token(data)
|
||||
|
||||
def parse_response_token(self, resp):
|
||||
if resp.status_code >= 500:
|
||||
resp.raise_for_status()
|
||||
|
||||
token = resp.json()
|
||||
if 'error' in token:
|
||||
raise self.oauth_error_class(
|
||||
error=token['error'],
|
||||
description=token.get('error_description')
|
||||
)
|
||||
|
||||
self.token = token
|
||||
return self.token
|
||||
|
||||
def _refresh_token(self, data):
|
||||
resp = self.session.request(
|
||||
'POST', self.token_endpoint, data=data, withhold_token=True)
|
||||
|
||||
return self.parse_response_token(resp)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
authlib.oauth2.rfc7523
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module represents a direct implementation of
|
||||
JSON Web Token (JWT) Profile for OAuth 2.0 Client
|
||||
Authentication and Authorization Grants.
|
||||
|
||||
https://tools.ietf.org/html/rfc7523
|
||||
"""
|
||||
|
||||
from .jwt_bearer import JWTBearerGrant
|
||||
from .client import (
|
||||
JWTBearerClientAssertion,
|
||||
)
|
||||
from .assertion import (
|
||||
client_secret_jwt_sign,
|
||||
private_key_jwt_sign,
|
||||
)
|
||||
from .auth import (
|
||||
ClientSecretJWT, PrivateKeyJWT,
|
||||
)
|
||||
from .token import JWTBearerTokenGenerator
|
||||
from .validator import JWTBearerToken, JWTBearerTokenValidator
|
||||
|
||||
__all__ = [
|
||||
'JWTBearerGrant',
|
||||
'JWTBearerClientAssertion',
|
||||
'client_secret_jwt_sign',
|
||||
'private_key_jwt_sign',
|
||||
'ClientSecretJWT',
|
||||
'PrivateKeyJWT',
|
||||
|
||||
'JWTBearerToken',
|
||||
'JWTBearerTokenGenerator',
|
||||
'JWTBearerTokenValidator',
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,66 @@
|
||||
import time
|
||||
from authlib.jose import jwt
|
||||
from authlib.common.security import generate_token
|
||||
|
||||
|
||||
def sign_jwt_bearer_assertion(
|
||||
key, issuer, audience, subject=None, issued_at=None,
|
||||
expires_at=None, claims=None, header=None, **kwargs):
|
||||
|
||||
if header is None:
|
||||
header = {}
|
||||
alg = kwargs.pop('alg', None)
|
||||
if alg:
|
||||
header['alg'] = alg
|
||||
if 'alg' not in header:
|
||||
raise ValueError('Missing "alg" in header')
|
||||
|
||||
payload = {'iss': issuer, 'aud': audience}
|
||||
|
||||
# subject is not required in Google service
|
||||
if subject:
|
||||
payload['sub'] = subject
|
||||
|
||||
if not issued_at:
|
||||
issued_at = int(time.time())
|
||||
|
||||
expires_in = kwargs.pop('expires_in', 3600)
|
||||
if not expires_at:
|
||||
expires_at = issued_at + expires_in
|
||||
|
||||
payload['iat'] = issued_at
|
||||
payload['exp'] = expires_at
|
||||
|
||||
if claims:
|
||||
payload.update(claims)
|
||||
|
||||
return jwt.encode(header, payload, key)
|
||||
|
||||
|
||||
def client_secret_jwt_sign(client_secret, client_id, token_endpoint, alg='HS256',
|
||||
claims=None, **kwargs):
|
||||
return _sign(client_secret, client_id, token_endpoint, alg, claims, **kwargs)
|
||||
|
||||
|
||||
def private_key_jwt_sign(private_key, client_id, token_endpoint, alg='RS256',
|
||||
claims=None, **kwargs):
|
||||
return _sign(private_key, client_id, token_endpoint, alg, claims, **kwargs)
|
||||
|
||||
|
||||
def _sign(key, client_id, token_endpoint, alg, claims=None, **kwargs):
|
||||
# REQUIRED. Issuer. This MUST contain the client_id of the OAuth Client.
|
||||
issuer = client_id
|
||||
# REQUIRED. Subject. This MUST contain the client_id of the OAuth Client.
|
||||
subject = client_id
|
||||
# The Audience SHOULD be the URL of the Authorization Server's Token Endpoint.
|
||||
audience = token_endpoint
|
||||
|
||||
# jti is required
|
||||
if claims is None:
|
||||
claims = {}
|
||||
if 'jti' not in claims:
|
||||
claims['jti'] = generate_token(36)
|
||||
|
||||
return sign_jwt_bearer_assertion(
|
||||
key=key, issuer=issuer, audience=audience, subject=subject,
|
||||
claims=claims, alg=alg, **kwargs)
|
||||
@@ -0,0 +1,94 @@
|
||||
from authlib.common.urls import add_params_to_qs
|
||||
from .assertion import client_secret_jwt_sign, private_key_jwt_sign
|
||||
from .client import ASSERTION_TYPE
|
||||
|
||||
|
||||
class ClientSecretJWT:
|
||||
"""Authentication method for OAuth 2.0 Client. This authentication
|
||||
method is called ``client_secret_jwt``, which is using ``client_id``
|
||||
and ``client_secret`` constructed with JWT to identify a client.
|
||||
|
||||
Here is an example of use ``client_secret_jwt`` with Requests Session::
|
||||
|
||||
from authlib.integrations.requests_client import OAuth2Session
|
||||
|
||||
token_endpoint = 'https://example.com/oauth/token'
|
||||
session = OAuth2Session(
|
||||
'your-client-id', 'your-client-secret',
|
||||
token_endpoint_auth_method='client_secret_jwt'
|
||||
)
|
||||
session.register_client_auth_method(ClientSecretJWT(token_endpoint))
|
||||
session.fetch_token(token_endpoint)
|
||||
|
||||
:param token_endpoint: A string URL of the token endpoint
|
||||
:param claims: Extra JWT claims
|
||||
:param headers: Extra JWT headers
|
||||
:param alg: ``alg`` value, default is HS256
|
||||
"""
|
||||
name = 'client_secret_jwt'
|
||||
alg = 'HS256'
|
||||
|
||||
def __init__(self, token_endpoint=None, claims=None, headers=None, alg=None):
|
||||
self.token_endpoint = token_endpoint
|
||||
self.claims = claims
|
||||
self.headers = headers
|
||||
if alg is not None:
|
||||
self.alg = alg
|
||||
|
||||
def sign(self, auth, token_endpoint):
|
||||
return client_secret_jwt_sign(
|
||||
auth.client_secret,
|
||||
client_id=auth.client_id,
|
||||
token_endpoint=token_endpoint,
|
||||
claims=self.claims,
|
||||
header=self.headers,
|
||||
alg=self.alg,
|
||||
)
|
||||
|
||||
def __call__(self, auth, method, uri, headers, body):
|
||||
token_endpoint = self.token_endpoint
|
||||
if not token_endpoint:
|
||||
token_endpoint = uri
|
||||
|
||||
client_assertion = self.sign(auth, token_endpoint)
|
||||
body = add_params_to_qs(body or '', [
|
||||
('client_assertion_type', ASSERTION_TYPE),
|
||||
('client_assertion', client_assertion)
|
||||
])
|
||||
return uri, headers, body
|
||||
|
||||
|
||||
class PrivateKeyJWT(ClientSecretJWT):
|
||||
"""Authentication method for OAuth 2.0 Client. This authentication
|
||||
method is called ``private_key_jwt``, which is using ``client_id``
|
||||
and ``private_key`` constructed with JWT to identify a client.
|
||||
|
||||
Here is an example of use ``private_key_jwt`` with Requests Session::
|
||||
|
||||
from authlib.integrations.requests_client import OAuth2Session
|
||||
|
||||
token_endpoint = 'https://example.com/oauth/token'
|
||||
session = OAuth2Session(
|
||||
'your-client-id', 'your-client-private-key',
|
||||
token_endpoint_auth_method='private_key_jwt'
|
||||
)
|
||||
session.register_client_auth_method(PrivateKeyJWT(token_endpoint))
|
||||
session.fetch_token(token_endpoint)
|
||||
|
||||
:param token_endpoint: A string URL of the token endpoint
|
||||
:param claims: Extra JWT claims
|
||||
:param headers: Extra JWT headers
|
||||
:param alg: ``alg`` value, default is RS256
|
||||
"""
|
||||
name = 'private_key_jwt'
|
||||
alg = 'RS256'
|
||||
|
||||
def sign(self, auth, token_endpoint):
|
||||
return private_key_jwt_sign(
|
||||
auth.client_secret,
|
||||
client_id=auth.client_id,
|
||||
token_endpoint=token_endpoint,
|
||||
claims=self.claims,
|
||||
header=self.headers,
|
||||
alg=self.alg,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from authlib.jose import jwt
|
||||
from authlib.jose.errors import JoseError
|
||||
from ..rfc6749 import InvalidClientError
|
||||
|
||||
ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JWTBearerClientAssertion:
|
||||
"""Implementation of Using JWTs for Client Authentication, which is
|
||||
defined by RFC7523.
|
||||
"""
|
||||
#: Value of ``client_assertion_type`` of JWTs
|
||||
CLIENT_ASSERTION_TYPE = ASSERTION_TYPE
|
||||
#: Name of the client authentication method
|
||||
CLIENT_AUTH_METHOD = 'client_assertion_jwt'
|
||||
|
||||
def __init__(self, token_url, validate_jti=True):
|
||||
self.token_url = token_url
|
||||
self._validate_jti = validate_jti
|
||||
|
||||
def __call__(self, query_client, request):
|
||||
data = request.form
|
||||
assertion_type = data.get('client_assertion_type')
|
||||
assertion = data.get('client_assertion')
|
||||
if assertion_type == ASSERTION_TYPE and assertion:
|
||||
resolve_key = self.create_resolve_key_func(query_client, request)
|
||||
self.process_assertion_claims(assertion, resolve_key)
|
||||
return self.authenticate_client(request.client)
|
||||
log.debug('Authenticate via %r failed', self.CLIENT_AUTH_METHOD)
|
||||
|
||||
def create_claims_options(self):
|
||||
"""Create a claims_options for verify JWT payload claims. Developers
|
||||
MAY overwrite this method to create a more strict options."""
|
||||
# https://tools.ietf.org/html/rfc7523#section-3
|
||||
# The Audience SHOULD be the URL of the Authorization Server's Token Endpoint
|
||||
options = {
|
||||
'iss': {'essential': True, 'validate': _validate_iss},
|
||||
'sub': {'essential': True},
|
||||
'aud': {'essential': True, 'value': self.token_url},
|
||||
'exp': {'essential': True},
|
||||
}
|
||||
if self._validate_jti:
|
||||
options['jti'] = {'essential': True, 'validate': self.validate_jti}
|
||||
return options
|
||||
|
||||
def process_assertion_claims(self, assertion, resolve_key):
|
||||
"""Extract JWT payload claims from request "assertion", per
|
||||
`Section 3.1`_.
|
||||
|
||||
:param assertion: assertion string value in the request
|
||||
:param resolve_key: function to resolve the sign key
|
||||
:return: JWTClaims
|
||||
:raise: InvalidClientError
|
||||
|
||||
.. _`Section 3.1`: https://tools.ietf.org/html/rfc7523#section-3.1
|
||||
"""
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
assertion, resolve_key,
|
||||
claims_options=self.create_claims_options()
|
||||
)
|
||||
claims.validate()
|
||||
except JoseError as e:
|
||||
log.debug('Assertion Error: %r', e)
|
||||
raise InvalidClientError()
|
||||
return claims
|
||||
|
||||
def authenticate_client(self, client):
|
||||
if client.check_endpoint_auth_method(self.CLIENT_AUTH_METHOD, 'token'):
|
||||
return client
|
||||
raise InvalidClientError()
|
||||
|
||||
def create_resolve_key_func(self, query_client, request):
|
||||
def resolve_key(headers, payload):
|
||||
# https://tools.ietf.org/html/rfc7523#section-3
|
||||
# For client authentication, the subject MUST be the
|
||||
# "client_id" of the OAuth client
|
||||
client_id = payload['sub']
|
||||
client = query_client(client_id)
|
||||
if not client:
|
||||
raise InvalidClientError()
|
||||
request.client = client
|
||||
return self.resolve_client_public_key(client, headers)
|
||||
return resolve_key
|
||||
|
||||
def validate_jti(self, claims, jti):
|
||||
"""Validate if the given ``jti`` value is used before. Developers
|
||||
MUST implement this method::
|
||||
|
||||
def validate_jti(self, claims, jti):
|
||||
key = 'jti:{}-{}'.format(claims['sub'], jti)
|
||||
if redis.get(key):
|
||||
return False
|
||||
redis.set(key, 1, ex=3600)
|
||||
return True
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def resolve_client_public_key(self, client, headers):
|
||||
"""Resolve the client public key for verifying the JWT signature.
|
||||
A client may have many public keys, in this case, we can retrieve it
|
||||
via ``kid`` value in headers. Developers MUST implement this method::
|
||||
|
||||
def resolve_client_public_key(self, client, headers):
|
||||
return client.public_key
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
def _validate_iss(claims, iss):
|
||||
return claims['sub'] == iss
|
||||
@@ -0,0 +1,182 @@
|
||||
import logging
|
||||
from authlib.jose import jwt, JoseError
|
||||
from ..rfc6749 import BaseGrant, TokenEndpointMixin
|
||||
from ..rfc6749 import (
|
||||
UnauthorizedClientError,
|
||||
InvalidRequestError,
|
||||
InvalidGrantError,
|
||||
InvalidClientError,
|
||||
)
|
||||
from .assertion import sign_jwt_bearer_assertion
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
|
||||
|
||||
|
||||
class JWTBearerGrant(BaseGrant, TokenEndpointMixin):
|
||||
GRANT_TYPE = JWT_BEARER_GRANT_TYPE
|
||||
|
||||
#: Options for verifying JWT payload claims. Developers MAY
|
||||
#: overwrite this constant to create a more strict options.
|
||||
CLAIMS_OPTIONS = {
|
||||
'iss': {'essential': True},
|
||||
'aud': {'essential': True},
|
||||
'exp': {'essential': True},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def sign(key, issuer, audience, subject=None,
|
||||
issued_at=None, expires_at=None, claims=None, **kwargs):
|
||||
return sign_jwt_bearer_assertion(
|
||||
key, issuer, audience, subject, issued_at,
|
||||
expires_at, claims, **kwargs)
|
||||
|
||||
def process_assertion_claims(self, assertion):
|
||||
"""Extract JWT payload claims from request "assertion", per
|
||||
`Section 3.1`_.
|
||||
|
||||
:param assertion: assertion string value in the request
|
||||
:return: JWTClaims
|
||||
:raise: InvalidGrantError
|
||||
|
||||
.. _`Section 3.1`: https://tools.ietf.org/html/rfc7523#section-3.1
|
||||
"""
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
assertion, self.resolve_public_key,
|
||||
claims_options=self.CLAIMS_OPTIONS)
|
||||
claims.validate()
|
||||
except JoseError as e:
|
||||
log.debug('Assertion Error: %r', e)
|
||||
raise InvalidGrantError(description=e.description)
|
||||
return claims
|
||||
|
||||
def resolve_public_key(self, headers, payload):
|
||||
client = self.resolve_issuer_client(payload['iss'])
|
||||
return self.resolve_client_key(client, headers, payload)
|
||||
|
||||
def validate_token_request(self):
|
||||
"""The client makes a request to the token endpoint by sending the
|
||||
following parameters using the "application/x-www-form-urlencoded"
|
||||
format per `Section 2.1`_:
|
||||
|
||||
grant_type
|
||||
REQUIRED. Value MUST be set to
|
||||
"urn:ietf:params:oauth:grant-type:jwt-bearer".
|
||||
|
||||
assertion
|
||||
REQUIRED. Value MUST contain a single JWT.
|
||||
|
||||
scope
|
||||
OPTIONAL.
|
||||
|
||||
The following example demonstrates an access token request with a JWT
|
||||
as an authorization grant:
|
||||
|
||||
.. code-block:: http
|
||||
|
||||
POST /token.oauth2 HTTP/1.1
|
||||
Host: as.example.com
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer
|
||||
&assertion=eyJhbGciOiJFUzI1NiIsImtpZCI6IjE2In0.
|
||||
eyJpc3Mi[...omitted for brevity...].
|
||||
J9l-ZhwP[...omitted for brevity...]
|
||||
|
||||
.. _`Section 2.1`: https://tools.ietf.org/html/rfc7523#section-2.1
|
||||
"""
|
||||
assertion = self.request.form.get('assertion')
|
||||
if not assertion:
|
||||
raise InvalidRequestError('Missing "assertion" in request')
|
||||
|
||||
claims = self.process_assertion_claims(assertion)
|
||||
client = self.resolve_issuer_client(claims['iss'])
|
||||
log.debug('Validate token request of %s', client)
|
||||
|
||||
if not client.check_grant_type(self.GRANT_TYPE):
|
||||
raise UnauthorizedClientError()
|
||||
|
||||
self.request.client = client
|
||||
self.validate_requested_scope()
|
||||
|
||||
subject = claims.get('sub')
|
||||
if subject:
|
||||
user = self.authenticate_user(subject)
|
||||
if not user:
|
||||
raise InvalidGrantError(description='Invalid "sub" value in assertion')
|
||||
|
||||
log.debug('Check client(%s) permission to User(%s)', client, user)
|
||||
if not self.has_granted_permission(client, user):
|
||||
raise InvalidClientError(
|
||||
description='Client has no permission to access user data')
|
||||
self.request.user = user
|
||||
|
||||
def create_token_response(self):
|
||||
"""If valid and authorized, the authorization server issues an access
|
||||
token.
|
||||
"""
|
||||
token = self.generate_token(
|
||||
scope=self.request.scope,
|
||||
user=self.request.user,
|
||||
include_refresh_token=False,
|
||||
)
|
||||
log.debug('Issue token %r to %r', token, self.request.client)
|
||||
self.save_token(token)
|
||||
return 200, token, self.TOKEN_RESPONSE_HEADER
|
||||
|
||||
def resolve_issuer_client(self, issuer):
|
||||
"""Fetch client via "iss" in assertion claims. Developers MUST
|
||||
implement this method in subclass, e.g.::
|
||||
|
||||
def resolve_issuer_client(self, issuer):
|
||||
return Client.query_by_iss(issuer)
|
||||
|
||||
:param issuer: "iss" value in assertion
|
||||
:return: Client instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def resolve_client_key(self, client, headers, payload):
|
||||
"""Resolve client key to decode assertion data. Developers MUST
|
||||
implement this method in subclass. For instance, there is a
|
||||
"jwks" column on client table, e.g.::
|
||||
|
||||
def resolve_client_key(self, client, headers, payload):
|
||||
# from authlib.jose import JsonWebKey
|
||||
|
||||
key_set = JsonWebKey.import_key_set(client.jwks)
|
||||
return key_set.find_by_kid(headers['kid'])
|
||||
|
||||
:param client: instance of OAuth client model
|
||||
:param headers: headers part of the JWT
|
||||
:param payload: payload part of the JWT
|
||||
:return: ``authlib.jose.Key`` instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def authenticate_user(self, subject):
|
||||
"""Authenticate user with the given assertion claims. Developers MUST
|
||||
implement it in subclass, e.g.::
|
||||
|
||||
def authenticate_user(self, subject):
|
||||
return User.get_by_sub(subject)
|
||||
|
||||
:param subject: "sub" value in claims
|
||||
:return: User instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def has_granted_permission(self, client, user):
|
||||
"""Check if the client has permission to access the given user's resource.
|
||||
Developers MUST implement it in subclass, e.g.::
|
||||
|
||||
def has_granted_permission(self, client, user):
|
||||
permission = ClientUserGrant.query(client=client, user=user)
|
||||
return permission.granted
|
||||
|
||||
:param client: instance of OAuth client model
|
||||
:param user: instance of User model
|
||||
:return: bool
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,93 @@
|
||||
import time
|
||||
from authlib.common.encoding import to_native
|
||||
from authlib.jose import jwt
|
||||
|
||||
|
||||
class JWTBearerTokenGenerator:
|
||||
"""A JSON Web Token formatted bearer token generator for jwt-bearer grant type.
|
||||
This token generator can be registered into authorization server::
|
||||
|
||||
authorization_server.register_token_generator(
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
JWTBearerTokenGenerator(private_rsa_key),
|
||||
)
|
||||
|
||||
In this way, we can generate the token into JWT format. And we don't have to
|
||||
save this token into database, since it will be short time valid. Consider to
|
||||
rewrite ``JWTBearerGrant.save_token``::
|
||||
|
||||
class MyJWTBearerGrant(JWTBearerGrant):
|
||||
def save_token(self, token):
|
||||
pass
|
||||
|
||||
:param secret_key: private RSA key in bytes, JWK or JWK Set.
|
||||
:param issuer: a string or URI of the issuer
|
||||
:param alg: ``alg`` to use in JWT
|
||||
"""
|
||||
DEFAULT_EXPIRES_IN = 3600
|
||||
|
||||
def __init__(self, secret_key, issuer=None, alg='RS256'):
|
||||
self.secret_key = secret_key
|
||||
self.issuer = issuer
|
||||
self.alg = alg
|
||||
|
||||
@staticmethod
|
||||
def get_allowed_scope(client, scope):
|
||||
if scope:
|
||||
scope = client.get_allowed_scope(scope)
|
||||
return scope
|
||||
|
||||
@staticmethod
|
||||
def get_sub_value(user):
|
||||
"""Return user's ID as ``sub`` value in token payload. For instance::
|
||||
|
||||
@staticmethod
|
||||
def get_sub_value(user):
|
||||
return str(user.id)
|
||||
"""
|
||||
return user.get_user_id()
|
||||
|
||||
def get_token_data(self, grant_type, client, expires_in, user=None, scope=None):
|
||||
scope = self.get_allowed_scope(client, scope)
|
||||
issued_at = int(time.time())
|
||||
data = {
|
||||
'scope': scope,
|
||||
'grant_type': grant_type,
|
||||
'iat': issued_at,
|
||||
'exp': issued_at + expires_in,
|
||||
'client_id': client.get_client_id(),
|
||||
}
|
||||
if self.issuer:
|
||||
data['iss'] = self.issuer
|
||||
if user:
|
||||
data['sub'] = self.get_sub_value(user)
|
||||
return data
|
||||
|
||||
def generate(self, grant_type, client, user=None, scope=None, expires_in=None):
|
||||
"""Generate a bearer token for OAuth 2.0 authorization token endpoint.
|
||||
|
||||
:param client: the client that making the request.
|
||||
:param grant_type: current requested grant_type.
|
||||
:param user: current authorized user.
|
||||
:param expires_in: if provided, use this value as expires_in.
|
||||
:param scope: current requested scope.
|
||||
:return: Token dict
|
||||
"""
|
||||
if expires_in is None:
|
||||
expires_in = self.DEFAULT_EXPIRES_IN
|
||||
|
||||
token_data = self.get_token_data(grant_type, client, expires_in, user, scope)
|
||||
access_token = jwt.encode({'alg': self.alg}, token_data, key=self.secret_key, check=False)
|
||||
token = {
|
||||
'token_type': 'Bearer',
|
||||
'access_token': to_native(access_token),
|
||||
'expires_in': expires_in
|
||||
}
|
||||
if scope:
|
||||
token['scope'] = scope
|
||||
return token
|
||||
|
||||
def __call__(self, grant_type, client, user=None, scope=None,
|
||||
expires_in=None, include_refresh_token=True):
|
||||
# there is absolutely no refresh token in JWT format
|
||||
return self.generate(grant_type, client, user, scope, expires_in)
|
||||
@@ -0,0 +1,54 @@
|
||||
import time
|
||||
import logging
|
||||
from authlib.jose import jwt, JoseError, JWTClaims
|
||||
from ..rfc6749 import TokenMixin
|
||||
from ..rfc6750 import BearerTokenValidator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JWTBearerToken(TokenMixin, JWTClaims):
|
||||
def check_client(self, client):
|
||||
return self['client_id'] == client.get_client_id()
|
||||
|
||||
def get_scope(self):
|
||||
return self.get('scope')
|
||||
|
||||
def get_expires_in(self):
|
||||
return self['exp'] - self['iat']
|
||||
|
||||
def is_expired(self):
|
||||
return self['exp'] < time.time()
|
||||
|
||||
def is_revoked(self):
|
||||
return False
|
||||
|
||||
|
||||
class JWTBearerTokenValidator(BearerTokenValidator):
|
||||
TOKEN_TYPE = 'bearer'
|
||||
token_cls = JWTBearerToken
|
||||
|
||||
def __init__(self, public_key, issuer=None, realm=None, **extra_attributes):
|
||||
super().__init__(realm, **extra_attributes)
|
||||
self.public_key = public_key
|
||||
claims_options = {
|
||||
'exp': {'essential': True},
|
||||
'client_id': {'essential': True},
|
||||
'grant_type': {'essential': True},
|
||||
}
|
||||
if issuer:
|
||||
claims_options['iss'] = {'essential': True, 'value': issuer}
|
||||
self.claims_options = claims_options
|
||||
|
||||
def authenticate_token(self, token_string):
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token_string, self.public_key,
|
||||
claims_options=self.claims_options,
|
||||
claims_cls=self.token_cls,
|
||||
)
|
||||
claims.validate()
|
||||
return claims
|
||||
except JoseError as error:
|
||||
logger.debug('Authenticate token failed. %r', error)
|
||||
return None
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
authlib.oauth2.rfc7591
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module represents a direct implementation of
|
||||
OAuth 2.0 Dynamic Client Registration Protocol.
|
||||
|
||||
https://tools.ietf.org/html/rfc7591
|
||||
"""
|
||||
|
||||
|
||||
from .claims import ClientMetadataClaims
|
||||
from .endpoint import ClientRegistrationEndpoint
|
||||
from .errors import (
|
||||
InvalidRedirectURIError,
|
||||
InvalidClientMetadataError,
|
||||
InvalidSoftwareStatementError,
|
||||
UnapprovedSoftwareStatementError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'ClientMetadataClaims', 'ClientRegistrationEndpoint',
|
||||
'InvalidRedirectURIError', 'InvalidClientMetadataError',
|
||||
'InvalidSoftwareStatementError', 'UnapprovedSoftwareStatementError',
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,218 @@
|
||||
from authlib.jose import BaseClaims, JsonWebKey
|
||||
from authlib.jose.errors import InvalidClaimError
|
||||
from authlib.common.urls import is_valid_url
|
||||
|
||||
|
||||
class ClientMetadataClaims(BaseClaims):
|
||||
# https://tools.ietf.org/html/rfc7591#section-2
|
||||
REGISTERED_CLAIMS = [
|
||||
'redirect_uris',
|
||||
'token_endpoint_auth_method',
|
||||
'grant_types',
|
||||
'response_types',
|
||||
'client_name',
|
||||
'client_uri',
|
||||
'logo_uri',
|
||||
'scope',
|
||||
'contacts',
|
||||
'tos_uri',
|
||||
'policy_uri',
|
||||
'jwks_uri',
|
||||
'jwks',
|
||||
'software_id',
|
||||
'software_version',
|
||||
]
|
||||
|
||||
def validate(self):
|
||||
self._validate_essential_claims()
|
||||
self.validate_redirect_uris()
|
||||
self.validate_token_endpoint_auth_method()
|
||||
self.validate_grant_types()
|
||||
self.validate_response_types()
|
||||
self.validate_client_name()
|
||||
self.validate_client_uri()
|
||||
self.validate_logo_uri()
|
||||
self.validate_scope()
|
||||
self.validate_contacts()
|
||||
self.validate_tos_uri()
|
||||
self.validate_policy_uri()
|
||||
self.validate_jwks_uri()
|
||||
self.validate_jwks()
|
||||
self.validate_software_id()
|
||||
self.validate_software_version()
|
||||
|
||||
def validate_redirect_uris(self):
|
||||
"""Array of redirection URI strings for use in redirect-based flows
|
||||
such as the authorization code and implicit flows. As required by
|
||||
Section 2 of OAuth 2.0 [RFC6749], clients using flows with
|
||||
redirection MUST register their redirection URI values.
|
||||
Authorization servers that support dynamic registration for
|
||||
redirect-based flows MUST implement support for this metadata
|
||||
value.
|
||||
"""
|
||||
uris = self.get('redirect_uris')
|
||||
if uris:
|
||||
for uri in uris:
|
||||
self._validate_uri('redirect_uris', uri)
|
||||
|
||||
def validate_token_endpoint_auth_method(self):
|
||||
"""String indicator of the requested authentication method for the
|
||||
token endpoint.
|
||||
"""
|
||||
# If unspecified or omitted, the default is "client_secret_basic"
|
||||
if 'token_endpoint_auth_method' not in self:
|
||||
self['token_endpoint_auth_method'] = 'client_secret_basic'
|
||||
self._validate_claim_value('token_endpoint_auth_method')
|
||||
|
||||
def validate_grant_types(self):
|
||||
"""Array of OAuth 2.0 grant type strings that the client can use at
|
||||
the token endpoint.
|
||||
"""
|
||||
self._validate_claim_value('grant_types')
|
||||
|
||||
def validate_response_types(self):
|
||||
"""Array of the OAuth 2.0 response type strings that the client can
|
||||
use at the authorization endpoint.
|
||||
"""
|
||||
self._validate_claim_value('response_types')
|
||||
|
||||
def validate_client_name(self):
|
||||
"""Human-readable string name of the client to be presented to the
|
||||
end-user during authorization. If omitted, the authorization
|
||||
server MAY display the raw "client_id" value to the end-user
|
||||
instead. It is RECOMMENDED that clients always send this field.
|
||||
The value of this field MAY be internationalized, as described in
|
||||
Section 2.2.
|
||||
"""
|
||||
|
||||
def validate_client_uri(self):
|
||||
"""URL string of a web page providing information about the client.
|
||||
If present, the server SHOULD display this URL to the end-user in
|
||||
a clickable fashion. It is RECOMMENDED that clients always send
|
||||
this field. The value of this field MUST point to a valid web
|
||||
page. The value of this field MAY be internationalized, as
|
||||
described in Section 2.2.
|
||||
"""
|
||||
self._validate_uri('client_uri')
|
||||
|
||||
def validate_logo_uri(self):
|
||||
"""URL string that references a logo for the client. If present, the
|
||||
server SHOULD display this image to the end-user during approval.
|
||||
The value of this field MUST point to a valid image file. The
|
||||
value of this field MAY be internationalized, as described in
|
||||
Section 2.2.
|
||||
"""
|
||||
self._validate_uri('logo_uri')
|
||||
|
||||
def validate_scope(self):
|
||||
"""String containing a space-separated list of scope values (as
|
||||
described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client
|
||||
can use when requesting access tokens. The semantics of values in
|
||||
this list are service specific. If omitted, an authorization
|
||||
server MAY register a client with a default set of scopes.
|
||||
"""
|
||||
self._validate_claim_value('scope')
|
||||
|
||||
def validate_contacts(self):
|
||||
"""Array of strings representing ways to contact people responsible
|
||||
for this client, typically email addresses. The authorization
|
||||
server MAY make these contact addresses available to end-users for
|
||||
support requests for the client. See Section 6 for information on
|
||||
Privacy Considerations.
|
||||
"""
|
||||
if 'contacts' in self and not isinstance(self['contacts'], list):
|
||||
raise InvalidClaimError('contacts')
|
||||
|
||||
def validate_tos_uri(self):
|
||||
"""URL string that points to a human-readable terms of service
|
||||
document for the client that describes a contractual relationship
|
||||
between the end-user and the client that the end-user accepts when
|
||||
authorizing the client. The authorization server SHOULD display
|
||||
this URL to the end-user if it is provided. The value of this
|
||||
field MUST point to a valid web page. The value of this field MAY
|
||||
be internationalized, as described in Section 2.2.
|
||||
"""
|
||||
self._validate_uri('tos_uri')
|
||||
|
||||
def validate_policy_uri(self):
|
||||
"""URL string that points to a human-readable privacy policy document
|
||||
that describes how the deployment organization collects, uses,
|
||||
retains, and discloses personal data. The authorization server
|
||||
SHOULD display this URL to the end-user if it is provided. The
|
||||
value of this field MUST point to a valid web page. The value of
|
||||
this field MAY be internationalized, as described in Section 2.2.
|
||||
"""
|
||||
self._validate_uri('policy_uri')
|
||||
|
||||
def validate_jwks_uri(self):
|
||||
"""URL string referencing the client's JSON Web Key (JWK) Set
|
||||
[RFC7517] document, which contains the client's public keys. The
|
||||
value of this field MUST point to a valid JWK Set document. These
|
||||
keys can be used by higher-level protocols that use signing or
|
||||
encryption. For instance, these keys might be used by some
|
||||
applications for validating signed requests made to the token
|
||||
endpoint when using JWTs for client authentication [RFC7523]. Use
|
||||
of this parameter is preferred over the "jwks" parameter, as it
|
||||
allows for easier key rotation. The "jwks_uri" and "jwks"
|
||||
parameters MUST NOT both be present in the same request or
|
||||
response.
|
||||
"""
|
||||
# TODO: use real HTTP library
|
||||
self._validate_uri('jwks_uri')
|
||||
|
||||
def validate_jwks(self):
|
||||
"""Client's JSON Web Key Set [RFC7517] document value, which contains
|
||||
the client's public keys. The value of this field MUST be a JSON
|
||||
object containing a valid JWK Set. These keys can be used by
|
||||
higher-level protocols that use signing or encryption. This
|
||||
parameter is intended to be used by clients that cannot use the
|
||||
"jwks_uri" parameter, such as native clients that cannot host
|
||||
public URLs. The "jwks_uri" and "jwks" parameters MUST NOT both
|
||||
be present in the same request or response.
|
||||
"""
|
||||
if 'jwks' in self:
|
||||
if 'jwks_uri' in self:
|
||||
# The "jwks_uri" and "jwks" parameters MUST NOT both be present
|
||||
raise InvalidClaimError('jwks')
|
||||
|
||||
jwks = self['jwks']
|
||||
try:
|
||||
key_set = JsonWebKey.import_key_set(jwks)
|
||||
if not key_set:
|
||||
raise InvalidClaimError('jwks')
|
||||
except ValueError:
|
||||
raise InvalidClaimError('jwks')
|
||||
|
||||
def validate_software_id(self):
|
||||
"""A unique identifier string (e.g., a Universally Unique Identifier
|
||||
(UUID)) assigned by the client developer or software publisher
|
||||
used by registration endpoints to identify the client software to
|
||||
be dynamically registered. Unlike "client_id", which is issued by
|
||||
the authorization server and SHOULD vary between instances, the
|
||||
"software_id" SHOULD remain the same for all instances of the
|
||||
client software. The "software_id" SHOULD remain the same across
|
||||
multiple updates or versions of the same piece of software. The
|
||||
value of this field is not intended to be human readable and is
|
||||
usually opaque to the client and authorization server.
|
||||
"""
|
||||
|
||||
def validate_software_version(self):
|
||||
"""A version identifier string for the client software identified by
|
||||
"software_id". The value of the "software_version" SHOULD change
|
||||
on any update to the client software identified by the same
|
||||
"software_id". The value of this field is intended to be compared
|
||||
using string equality matching and no other comparison semantics
|
||||
are defined by this specification. The value of this field is
|
||||
outside the scope of this specification, but it is not intended to
|
||||
be human readable and is usually opaque to the client and
|
||||
authorization server. The definition of what constitutes an
|
||||
update to client software that would trigger a change to this
|
||||
value is specific to the software itself and is outside the scope
|
||||
of this specification.
|
||||
"""
|
||||
|
||||
def _validate_uri(self, key, uri=None):
|
||||
if uri is None:
|
||||
uri = self.get(key)
|
||||
if uri and not is_valid_url(uri):
|
||||
raise InvalidClaimError(key)
|
||||
@@ -0,0 +1,211 @@
|
||||
import os
|
||||
import time
|
||||
import binascii
|
||||
from authlib.consts import default_json_headers
|
||||
from authlib.common.security import generate_token
|
||||
from authlib.jose import JsonWebToken, JoseError
|
||||
from ..rfc6749 import AccessDeniedError, InvalidRequestError
|
||||
from ..rfc6749 import scope_to_list
|
||||
from .claims import ClientMetadataClaims
|
||||
from .errors import (
|
||||
InvalidClientMetadataError,
|
||||
UnapprovedSoftwareStatementError,
|
||||
InvalidSoftwareStatementError,
|
||||
)
|
||||
|
||||
|
||||
class ClientRegistrationEndpoint:
|
||||
"""The client registration endpoint is an OAuth 2.0 endpoint designed to
|
||||
allow a client to be registered with the authorization server.
|
||||
"""
|
||||
ENDPOINT_NAME = 'client_registration'
|
||||
|
||||
#: The claims validation class
|
||||
claims_class = ClientMetadataClaims
|
||||
|
||||
#: Rewrite this value with a list to support ``software_statement``
|
||||
#: e.g. ``software_statement_alg_values_supported = ['RS256']``
|
||||
software_statement_alg_values_supported = None
|
||||
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
|
||||
def __call__(self, request):
|
||||
return self.create_registration_response(request)
|
||||
|
||||
def create_registration_response(self, request):
|
||||
token = self.authenticate_token(request)
|
||||
if not token:
|
||||
raise AccessDeniedError()
|
||||
|
||||
request.credential = token
|
||||
|
||||
client_metadata = self.extract_client_metadata(request)
|
||||
client_info = self.generate_client_info()
|
||||
body = {}
|
||||
body.update(client_metadata)
|
||||
body.update(client_info)
|
||||
client = self.save_client(client_info, client_metadata, request)
|
||||
registration_info = self.generate_client_registration_info(client, request)
|
||||
if registration_info:
|
||||
body.update(registration_info)
|
||||
return 201, body, default_json_headers
|
||||
|
||||
def extract_client_metadata(self, request):
|
||||
if not request.data:
|
||||
raise InvalidRequestError()
|
||||
|
||||
json_data = request.data.copy()
|
||||
software_statement = json_data.pop('software_statement', None)
|
||||
if software_statement and self.software_statement_alg_values_supported:
|
||||
data = self.extract_software_statement(software_statement, request)
|
||||
json_data.update(data)
|
||||
|
||||
options = self.get_claims_options()
|
||||
claims = self.claims_class(json_data, {}, options, self.get_server_metadata())
|
||||
try:
|
||||
claims.validate()
|
||||
except JoseError as error:
|
||||
raise InvalidClientMetadataError(error.description)
|
||||
return claims.get_registered_claims()
|
||||
|
||||
def extract_software_statement(self, software_statement, request):
|
||||
key = self.resolve_public_key(request)
|
||||
if not key:
|
||||
raise UnapprovedSoftwareStatementError()
|
||||
|
||||
try:
|
||||
jwt = JsonWebToken(self.software_statement_alg_values_supported)
|
||||
claims = jwt.decode(software_statement, key)
|
||||
# there is no need to validate claims
|
||||
return claims
|
||||
except JoseError:
|
||||
raise InvalidSoftwareStatementError()
|
||||
|
||||
def get_claims_options(self):
|
||||
"""Generate claims options validation from Authorization Server metadata."""
|
||||
metadata = self.get_server_metadata()
|
||||
if not metadata:
|
||||
return {}
|
||||
|
||||
scopes_supported = metadata.get('scopes_supported')
|
||||
response_types_supported = metadata.get('response_types_supported')
|
||||
grant_types_supported = metadata.get('grant_types_supported')
|
||||
auth_methods_supported = metadata.get('token_endpoint_auth_methods_supported')
|
||||
options = {}
|
||||
if scopes_supported is not None:
|
||||
scopes_supported = set(scopes_supported)
|
||||
|
||||
def _validate_scope(claims, value):
|
||||
if not value:
|
||||
return True
|
||||
scopes = set(scope_to_list(value))
|
||||
return scopes_supported.issuperset(scopes)
|
||||
|
||||
options['scope'] = {'validate': _validate_scope}
|
||||
|
||||
if response_types_supported is not None:
|
||||
response_types_supported = set(response_types_supported)
|
||||
|
||||
def _validate_response_types(claims, value):
|
||||
# If omitted, the default is that the client will use only the "code"
|
||||
# response type.
|
||||
response_types = set(value) if value else {"code"}
|
||||
return response_types_supported.issuperset(response_types)
|
||||
|
||||
options['response_types'] = {'validate': _validate_response_types}
|
||||
|
||||
if grant_types_supported is not None:
|
||||
grant_types_supported = set(grant_types_supported)
|
||||
|
||||
def _validate_grant_types(claims, value):
|
||||
# If omitted, the default behavior is that the client will use only
|
||||
# the "authorization_code" Grant Type.
|
||||
grant_types = set(value) if value else {"authorization_code"}
|
||||
return grant_types_supported.issuperset(grant_types)
|
||||
|
||||
options['grant_types'] = {'validate': _validate_grant_types}
|
||||
|
||||
if auth_methods_supported is not None:
|
||||
options['token_endpoint_auth_method'] = {'values': auth_methods_supported}
|
||||
|
||||
return options
|
||||
|
||||
def generate_client_info(self):
|
||||
# https://tools.ietf.org/html/rfc7591#section-3.2.1
|
||||
client_id = self.generate_client_id()
|
||||
client_secret = self.generate_client_secret()
|
||||
client_id_issued_at = int(time.time())
|
||||
client_secret_expires_at = 0
|
||||
return dict(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
client_id_issued_at=client_id_issued_at,
|
||||
client_secret_expires_at=client_secret_expires_at,
|
||||
)
|
||||
|
||||
def generate_client_registration_info(self, client, request):
|
||||
"""Generate ```registration_client_uri`` and ``registration_access_token``
|
||||
for RFC7592. This method returns ``None`` by default. Developers MAY rewrite
|
||||
this method to return registration information."""
|
||||
return None
|
||||
|
||||
def create_endpoint_request(self, request):
|
||||
return self.server.create_json_request(request)
|
||||
|
||||
def generate_client_id(self):
|
||||
"""Generate ``client_id`` value. Developers MAY rewrite this method
|
||||
to use their own way to generate ``client_id``.
|
||||
"""
|
||||
return generate_token(42)
|
||||
|
||||
def generate_client_secret(self):
|
||||
"""Generate ``client_secret`` value. Developers MAY rewrite this method
|
||||
to use their own way to generate ``client_secret``.
|
||||
"""
|
||||
return binascii.hexlify(os.urandom(24)).decode('ascii')
|
||||
|
||||
def get_server_metadata(self):
|
||||
"""Return server metadata which includes supported grant types,
|
||||
response types and etc.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def authenticate_token(self, request):
|
||||
"""Authenticate current credential who is requesting to register a client.
|
||||
Developers MUST implement this method in subclass::
|
||||
|
||||
def authenticate_token(self, request):
|
||||
auth = request.headers.get('Authorization')
|
||||
return get_token_by_auth(auth)
|
||||
|
||||
:return: token instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def resolve_public_key(self, request):
|
||||
"""Resolve a public key for decoding ``software_statement``. If
|
||||
``enable_software_statement=True``, developers MUST implement this
|
||||
method in subclass::
|
||||
|
||||
def resolve_public_key(self, request):
|
||||
return get_public_key_from_user(request.credential)
|
||||
|
||||
:return: JWK or Key string
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def save_client(self, client_info, client_metadata, request):
|
||||
"""Save client into database. Developers MUST implement this method
|
||||
in subclass::
|
||||
|
||||
def save_client(self, client_info, client_metadata, request):
|
||||
client = OAuthClient(
|
||||
client_id=client_info['client_id'],
|
||||
client_secret=client_info['client_secret'],
|
||||
...
|
||||
)
|
||||
client.save()
|
||||
return client
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,33 @@
|
||||
from ..rfc6749 import OAuth2Error
|
||||
|
||||
|
||||
class InvalidRedirectURIError(OAuth2Error):
|
||||
"""The value of one or more redirection URIs is invalid.
|
||||
https://tools.ietf.org/html/rfc7591#section-3.2.2
|
||||
"""
|
||||
error = 'invalid_redirect_uri'
|
||||
|
||||
|
||||
class InvalidClientMetadataError(OAuth2Error):
|
||||
"""The value of one of the client metadata fields is invalid and the
|
||||
server has rejected this request. Note that an authorization
|
||||
server MAY choose to substitute a valid value for any requested
|
||||
parameter of a client's metadata.
|
||||
https://tools.ietf.org/html/rfc7591#section-3.2.2
|
||||
"""
|
||||
error = 'invalid_client_metadata'
|
||||
|
||||
|
||||
class InvalidSoftwareStatementError(OAuth2Error):
|
||||
"""The software statement presented is invalid.
|
||||
https://tools.ietf.org/html/rfc7591#section-3.2.2
|
||||
"""
|
||||
error = 'invalid_software_statement'
|
||||
|
||||
|
||||
class UnapprovedSoftwareStatementError(OAuth2Error):
|
||||
"""The software statement presented is not approved for use by this
|
||||
authorization server.
|
||||
https://tools.ietf.org/html/rfc7591#section-3.2.2
|
||||
"""
|
||||
error = 'unapproved_software_statement'
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
authlib.oauth2.rfc7592
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module represents a direct implementation of
|
||||
OAuth 2.0 Dynamic Client Registration Management Protocol.
|
||||
|
||||
https://tools.ietf.org/html/rfc7592
|
||||
"""
|
||||
|
||||
from .endpoint import ClientConfigurationEndpoint
|
||||
|
||||
__all__ = ['ClientConfigurationEndpoint']
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,262 @@
|
||||
from authlib.consts import default_json_headers
|
||||
from authlib.jose import JoseError
|
||||
from ..rfc7591.claims import ClientMetadataClaims
|
||||
from ..rfc6749 import scope_to_list
|
||||
from ..rfc6749 import AccessDeniedError
|
||||
from ..rfc6749 import InvalidClientError
|
||||
from ..rfc6749 import InvalidRequestError
|
||||
from ..rfc6749 import UnauthorizedClientError
|
||||
from ..rfc7591 import InvalidClientMetadataError
|
||||
|
||||
|
||||
class ClientConfigurationEndpoint:
|
||||
ENDPOINT_NAME = 'client_configuration'
|
||||
|
||||
#: The claims validation class
|
||||
claims_class = ClientMetadataClaims
|
||||
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
|
||||
def __call__(self, request):
|
||||
return self.create_configuration_response(request)
|
||||
|
||||
def create_configuration_response(self, request):
|
||||
# This request is authenticated by the registration access token issued
|
||||
# to the client.
|
||||
token = self.authenticate_token(request)
|
||||
if not token:
|
||||
raise AccessDeniedError()
|
||||
|
||||
request.credential = token
|
||||
|
||||
client = self.authenticate_client(request)
|
||||
if not client:
|
||||
# If the client does not exist on this server, the server MUST respond
|
||||
# with HTTP 401 Unauthorized and the registration access token used to
|
||||
# make this request SHOULD be immediately revoked.
|
||||
self.revoke_access_token(request, token)
|
||||
raise InvalidClientError(status_code=401)
|
||||
|
||||
if not self.check_permission(client, request):
|
||||
# If the client does not have permission to read its record, the server
|
||||
# MUST return an HTTP 403 Forbidden.
|
||||
raise UnauthorizedClientError(status_code=403)
|
||||
|
||||
request.client = client
|
||||
|
||||
if request.method == 'GET':
|
||||
return self.create_read_client_response(client, request)
|
||||
elif request.method == 'DELETE':
|
||||
return self.create_delete_client_response(client, request)
|
||||
elif request.method == 'PUT':
|
||||
return self.create_update_client_response(client, request)
|
||||
|
||||
def create_endpoint_request(self, request):
|
||||
return self.server.create_json_request(request)
|
||||
|
||||
def create_read_client_response(self, client, request):
|
||||
body = self.introspect_client(client)
|
||||
body.update(self.generate_client_registration_info(client, request))
|
||||
return 200, body, default_json_headers
|
||||
|
||||
def create_delete_client_response(self, client, request):
|
||||
self.delete_client(client, request)
|
||||
headers = [
|
||||
('Cache-Control', 'no-store'),
|
||||
('Pragma', 'no-cache'),
|
||||
]
|
||||
return 204, '', headers
|
||||
|
||||
def create_update_client_response(self, client, request):
|
||||
# The updated client metadata fields request MUST NOT include the
|
||||
# 'registration_access_token', 'registration_client_uri',
|
||||
# 'client_secret_expires_at', or 'client_id_issued_at' fields
|
||||
must_not_include = (
|
||||
'registration_access_token',
|
||||
'registration_client_uri',
|
||||
'client_secret_expires_at',
|
||||
'client_id_issued_at',
|
||||
)
|
||||
for k in must_not_include:
|
||||
if k in request.data:
|
||||
raise InvalidRequestError()
|
||||
|
||||
# The client MUST include its 'client_id' field in the request
|
||||
client_id = request.data.get('client_id')
|
||||
if not client_id:
|
||||
raise InvalidRequestError()
|
||||
if client_id != client.get_client_id():
|
||||
raise InvalidRequestError()
|
||||
|
||||
# If the client includes the 'client_secret' field in the request,
|
||||
# the value of this field MUST match the currently issued client
|
||||
# secret for that client.
|
||||
if 'client_secret' in request.data:
|
||||
if not client.check_client_secret(request.data['client_secret']):
|
||||
raise InvalidRequestError()
|
||||
|
||||
client_metadata = self.extract_client_metadata(request)
|
||||
client = self.update_client(client, client_metadata, request)
|
||||
return self.create_read_client_response(client, request)
|
||||
|
||||
def extract_client_metadata(self, request):
|
||||
json_data = request.data.copy()
|
||||
options = self.get_claims_options()
|
||||
claims = self.claims_class(json_data, {}, options, self.get_server_metadata())
|
||||
|
||||
try:
|
||||
claims.validate()
|
||||
except JoseError as error:
|
||||
raise InvalidClientMetadataError(error.description)
|
||||
return claims.get_registered_claims()
|
||||
|
||||
def get_claims_options(self):
|
||||
metadata = self.get_server_metadata()
|
||||
if not metadata:
|
||||
return {}
|
||||
|
||||
scopes_supported = metadata.get('scopes_supported')
|
||||
response_types_supported = metadata.get('response_types_supported')
|
||||
grant_types_supported = metadata.get('grant_types_supported')
|
||||
auth_methods_supported = metadata.get('token_endpoint_auth_methods_supported')
|
||||
options = {}
|
||||
if scopes_supported is not None:
|
||||
scopes_supported = set(scopes_supported)
|
||||
|
||||
def _validate_scope(claims, value):
|
||||
if not value:
|
||||
return True
|
||||
scopes = set(scope_to_list(value))
|
||||
return scopes_supported.issuperset(scopes)
|
||||
|
||||
options['scope'] = {'validate': _validate_scope}
|
||||
|
||||
if response_types_supported is not None:
|
||||
response_types_supported = set(response_types_supported)
|
||||
|
||||
def _validate_response_types(claims, value):
|
||||
# If omitted, the default is that the client will use only the "code"
|
||||
# response type.
|
||||
response_types = set(value) if value else {"code"}
|
||||
return response_types_supported.issuperset(response_types)
|
||||
|
||||
options['response_types'] = {'validate': _validate_response_types}
|
||||
|
||||
if grant_types_supported is not None:
|
||||
grant_types_supported = set(grant_types_supported)
|
||||
|
||||
def _validate_grant_types(claims, value):
|
||||
# If omitted, the default behavior is that the client will use only
|
||||
# the "authorization_code" Grant Type.
|
||||
grant_types = set(value) if value else {"authorization_code"}
|
||||
return grant_types_supported.issuperset(grant_types)
|
||||
|
||||
options['grant_types'] = {'validate': _validate_grant_types}
|
||||
|
||||
if auth_methods_supported is not None:
|
||||
options['token_endpoint_auth_method'] = {'values': auth_methods_supported}
|
||||
|
||||
return options
|
||||
|
||||
def introspect_client(self, client):
|
||||
return {**client.client_info, **client.client_metadata}
|
||||
|
||||
def generate_client_registration_info(self, client, request):
|
||||
"""Generate ```registration_client_uri`` and ``registration_access_token``
|
||||
for RFC7592. By default this method returns the values sent in the current
|
||||
request. Developers MUST rewrite this method to return different registration
|
||||
information.::
|
||||
|
||||
def generate_client_registration_info(self, client, request):{
|
||||
access_token = request.headers['Authorization'].split(' ')[1]
|
||||
return {
|
||||
'registration_client_uri': request.uri,
|
||||
'registration_access_token': access_token,
|
||||
}
|
||||
|
||||
:param client: the instance of OAuth client
|
||||
:param request: formatted request instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def authenticate_token(self, request):
|
||||
"""Authenticate current credential who is requesting to register a client.
|
||||
Developers MUST implement this method in subclass::
|
||||
|
||||
def authenticate_token(self, request):
|
||||
auth = request.headers.get('Authorization')
|
||||
return get_token_by_auth(auth)
|
||||
|
||||
:return: token instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def authenticate_client(self, request):
|
||||
"""Read a client from the request payload.
|
||||
Developers MUST implement this method in subclass::
|
||||
|
||||
def authenticate_client(self, request):
|
||||
client_id = request.data.get('client_id')
|
||||
return Client.get(client_id=client_id)
|
||||
|
||||
:return: client instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def revoke_access_token(self, token, request):
|
||||
"""Revoke a token access in case an invalid client has been requested.
|
||||
Developers MUST implement this method in subclass::
|
||||
|
||||
def revoke_access_token(self, token, request):
|
||||
token.revoked = True
|
||||
token.save()
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def check_permission(self, client, request):
|
||||
"""Checks wether the current client is allowed to be accessed, edited
|
||||
or deleted. Developers MUST implement it in subclass, e.g.::
|
||||
|
||||
def check_permission(self, client, request):
|
||||
return client.editable
|
||||
|
||||
:return: boolean
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def delete_client(self, client, request):
|
||||
"""Delete authorization code from database or cache. Developers MUST
|
||||
implement it in subclass, e.g.::
|
||||
|
||||
def delete_client(self, client, request):
|
||||
client.delete()
|
||||
|
||||
:param client: the instance of OAuth client
|
||||
:param request: formatted request instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def update_client(self, client, client_metadata, request):
|
||||
"""Update the client in the database. Developers MUST implement this method
|
||||
in subclass::
|
||||
|
||||
def update_client(self, client, client_metadata, request):
|
||||
client.set_client_metadata({**client.client_metadata, **client_metadata})
|
||||
client.save()
|
||||
return client
|
||||
|
||||
:param client: the instance of OAuth client
|
||||
:param client_metadata: a dict of the client claims to update
|
||||
:param request: formatted request instance
|
||||
:return: client instance
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_server_metadata(self):
|
||||
"""Return server metadata which includes supported grant types,
|
||||
response types and etc.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
authlib.oauth2.rfc7636
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module represents a direct implementation of
|
||||
Proof Key for Code Exchange by OAuth Public Clients.
|
||||
|
||||
https://tools.ietf.org/html/rfc7636
|
||||
"""
|
||||
|
||||
from .challenge import CodeChallenge, create_s256_code_challenge
|
||||
|
||||
__all__ = ['CodeChallenge', 'create_s256_code_challenge']
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,148 @@
|
||||
import re
|
||||
import hashlib
|
||||
from authlib.common.encoding import to_bytes, to_unicode, urlsafe_b64encode
|
||||
from ..rfc6749 import (
|
||||
InvalidRequestError,
|
||||
InvalidGrantError,
|
||||
OAuth2Request,
|
||||
)
|
||||
|
||||
|
||||
CODE_VERIFIER_PATTERN = re.compile(r'^[a-zA-Z0-9\-._~]{43,128}$')
|
||||
CODE_CHALLENGE_PATTERN = re.compile(r'^[a-zA-Z0-9\-._~]{43,128}$')
|
||||
|
||||
|
||||
def create_s256_code_challenge(code_verifier):
|
||||
"""Create S256 code_challenge with the given code_verifier."""
|
||||
data = hashlib.sha256(to_bytes(code_verifier, 'ascii')).digest()
|
||||
return to_unicode(urlsafe_b64encode(data))
|
||||
|
||||
|
||||
def compare_plain_code_challenge(code_verifier, code_challenge):
|
||||
# If the "code_challenge_method" from Section 4.3 was "plain",
|
||||
# they are compared directly
|
||||
return code_verifier == code_challenge
|
||||
|
||||
|
||||
def compare_s256_code_challenge(code_verifier, code_challenge):
|
||||
# BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge
|
||||
return create_s256_code_challenge(code_verifier) == code_challenge
|
||||
|
||||
|
||||
class CodeChallenge:
|
||||
"""CodeChallenge extension to Authorization Code Grant. It is used to
|
||||
improve the security of Authorization Code flow for public clients by
|
||||
sending extra "code_challenge" and "code_verifier" to the authorization
|
||||
server.
|
||||
|
||||
The AuthorizationCodeGrant SHOULD save the ``code_challenge`` and
|
||||
``code_challenge_method`` into database when ``save_authorization_code``.
|
||||
Then register this extension via::
|
||||
|
||||
server.register_grant(
|
||||
AuthorizationCodeGrant,
|
||||
[CodeChallenge(required=True)]
|
||||
)
|
||||
"""
|
||||
#: defaults to "plain" if not present in the request
|
||||
DEFAULT_CODE_CHALLENGE_METHOD = 'plain'
|
||||
#: supported ``code_challenge_method``
|
||||
SUPPORTED_CODE_CHALLENGE_METHOD = ['plain', 'S256']
|
||||
|
||||
CODE_CHALLENGE_METHODS = {
|
||||
'plain': compare_plain_code_challenge,
|
||||
'S256': compare_s256_code_challenge,
|
||||
}
|
||||
|
||||
def __init__(self, required=True):
|
||||
self.required = required
|
||||
|
||||
def __call__(self, grant):
|
||||
grant.register_hook(
|
||||
'after_validate_authorization_request',
|
||||
self.validate_code_challenge,
|
||||
)
|
||||
grant.register_hook(
|
||||
'after_validate_token_request',
|
||||
self.validate_code_verifier,
|
||||
)
|
||||
|
||||
def validate_code_challenge(self, grant):
|
||||
request: OAuth2Request = grant.request
|
||||
challenge = request.data.get('code_challenge')
|
||||
method = request.data.get('code_challenge_method')
|
||||
if not challenge and not method:
|
||||
return
|
||||
|
||||
if not challenge:
|
||||
raise InvalidRequestError('Missing "code_challenge"')
|
||||
|
||||
if len(request.datalist.get('code_challenge', [])) > 1:
|
||||
raise InvalidRequestError('Multiple "code_challenge" in request.')
|
||||
|
||||
if not CODE_CHALLENGE_PATTERN.match(challenge):
|
||||
raise InvalidRequestError('Invalid "code_challenge"')
|
||||
|
||||
if method and method not in self.SUPPORTED_CODE_CHALLENGE_METHOD:
|
||||
raise InvalidRequestError('Unsupported "code_challenge_method"')
|
||||
|
||||
if len(request.datalist.get('code_challenge_method', [])) > 1:
|
||||
raise InvalidRequestError('Multiple "code_challenge_method" in request.')
|
||||
|
||||
def validate_code_verifier(self, grant):
|
||||
request: OAuth2Request = grant.request
|
||||
verifier = request.form.get('code_verifier')
|
||||
|
||||
# public client MUST verify code challenge
|
||||
if self.required and request.auth_method == 'none' and not verifier:
|
||||
raise InvalidRequestError('Missing "code_verifier"')
|
||||
|
||||
authorization_code = request.authorization_code
|
||||
challenge = self.get_authorization_code_challenge(authorization_code)
|
||||
|
||||
# ignore, it is the normal RFC6749 authorization_code request
|
||||
if not challenge and not verifier:
|
||||
return
|
||||
|
||||
# challenge exists, code_verifier is required
|
||||
if not verifier:
|
||||
raise InvalidRequestError('Missing "code_verifier"')
|
||||
|
||||
if not CODE_VERIFIER_PATTERN.match(verifier):
|
||||
raise InvalidRequestError('Invalid "code_verifier"')
|
||||
|
||||
# 4.6. Server Verifies code_verifier before Returning the Tokens
|
||||
method = self.get_authorization_code_challenge_method(authorization_code)
|
||||
if method is None:
|
||||
method = self.DEFAULT_CODE_CHALLENGE_METHOD
|
||||
|
||||
func = self.CODE_CHALLENGE_METHODS.get(method)
|
||||
if not func:
|
||||
raise RuntimeError(f'No verify method for "{method}"')
|
||||
|
||||
# If the values are not equal, an error response indicating
|
||||
# "invalid_grant" MUST be returned.
|
||||
if not func(verifier, challenge):
|
||||
raise InvalidGrantError(description='Code challenge failed.')
|
||||
|
||||
def get_authorization_code_challenge(self, authorization_code):
|
||||
"""Get "code_challenge" associated with this authorization code.
|
||||
Developers MAY re-implement it in subclass, the default logic::
|
||||
|
||||
def get_authorization_code_challenge(self, authorization_code):
|
||||
return authorization_code.code_challenge
|
||||
|
||||
:param authorization_code: the instance of authorization_code
|
||||
"""
|
||||
return authorization_code.code_challenge
|
||||
|
||||
def get_authorization_code_challenge_method(self, authorization_code):
|
||||
"""Get "code_challenge_method" associated with this authorization code.
|
||||
Developers MAY re-implement it in subclass, the default logic::
|
||||
|
||||
def get_authorization_code_challenge_method(self, authorization_code):
|
||||
return authorization_code.code_challenge_method
|
||||
|
||||
:param authorization_code: the instance of authorization_code
|
||||
"""
|
||||
return authorization_code.code_challenge_method
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
authlib.oauth2.rfc7662
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module represents a direct implementation of
|
||||
OAuth 2.0 Token Introspection.
|
||||
|
||||
https://tools.ietf.org/html/rfc7662
|
||||
"""
|
||||
|
||||
from .introspection import IntrospectionEndpoint
|
||||
from .models import IntrospectionToken
|
||||
from .token_validator import IntrospectTokenValidator
|
||||
|
||||
__all__ = ['IntrospectionEndpoint', 'IntrospectionToken', 'IntrospectTokenValidator']
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,131 @@
|
||||
from authlib.consts import default_json_headers
|
||||
from ..rfc6749 import (
|
||||
TokenEndpoint,
|
||||
InvalidRequestError,
|
||||
UnsupportedTokenTypeError,
|
||||
)
|
||||
|
||||
|
||||
class IntrospectionEndpoint(TokenEndpoint):
|
||||
"""Implementation of introspection endpoint which is described in
|
||||
`RFC7662`_.
|
||||
|
||||
.. _RFC7662: https://tools.ietf.org/html/rfc7662
|
||||
"""
|
||||
#: Endpoint name to be registered
|
||||
ENDPOINT_NAME = 'introspection'
|
||||
|
||||
def authenticate_token(self, request, client):
|
||||
"""The protected resource calls the introspection endpoint using an HTTP
|
||||
``POST`` request with parameters sent as
|
||||
"application/x-www-form-urlencoded" data. The protected resource sends a
|
||||
parameter representing the token along with optional parameters
|
||||
representing additional context that is known by the protected resource
|
||||
to aid the authorization server in its response.
|
||||
|
||||
token
|
||||
**REQUIRED** The string value of the token. For access tokens, this
|
||||
is the ``access_token`` value returned from the token endpoint
|
||||
defined in OAuth 2.0. For refresh tokens, this is the
|
||||
``refresh_token`` value returned from the token endpoint as defined
|
||||
in OAuth 2.0.
|
||||
|
||||
token_type_hint
|
||||
**OPTIONAL** A hint about the type of the token submitted for
|
||||
introspection.
|
||||
"""
|
||||
|
||||
self.check_params(request, client)
|
||||
token = self.query_token(request.form['token'], request.form.get('token_type_hint'))
|
||||
if token and self.check_permission(token, client, request):
|
||||
return token
|
||||
|
||||
def check_params(self, request, client):
|
||||
params = request.form
|
||||
if 'token' not in params:
|
||||
raise InvalidRequestError()
|
||||
|
||||
hint = params.get('token_type_hint')
|
||||
if hint and hint not in self.SUPPORTED_TOKEN_TYPES:
|
||||
raise UnsupportedTokenTypeError()
|
||||
|
||||
def create_endpoint_response(self, request):
|
||||
"""Validate introspection request and create the response.
|
||||
|
||||
:returns: (status_code, body, headers)
|
||||
"""
|
||||
# The authorization server first validates the client credentials
|
||||
client = self.authenticate_endpoint_client(request)
|
||||
|
||||
# then verifies whether the token was issued to the client making
|
||||
# the revocation request
|
||||
token = self.authenticate_token(request, client)
|
||||
|
||||
# the authorization server invalidates the token
|
||||
body = self.create_introspection_payload(token)
|
||||
return 200, body, default_json_headers
|
||||
|
||||
def create_introspection_payload(self, token):
|
||||
# the token is not active, does not exist on this server, or the
|
||||
# protected resource is not allowed to introspect this particular
|
||||
# token, then the authorization server MUST return an introspection
|
||||
# response with the "active" field set to "false"
|
||||
if not token:
|
||||
return {'active': False}
|
||||
if token.is_expired() or token.is_revoked():
|
||||
return {'active': False}
|
||||
payload = self.introspect_token(token)
|
||||
if 'active' not in payload:
|
||||
payload['active'] = True
|
||||
return payload
|
||||
|
||||
def check_permission(self, token, client, request):
|
||||
"""Check if the request has permission to introspect the token. Developers
|
||||
MUST implement this method::
|
||||
|
||||
def check_permission(self, token, client, request):
|
||||
# only allow a special client to introspect the token
|
||||
return client.client_id == 'introspection_client'
|
||||
|
||||
:return: bool
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def query_token(self, token_string, token_type_hint):
|
||||
"""Get the token from database/storage by the given token string.
|
||||
Developers should implement this method::
|
||||
|
||||
def query_token(self, token_string, token_type_hint):
|
||||
if token_type_hint == 'access_token':
|
||||
tok = Token.query_by_access_token(token_string)
|
||||
elif token_type_hint == 'refresh_token':
|
||||
tok = Token.query_by_refresh_token(token_string)
|
||||
else:
|
||||
tok = Token.query_by_access_token(token_string)
|
||||
if not tok:
|
||||
tok = Token.query_by_refresh_token(token_string)
|
||||
return tok
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def introspect_token(self, token):
|
||||
"""Read given token and return its introspection metadata as a
|
||||
dictionary following `Section 2.2`_::
|
||||
|
||||
def introspect_token(self, token):
|
||||
return {
|
||||
'active': True,
|
||||
'client_id': token.client_id,
|
||||
'token_type': token.token_type,
|
||||
'username': get_token_username(token),
|
||||
'scope': token.get_scope(),
|
||||
'sub': get_token_user_sub(token),
|
||||
'aud': token.client_id,
|
||||
'iss': 'https://server.example.com/',
|
||||
'exp': token.expires_at,
|
||||
'iat': token.issued_at,
|
||||
}
|
||||
|
||||
.. _`Section 2.2`: https://tools.ietf.org/html/rfc7662#section-2.2
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user