venv added, updated

This commit is contained in:
Norbert
2024-09-13 09:46:28 +02:00
parent 577596d9f3
commit 82af8c809a
4812 changed files with 640223 additions and 2 deletions

View File

@@ -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',
]

View 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

View 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)

View 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

View File

@@ -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',
]

View File

@@ -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

View File

@@ -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

View File

@@ -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.'

View File

@@ -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',
]

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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',
]

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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']

View File

@@ -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

View File

@@ -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()

View File

@@ -0,0 +1,3 @@
from .client import AssertionClient
__all__ = ['AssertionClient']

View File

@@ -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)

View File

@@ -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',
]

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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',
]

View File

@@ -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)

View File

@@ -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()

View File

@@ -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'

View File

@@ -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']

View File

@@ -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()

View File

@@ -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']

View File

@@ -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

View File

@@ -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']

View File

@@ -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