first commit

This commit is contained in:
linkitapps
2026-09-14 23:22:10 +09:00
commit 9f9c8fb69b
4207 changed files with 1333187 additions and 0 deletions
@@ -0,0 +1,21 @@
from .auth import ClientAuth
from .auth import TokenAuth
from .base import OAuth2Error
from .client import OAuth2Client
from .rfc6749 import AuthorizationServer
from .rfc6749 import ClientAuthentication
from .rfc6749 import JsonRequest
from .rfc6749 import OAuth2Request
from .rfc6749 import ResourceProtector
__all__ = [
"OAuth2Error",
"ClientAuth",
"TokenAuth",
"OAuth2Client",
"OAuth2Request",
"JsonRequest",
"AuthorizationServer",
"ClientAuthentication",
"ResourceProtector",
]
@@ -0,0 +1,115 @@
import base64
from authlib.common.encoding import to_bytes
from authlib.common.encoding import to_native
from authlib.common.urls import add_params_to_qs
from authlib.common.urls import add_params_to_uri
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
def __del__(self):
del self.client
del self.hooks
@@ -0,0 +1,62 @@
from authlib.common.errors import AuthlibHTTPError
from authlib.common.urls import add_params_to_uri
def invalid_error_characters(text: str) -> list[str]:
"""Check whether the string only contains characters from the restricted ASCII set defined in RFC6749 for errors.
https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
"""
valid_ranges = [
(0x20, 0x21),
(0x23, 0x5B),
(0x5D, 0x7E),
]
return [
char
for char in set(text)
if not any(start <= ord(char) <= end for start, end in valid_ranges)
]
class OAuth2Error(AuthlibHTTPError):
def __init__(
self,
description=None,
uri=None,
status_code=None,
state=None,
redirect_uri=None,
redirect_fragment=False,
error=None,
):
# Human-readable ASCII [USASCII] text providing
# additional information, used to assist the client developer in
# understanding the error that occurred.
# Values for the "error_description" parameter MUST NOT include
# characters outside the set %x20-21 / %x23-5B / %x5D-7E.
if description:
if chars := invalid_error_characters(description):
raise ValueError(
f"Error description contains forbidden characters: {', '.join(chars)}."
)
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)
@@ -0,0 +1,76 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from typing import TypedDict
from joserfc.errors import InvalidClaimError
from joserfc.jwt import BaseClaimsRegistry
from joserfc.jwt import Claims
from joserfc.jwt import JWTClaimsRegistry
from joserfc.registry import Header
class ClaimsOption(TypedDict, total=False):
essential: bool
allow_blank: bool | None
value: str | int | bool
values: list[str | int | bool] | list[str] | list[int] | list[bool]
validate: Callable[[BaseClaims, Any], bool]
class BaseClaims(dict):
registry_cls = BaseClaimsRegistry
REGISTERED_CLAIMS = []
def __init__(
self,
claims: Claims,
header: Header,
options: dict[str, ClaimsOption] | None = None,
params: dict[str, Any] = None,
):
super().__init__(claims)
self._validate_hooks = {}
self.header = header
if options:
self._extract_validate_hooks(options)
self.options = options or {}
self.params = params or {}
def _extract_validate_hooks(self, options: dict[str, ClaimsOption]):
for key in options:
validate = options[key].pop("validate", None)
if validate:
self._validate_hooks[key] = validate
def _run_validate_hooks(self):
for key in self._validate_hooks:
validate = self._validate_hooks[key]
if validate and key in self and not validate(self, self[key]):
raise InvalidClaimError(key)
def get_registered_claims(self):
rv = {}
for k in self.REGISTERED_CLAIMS:
if k in self:
rv[k] = self[k]
return rv
def validate(self, now=None, leeway=0):
validator = self.registry_cls(**self.options)
validator.validate(self)
self._run_validate_hooks()
class JWTClaims(BaseClaims):
registry_cls = JWTClaimsRegistry
REGISTERED_CLAIMS = ["iss", "sub", "aud", "exp", "nbf", "iat", "jti"]
def validate(self, now=None, leeway=0):
if self.options:
validator = self.registry_cls(now, leeway, **self.options)
else:
validator = self.registry_cls(now, leeway)
validator.validate(self)
self._run_validate_hooks()
@@ -0,0 +1,528 @@
from authlib.common.security import generate_token
from authlib.common.urls import url_decode
from .auth import ClientAuth
from .auth import TokenAuth
from .base import OAuth2Error
from .rfc6749.parameters import parse_authorization_code_response
from .rfc6749.parameters import parse_implicit_response
from .rfc6749.parameters import prepare_grant_uri
from .rfc6749.parameters import prepare_token_request
from .rfc7009 import prepare_revoke_token_request
from .rfc7636 import create_s256_code_challenge
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.
:param state: Optional "state" value 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
"""
if auth is None:
auth = self.client_auth(self.revocation_endpoint_auth_method)
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
"""
if auth is None:
auth = self.client_auth(self.token_endpoint_auth_method)
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 compliance_hook in self.compliance_hook[hook]:
url, headers, body = compliance_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 __del__(self):
del self.session
def _guess_grant_type(kwargs):
if "code" in kwargs:
grant_type = "authorization_code"
elif "username" in kwargs and "password" in kwargs:
grant_type = "password"
else:
grant_type = "client_credentials"
return grant_type
@@ -0,0 +1,94 @@
"""authlib.oauth2.rfc6749.
~~~~~~~~~~~~~~~~~~~~~~
This module represents a direct implementation of
The OAuth 2.0 Authorization Framework.
https://tools.ietf.org/html/rfc6749
"""
from .authenticate_client import ClientAuthentication
from .authorization_server import AuthorizationServer
from .endpoint import Endpoint
from .endpoint import EndpointRequest
from .errors import AccessDeniedError
from .errors import InsecureTransportError
from .errors import InvalidClientError
from .errors import InvalidGrantError
from .errors import InvalidRequestError
from .errors import InvalidScopeError
from .errors import MismatchingStateException
from .errors import MissingAuthorizationError
from .errors import MissingCodeException # exceptions for clients
from .errors import MissingTokenException
from .errors import MissingTokenTypeException
from .errors import OAuth2Error
from .errors import UnauthorizedClientError
from .errors import UnsupportedGrantTypeError
from .errors import UnsupportedResponseTypeError
from .errors import UnsupportedTokenTypeError
from .grants import AuthorizationCodeGrant
from .grants import AuthorizationEndpointMixin
from .grants import BaseGrant
from .grants import ClientCredentialsGrant
from .grants import ImplicitGrant
from .grants import RefreshTokenGrant
from .grants import ResourceOwnerPasswordCredentialsGrant
from .grants import TokenEndpointMixin
from .models import AuthorizationCodeMixin
from .models import ClientMixin
from .models import TokenMixin
from .requests import JsonPayload
from .requests import JsonRequest
from .requests import OAuth2Payload
from .requests import OAuth2Request
from .resource_protector import ResourceProtector
from .resource_protector import TokenValidator
from .token_endpoint import TokenEndpoint
from .util import list_to_scope
from .util import scope_to_list
from .wrappers import OAuth2Token
__all__ = [
"OAuth2Payload",
"OAuth2Token",
"OAuth2Request",
"JsonPayload",
"JsonRequest",
"OAuth2Error",
"AccessDeniedError",
"MissingAuthorizationError",
"InvalidGrantError",
"InvalidClientError",
"InvalidRequestError",
"InvalidScopeError",
"InsecureTransportError",
"UnauthorizedClientError",
"UnsupportedResponseTypeError",
"UnsupportedGrantTypeError",
"UnsupportedTokenTypeError",
"MissingCodeException",
"MissingTokenException",
"MissingTokenTypeException",
"MismatchingStateException",
"ClientMixin",
"AuthorizationCodeMixin",
"TokenMixin",
"ClientAuthentication",
"AuthorizationServer",
"ResourceProtector",
"TokenValidator",
"Endpoint",
"EndpointRequest",
"TokenEndpoint",
"BaseGrant",
"AuthorizationEndpointMixin",
"TokenEndpointMixin",
"AuthorizationCodeGrant",
"ImplicitGrant",
"ResourceOwnerPasswordCredentialsGrant",
"ClientCredentialsGrant",
"RefreshTokenGrant",
"scope_to_list",
"list_to_scope",
]
@@ -0,0 +1,114 @@
"""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(
status_code=401,
description=f"The client cannot authenticate with methods: {methods}",
)
raise InvalidClientError(
description=f"The client cannot authenticate with methods: {methods}",
)
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, 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)
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.payload.client_id
if client_id and not request.payload.data.get("client_secret"):
client = _validate_client(query_client, client_id)
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, status_code=400):
if client_id is None:
raise InvalidClientError(
status_code=status_code,
description="Missing 'client_id' parameter.",
)
client = query_client(client_id)
if not client:
raise InvalidClientError(
status_code=status_code,
description="The client does not exist on this server.",
)
return client
@@ -0,0 +1,419 @@
from authlib.common.errors import ContinueIteration
from authlib.deprecate import deprecate
from .authenticate_client import ClientAuthentication
from .endpoint import Endpoint
from .endpoint import EndpointRequest
from .errors import InvalidScopeError
from .errors import OAuth2Error
from .errors import UnsupportedGrantTypeError
from .errors import UnsupportedResponseTypeError
from .hooks import Hookable
from .hooks import hooked
from .requests import JsonRequest
from .requests import OAuth2Request
from .util import scope_to_list
class AuthorizationServer(Hookable):
"""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):
super().__init__()
self.scopes_supported = scopes_supported
self._token_generators = {}
self._client_auth = None
self._authorization_grants = []
self._token_grants = []
self._endpoints = {}
self._extensions = []
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 register_extension(self, extension):
self._extensions.append(extension(self))
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):
"""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()
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: type[Endpoint] | Endpoint):
"""Add extra endpoint to authorization server. e.g.
RevocationEndpoint::
authorization_server.register_endpoint(RevocationEndpoint)
:param endpoint: An 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)
@hooked
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)
# Per RFC 6749 §4.1.2.1, only redirect with the error if the client
# exists and the redirect_uri has been validated against it.
redirect_uri = None
if client_id := request.payload.client_id:
if client := self.query_client(client_id):
if requested_uri := request.payload.redirect_uri:
if client.check_redirect_uri(requested_uri):
redirect_uri = requested_uri
else:
redirect_uri = client.get_default_redirect_uri()
raise UnsupportedResponseTypeError(
f"The response type '{request.payload.response_type}' is not supported by the server.",
request.payload.response_type,
redirect_uri=redirect_uri,
)
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)
try:
request.user = end_user
grant = self.get_authorization_grant(request)
grant.validate_no_multiple_request_parameter(request)
grant.validate_consent_request()
except OAuth2Error as error:
# REQUIRED if a "state" parameter was present in the client
# authorization request. The exact value received from the
# client.
error.state = request.payload.state
raise
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.payload.grant_type)
def validate_endpoint_request(self, name, request=None) -> EndpointRequest:
"""Validate endpoint request and return the validated request object.
Use this for interactive endpoints where you need to handle UI
between validation and response creation.
:param name: Endpoint name
:param request: HTTP request instance
:returns: Validated EndpointRequest object
:raises OAuth2Error: If validation fails
:raises RuntimeError: If endpoint not found
Example::
req = server.validate_endpoint_request("end_session")
if req.needs_confirmation:
return render_template("confirm_logout.html", ...)
return server.create_endpoint_response("end_session", req)
"""
if name not in self._endpoints:
raise RuntimeError(f"There is no '{name}' endpoint.")
endpoint = self._endpoints[name][0]
request = endpoint.create_endpoint_request(request)
return endpoint.validate_request(request)
def create_endpoint_response(self, name, request=None):
"""Validate endpoint request and create endpoint response.
Can be called with:
- A raw HTTP request or None: validates and responds in one step
- A validated EndpointRequest: skips validation, creates response directly
:param name: Endpoint name
:param request: HTTP request instance or validated EndpointRequest
:return: Response, or None if the endpoint returns None
"""
if name not in self._endpoints:
raise RuntimeError(f"There is no '{name}' endpoint.")
endpoints = self._endpoints[name]
# If request is already validated, create response directly
if isinstance(request, EndpointRequest):
endpoint = endpoints[0]
try:
result = endpoint.create_response(request)
if result is None:
return None
return self.handle_response(*result)
except OAuth2Error as error:
return self.handle_error_response(request.request, error)
# Otherwise, validate and respond (existing behavior)
for endpoint in endpoints:
request = endpoint.create_endpoint_request(request)
try:
result = endpoint(request)
if result is None:
return None
return self.handle_response(*result)
except ContinueIteration:
continue
except OAuth2Error as error:
return self.handle_error_response(request, error)
@hooked
def create_authorization_response(self, request=None, grant_user=None, grant=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)
if not grant:
deprecate("The 'grant' parameter will become mandatory.", version="1.8")
try:
grant = self.get_authorization_grant(request)
except UnsupportedResponseTypeError as error:
error.state = request.payload.state
return self.handle_error_response(request, error)
try:
redirect_uri = grant.validate_authorization_request()
args = grant.create_authorization_response(redirect_uri, grant_user)
response = self.handle_response(*args)
except OAuth2Error as error:
error.state = request.payload.state
response = self.handle_error_response(request, error)
grant.execute_hook("after_authorization_response", response)
return response
def create_token_response(self, request=None):
"""Validate token request and create token response.
:param request: HTTP request instance
"""
request = self.create_oauth2_request(request)
try:
grant = self.get_token_grant(request)
except UnsupportedGrantTypeError as error:
return self.handle_error_response(request, error)
try:
grant.validate_token_request()
args = grant.create_token_response()
return self.handle_response(*args)
except OAuth2Error as error:
return self.handle_error_response(request, error)
def handle_error_response(self, request, error):
return self.handle_response(*error(self.get_error_uri(request, error)))
def _create_grant(grant_cls, extensions, request, server):
grant = grant_cls(request, server)
if extensions:
for ext in extensions:
ext(grant)
return grant
@@ -0,0 +1,90 @@
"""
authlib.oauth2.rfc6749.endpoint
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Base class for OAuth2 endpoints.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Any
if TYPE_CHECKING:
from .requests import OAuth2Request
@dataclass
class EndpointRequest:
"""Base class for validated endpoint requests.
This object is returned by :meth:`Endpoint.validate_request` and contains
all validated information from the endpoint request. Subclasses add
endpoint-specific fields.
"""
request: OAuth2Request
client: Any = None
class Endpoint:
"""Base class for OAuth2 endpoints.
Supports two modes of operation:
**Automatic mode** (non-interactive endpoints):
Call ``server.create_endpoint_response(name)`` which validates the request
and creates the response in one step.
**Interactive mode** (endpoints requiring user confirmation):
1. Call ``server.validate_endpoint_request(name)`` to get a validated request
2. Handle user interaction (e.g., show confirmation page)
3. Call ``server.create_endpoint_response(name, validated_request)`` to complete
Subclasses must implement :meth:`validate_request` and :meth:`create_response`.
"""
#: Endpoint name used for registration
ENDPOINT_NAME: str | None = None
def __init__(self, server=None):
self.server = server
def create_endpoint_request(self, request):
"""Convert framework request to OAuth2Request."""
return self.server.create_oauth2_request(request)
def validate_request(self, request: OAuth2Request) -> EndpointRequest:
"""Validate the request and return a validated request object.
:param request: The OAuth2Request to validate
:returns: EndpointRequest with validated data
:raises OAuth2Error: If validation fails
"""
raise NotImplementedError()
def create_response(
self, validated_request: EndpointRequest
) -> tuple[int, Any, list] | None:
"""Create the HTTP response from a validated request.
:param validated_request: The validated EndpointRequest
:returns: Tuple of (status_code, body, headers), or None if the
application should provide its own response
"""
raise NotImplementedError()
def create_endpoint_response(
self, request: OAuth2Request
) -> tuple[int, Any, list] | None:
"""Validate and respond in one step (non-interactive mode).
:param request: The OAuth2Request to process
:returns: Tuple of (status_code, body, headers), or None
"""
validated = self.validate_request(request)
return self.create_response(validated)
def __call__(self, request: OAuth2Request) -> tuple[int, Any, list] | None:
return self.create_endpoint_response(request)
@@ -0,0 +1,247 @@
"""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.common.security import is_secure_transport
from authlib.oauth2.base import OAuth2Error
__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, *args, **kwargs):
super().__init__(*args, **kwargs)
self.response_type = response_type
def get_error_description(self):
return f"response_type={self.response_type} is not supported"
class UnsupportedGrantTypeError(OAuth2Error):
"""The authorization grant type is not supported by the
authorization server.
https://tools.ietf.org/html/rfc6749#section-5.2
"""
error = "unsupported_grant_type"
def __init__(self, grant_type):
super().__init__()
self.grant_type = grant_type
def get_error_description(self):
return f"grant_type={self.grant_type} is not supported"
class InvalidScopeError(OAuth2Error):
"""The requested scope is invalid, unknown, malformed, or
exceeds the scope granted by the resource owner.
https://tools.ietf.org/html/rfc6749#section-5.2
"""
error = "invalid_scope"
description = "The requested scope is invalid, unknown, or malformed."
class AccessDeniedError(OAuth2Error):
"""The resource owner or authorization server denied the request.
Used in authorization endpoint for "code" and "implicit". Defined in
`Section 4.1.2.1`_.
.. _`Section 4.1.2.1`: https://tools.ietf.org/html/rfc6749#section-4.1.2.1
"""
error = "access_denied"
description = "The resource owner or authorization server denied the request"
# -- below are extended errors -- #
class ForbiddenError(OAuth2Error):
status_code = 401
def __init__(self, auth_type=None, realm=None):
super().__init__()
self.auth_type = auth_type
self.realm = realm
def get_headers(self):
headers = super().get_headers()
if not self.auth_type:
return headers
extras = []
if self.realm:
extras.append(f'realm="{self.realm}"')
extras.append(f'error="{self.error}"')
error_description = self.description
extras.append(f'error_description="{error_description}"')
headers.append(("WWW-Authenticate", f"{self.auth_type} " + ", ".join(extras)))
return headers
class MissingAuthorizationError(ForbiddenError):
error = "missing_authorization"
description = "Missing 'Authorization' in headers."
class UnsupportedTokenTypeError(ForbiddenError):
error = "unsupported_token_type"
# -- exceptions for clients -- #
class MissingCodeException(OAuth2Error):
error = "missing_code"
description = "Missing 'code' in response."
class MissingTokenException(OAuth2Error):
error = "missing_token"
description = "Missing 'access_token' in response."
class MissingTokenTypeException(OAuth2Error):
error = "missing_token_type"
description = "Missing 'token_type' in response."
class MismatchingStateException(OAuth2Error):
error = "mismatching_state"
description = "CSRF Warning! State not equal in request and response."
@@ -0,0 +1,41 @@
"""
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
"""
from .authorization_code import AuthorizationCodeGrant
from .base import AuthorizationEndpointMixin
from .base import BaseGrant
from .base import TokenEndpointMixin
from .client_credentials import ClientCredentialsGrant
from .implicit import ImplicitGrant
from .refresh_token import RefreshTokenGrant
from .resource_owner_password_credentials import ResourceOwnerPasswordCredentialsGrant
__all__ = [
"BaseGrant",
"AuthorizationEndpointMixin",
"TokenEndpointMixin",
"AuthorizationCodeGrant",
"ImplicitGrant",
"ResourceOwnerPasswordCredentialsGrant",
"ClientCredentialsGrant",
"RefreshTokenGrant",
]
@@ -0,0 +1,400 @@
import logging
from authlib.common.security import generate_token
from authlib.common.urls import add_params_to_uri
from ..errors import AccessDeniedError
from ..errors import InvalidClientError
from ..errors import InvalidGrantError
from ..errors import InvalidRequestError
from ..errors import InvalidScopeError
from ..errors import OAuth2Error
from ..errors import UnauthorizedClientError
from ..hooks import hooked
from .base import AuthorizationEndpointMixin
from .base import BaseGrant
from .base import TokenEndpointMixin
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(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.payload.state:
params.append(("state", self.request.payload.state))
uri = add_params_to_uri(redirect_uri, params)
headers = [("Location", uri)]
return 302, "", headers
@hooked
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.payload.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
@hooked
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.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.payload.redirect_uri,
scope=request.scope,
user_id=request.user.id,
)
item.save()
.. note:: Use ``request.scope`` instead of ``request.payload.scope`` to get
the resolved scope. Per RFC 6749 Section 3.3, if the client omits the
scope parameter, the server uses a default value from
``client.get_allowed_scope()``.
"""
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.payload.client_id
log.debug("Validate authorization request of %r", client_id)
if client_id is None:
raise InvalidClientError(
description="Missing 'client_id' parameter.",
)
client = grant.server.query_client(client_id)
if not client:
raise InvalidClientError(
description="The client does not exist on this server.",
)
redirect_uri = grant.validate_authorization_redirect_uri(request, client)
response_type = request.payload.response_type
if not client.check_response_type(response_type):
raise UnauthorizedClientError(
f"The client is not authorized to use 'response_type={response_type}'",
redirect_uri=redirect_uri,
)
grant.request.client = client
@hooked
def validate_authorization_request_payload(grant, redirect_uri):
grant.validate_requested_scope()
scope = client.get_allowed_scope(request.payload.scope)
if scope is None:
raise InvalidScopeError()
request.scope = scope
try:
validate_authorization_request_payload(grant, redirect_uri)
except OAuth2Error as error:
error.redirect_uri = redirect_uri
raise error
return redirect_uri
@@ -0,0 +1,158 @@
from authlib.consts import default_json_headers
from ..errors import InvalidRequestError
from ..hooks import Hookable
from ..hooks import hooked
from ..requests import OAuth2Request
class BaseGrant(Hookable):
#: 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):
super().__init__()
self.prompt = None
self.redirect_uri = None
self.request = request
self.server = server
@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.payload.scope
return self.server.validate_requested_scope(scope)
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.payload.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.payload.response_type in cls.RESPONSE_TYPES
@staticmethod
def validate_authorization_redirect_uri(request: OAuth2Request, client):
if request.payload.redirect_uri:
if not client.check_redirect_uri(request.payload.redirect_uri):
raise InvalidRequestError(
f"Redirect URI {request.payload.redirect_uri} is not supported by client.",
)
return request.payload.redirect_uri
else:
redirect_uri = client.get_default_redirect_uri()
if not redirect_uri:
raise InvalidRequestError(
"Missing 'redirect_uri' in request.", state=request.payload.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.payload.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.payload.state
)
@hooked
def validate_consent_request(self):
redirect_uri = self.validate_authorization_request()
self.redirect_uri = redirect_uri
return redirect_uri
def validate_authorization_request(self):
raise NotImplementedError()
def create_authorization_response(self, redirect_uri: str, grant_user):
raise NotImplementedError()
@@ -0,0 +1,109 @@
import logging
from ..errors import UnauthorizedClientError
from ..hooks import hooked
from .base import BaseGrant
from .base import TokenEndpointMixin
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(
f"The client is not authorized to use 'grant_type={self.GRANT_TYPE}'"
)
self.request.client = client
self.validate_requested_scope()
@hooked
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.payload.scope, include_refresh_token=False
)
log.debug("Issue token %r to %r", token, self.client)
self.save_token(token)
return 200, token, self.TOKEN_RESPONSE_HEADER
@@ -0,0 +1,230 @@
import logging
from authlib.common.urls import add_params_to_uri
from ..errors import AccessDeniedError
from ..errors import InvalidScopeError
from ..errors import OAuth2Error
from ..errors import UnauthorizedClientError
from ..hooks import hooked
from .base import AuthorizationEndpointMixin
from .base import BaseGrant
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
@hooked
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.payload.response_type
if not client.check_response_type(response_type):
raise UnauthorizedClientError(
f"The client is not authorized to use 'response_type={response_type}'",
redirect_uri=redirect_uri,
redirect_fragment=True,
)
try:
self.request.client = client
self.validate_requested_scope()
scope = client.get_allowed_scope(self.request.payload.scope)
if scope is None:
raise InvalidScopeError()
self.request.scope = scope
except OAuth2Error as error:
error.redirect_uri = redirect_uri
error.redirect_fragment = True
raise error
return redirect_uri
@hooked
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.payload.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)
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(redirect_uri=redirect_uri, redirect_fragment=True)
@@ -0,0 +1,183 @@
"""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 ..errors import InvalidGrantError
from ..errors import InvalidRequestError
from ..errors import InvalidScopeError
from ..errors import UnauthorizedClientError
from ..hooks import hooked
from ..util import scope_to_list
from .base import BaseGrant
from .base import TokenEndpointMixin
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(
f"The client is not authorized to use 'grant_type={self.GRANT_TYPE}'"
)
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.payload.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
@hooked
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.revoke_old_credential(refresh_token)
return 200, token, self.TOKEN_RESPONSE_HEADER
def issue_token(self, user, refresh_token):
scope = self.request.payload.scope
if not scope:
scope = refresh_token.get_scope()
token = self.generate_token(
user=user,
scope=scope,
include_refresh_token=self.INCLUDE_NEW_REFRESH_TOKEN,
)
return token
def authenticate_refresh_token(self, refresh_token):
"""Get token information with refresh_token string. Developers MUST
implement this method in subclass::
def authenticate_refresh_token(self, refresh_token):
token = Token.get(refresh_token=refresh_token)
if token and not token.refresh_token_revoked:
return token
:param refresh_token: The refresh token issued to the client
:return: token
"""
raise NotImplementedError()
def authenticate_user(self, refresh_token):
"""Authenticate the user related to this credential. Developers MUST
implement this method in subclass::
def authenticate_user(self, credential):
return User.get(credential.user_id)
:param refresh_token: Token object
:return: user
"""
raise NotImplementedError()
def revoke_old_credential(self, refresh_token):
"""The authorization server MAY revoke the old refresh token after
issuing a new refresh token to the client. Developers MUST implement
this method in subclass::
def revoke_old_credential(self, refresh_token):
credential.revoked = True
credential.save()
:param refresh_token: Token object
"""
raise NotImplementedError()
@@ -0,0 +1,155 @@
import logging
from ..errors import InvalidRequestError
from ..errors import UnauthorizedClientError
from ..hooks import hooked
from .base import BaseGrant
from .base import TokenEndpointMixin
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(
f"The client is not authorized to use 'grant_type={self.GRANT_TYPE}'"
)
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()
@hooked
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.payload.scope
token = self.generate_token(user=user, scope=scope)
log.debug("Issue token %r to %r", token, self.client)
self.save_token(token)
return 200, token, self.TOKEN_RESPONSE_HEADER
def authenticate_user(self, username, password):
"""Validate the resource owner password credentials using its
existing password validation algorithm::
def authenticate_user(self, username, password):
user = get_user_by_username(username)
if user.check_password(password):
return user
"""
raise NotImplementedError()
@@ -0,0 +1,37 @@
from collections import defaultdict
class Hookable:
_hooks = None
def __init__(self):
self._hooks = defaultdict(set)
def register_hook(self, hook_type, hook):
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)
def hooked(func=None, before=None, after=None):
"""Execute hooks before and after the decorated method."""
def decorator(func):
before_name = before or f"before_{func.__name__}"
after_name = after or f"after_{func.__name__}"
def wrapper(self, *args, **kwargs):
self.execute_hook(before_name, *args, **kwargs)
result = func(self, *args, **kwargs)
self.execute_hook(after_name, result)
return result
return wrapper
# The decorator has been called without parenthesis
if callable(func):
return decorator(func)
return decorator
@@ -0,0 +1,243 @@
"""authlib.oauth2.rfc6749.models.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This module defines how to construct Client, AuthorizationCode and Token.
"""
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_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()
def get_user(self):
"""A method to get the user object associated with this token:
.. code-block::
def get_user(self):
return User.get(self.user_id)
"""
raise NotImplementedError()
def get_client(self) -> ClientMixin:
"""A method to get the client object associated with this token:
.. code-block::
def get_client(self):
return Client.get(self.client_id)
"""
raise NotImplementedError()
@@ -0,0 +1,214 @@
from authlib.common.encoding import to_unicode
from authlib.common.urls import add_params_to_qs
from authlib.common.urls import add_params_to_uri
from authlib.common.urls import urlparse
from .errors import MismatchingStateException
from .errors import MissingCodeException
from .errors import MissingTokenException
from .errors import MissingTokenTypeException
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, value in kwargs.items():
if value is not None:
if isinstance(value, (list, tuple)):
for v in value:
if v is not None:
params.append((to_unicode(k), v))
else:
params.append((to_unicode(k), value))
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 kwargs.get("code") is None:
raise MissingCodeException()
for k in kwargs:
if kwargs[k]:
params.append((to_unicode(k), kwargs[k]))
return add_params_to_qs(body, params)
def parse_authorization_code_response(uri, state=None):
"""Parse authorization grant response URI into a dict.
If the resource owner grants the access request, the authorization
server issues an authorization code and delivers it to the client by
adding the following parameters to the query component of the
redirection URI using the ``application/x-www-form-urlencoded`` format:
**code**
REQUIRED. The authorization code generated by the
authorization server. The authorization code MUST expire
shortly after it is issued to mitigate the risk of leaks. A
maximum authorization code lifetime of 10 minutes is
RECOMMENDED. The client MUST NOT use the authorization code
more than once. If an authorization code is used more than
once, the authorization server MUST deny the request and SHOULD
revoke (when possible) all tokens previously issued based on
that authorization code. The authorization code is bound to
the client identifier and redirection URI.
**state**
REQUIRED if the "state" parameter was present in the client
authorization request. The exact value received from the
client.
:param uri: The full redirect URL back to the client.
:param state: The state parameter from the authorization request.
For example, the authorization server redirects the user-agent by
sending the following HTTP response:
.. code-block:: http
HTTP/1.1 302 Found
Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA
&state=xyz
"""
query = urlparse.urlparse(uri).query
params = dict(urlparse.parse_qsl(query))
if "code" not in params:
raise MissingCodeException()
params_state = params.get("state")
if state and params_state != state:
raise MismatchingStateException()
return params
def parse_implicit_response(uri, state=None):
"""Parse the implicit token response URI into a dict.
If the resource owner grants the access request, the authorization
server issues an access token and delivers it to the client by adding
the following parameters to the fragment component of the redirection
URI using the ``application/x-www-form-urlencoded`` format:
**access_token**
REQUIRED. The access token issued by the authorization server.
**token_type**
REQUIRED. The type of the token issued as described in
Section 7.1. Value is case insensitive.
**expires_in**
RECOMMENDED. The lifetime in seconds of the access token. For
example, the value "3600" denotes that the access token will
expire in one hour from the time the response was generated.
If omitted, the authorization server SHOULD provide the
expiration time via other means or document the default value.
**scope**
OPTIONAL, if identical to the scope requested by the client,
otherwise REQUIRED. The scope of the access token as described
by Section 3.3.
**state**
REQUIRED if the "state" parameter was present in the client
authorization request. The exact value received from the
client.
Similar to the authorization code response, but with a full token provided
in the URL fragment:
.. code-block:: http
HTTP/1.1 302 Found
Location: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA
&state=xyz&token_type=example&expires_in=3600
"""
fragment = urlparse.urlparse(uri).fragment
params = dict(urlparse.parse_qsl(fragment, keep_blank_values=True))
if "access_token" not in params:
raise MissingTokenException()
if "token_type" not in params:
raise MissingTokenTypeException()
if state and params.get("state", None) != state:
raise MismatchingStateException()
return params
@@ -0,0 +1,199 @@
from collections import defaultdict
from authlib.deprecate import deprecate
from .errors import InsecureTransportError
class OAuth2Payload:
@property
def data(self):
raise NotImplementedError()
@property
def datalist(self) -> defaultdict[str, list]:
raise NotImplementedError()
@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.data.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 BasicOAuth2Payload(OAuth2Payload):
def __init__(self, payload):
self._data = payload
self._datalist = {key: [value] for key, value in payload.items()}
@property
def data(self):
return self._data
@property
def datalist(self) -> defaultdict[str, list]:
return self._datalist
class OAuth2Request(OAuth2Payload):
def __init__(self, method: str, uri: str, body=None, headers=None):
InsecureTransportError.check(uri)
#: HTTP method
self.method = method
self.uri = uri
#: HTTP headers
self.headers = headers or {}
# Store body for backward compatibility but issue deprecation warning if used
if body is not None:
deprecate(
"'body' parameter in OAuth2Request is deprecated. "
"Use the payload system instead.",
version="1.8",
)
self._body = body
self.payload = None
self.client = None
self.auth_method = None
self.user = None
self.authorization_code = None
self.refresh_token = None
self.credential = None
self._scope = None
@property
def args(self):
raise NotImplementedError()
@property
def form(self):
if self._body:
return self._body
raise NotImplementedError()
@property
def data(self):
deprecate(
"'request.data' is deprecated in favor of 'request.payload.data'",
version="1.8",
)
return self.payload.data
@property
def datalist(self) -> defaultdict[str, list]:
deprecate(
"'request.datalist' is deprecated in favor of 'request.payload.datalist'",
version="1.8",
)
return self.payload.datalist
@property
def client_id(self) -> str:
deprecate(
"'request.client_id' is deprecated in favor of 'request.payload.client_id'",
version="1.8",
)
return self.payload.client_id
@property
def response_type(self) -> str:
deprecate(
"'request.response_type' is deprecated in favor of 'request.payload.response_type'",
version="1.8",
)
return self.payload.response_type
@property
def grant_type(self) -> str:
deprecate(
"'request.grant_type' is deprecated in favor of 'request.payload.grant_type'",
version="1.8",
)
return self.payload.grant_type
@property
def redirect_uri(self):
deprecate(
"'request.redirect_uri' is deprecated in favor of 'request.payload.redirect_uri'",
version="1.8",
)
return self.payload.redirect_uri
@property
def scope(self) -> str:
if self._scope is not None:
return self._scope
return self.payload.scope
@scope.setter
def scope(self, value: str):
self._scope = value
@property
def state(self):
deprecate(
"'request.state' is deprecated in favor of 'request.payload.state'",
version="1.8",
)
return self.payload.state
@property
def body(self):
deprecate(
"'request.body' is deprecated. Use the payload system instead.",
version="1.8",
)
return self._body
class JsonPayload:
@property
def data(self):
raise NotImplementedError()
class JsonRequest:
def __init__(self, method, uri, headers=None):
self.method = method
self.uri = uri
self.headers = headers or {}
self.payload = None
@property
def data(self):
deprecate(
"'request.data' is deprecated in favor of 'request.payload.data'",
version="1.8",
)
return self.payload.data
@@ -0,0 +1,148 @@
"""authlib.oauth2.rfc6749.resource_protector.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Implementation of Accessing Protected Resources per `Section 7`_.
.. _`Section 7`: https://tools.ietf.org/html/rfc6749#section-7
"""
from .errors import MissingAuthorizationError
from .errors import UnsupportedTokenTypeError
from .util import scope_to_list
class TokenValidator:
"""Base token validator class. Subclass this validator to register
into ResourceProtector instance.
"""
TOKEN_TYPE = "bearer"
def __init__(self, realm=None, **extra_attributes):
self.realm = realm
self.extra_attributes = extra_attributes
@staticmethod
def scope_insufficient(token_scopes, required_scopes):
if not required_scopes:
return False
token_scopes = scope_to_list(token_scopes)
if not token_scopes:
return True
token_scopes = set(token_scopes)
for scope in required_scopes:
resource_scopes = set(scope_to_list(scope))
if token_scopes.issuperset(resource_scopes):
return False
return True
def authenticate_token(self, token_string):
"""A method to query token from database with the given token string.
Developers MUST re-implement this method. For instance::
def authenticate_token(self, token_string):
return get_token_from_database(token_string)
:param token_string: A string to represent the access_token.
:return: token
"""
raise NotImplementedError()
def validate_request(self, request):
"""A method to validate if the HTTP request is valid or not. Developers MUST
re-implement this method. For instance, your server requires a
"X-Device-Version" in the header::
def validate_request(self, request):
if "X-Device-Version" not in request.headers:
raise InvalidRequestError()
Usually, you don't have to detect if the request is valid or not. If you have
to, you MUST re-implement this method.
:param request: instance of HttpRequest
:raise: InvalidRequestError
"""
def validate_token(self, token, scopes, request):
"""A method to validate if the authorized token is valid, if it has the
permission on the given scopes. Developers MUST re-implement this method.
e.g, check if token is expired, revoked::
def validate_token(self, token, scopes, request):
if not token:
raise InvalidTokenError()
if token.is_expired() or token.is_revoked():
raise InvalidTokenError()
if not match_token_scopes(token, scopes):
raise InsufficientScopeError()
"""
raise NotImplementedError()
class ResourceProtector:
def __init__(self):
self._token_validators = {}
self._default_realm = None
self._default_auth_type = None
def register_token_validator(self, validator: TokenValidator):
"""Register a token validator for a given Authorization type.
Authlib has a built-in BearerTokenValidator per rfc6750.
"""
if not self._default_auth_type:
self._default_realm = validator.realm
self._default_auth_type = validator.TOKEN_TYPE
if validator.TOKEN_TYPE not in self._token_validators:
self._token_validators[validator.TOKEN_TYPE] = validator
def get_token_validator(self, token_type):
"""Get token validator from registry for the given token type."""
validator = self._token_validators.get(token_type.lower())
if not validator:
raise UnsupportedTokenTypeError(
self._default_auth_type, self._default_realm
)
return validator
def parse_request_authorization(self, request):
"""Parse the token and token validator from request Authorization header.
Here is an example of Authorization header::
Authorization: Bearer a-token-string
This method will parse this header, if it can find the validator for
``Bearer``, it will return the validator and ``a-token-string``.
:return: validator, token_string
:raise: MissingAuthorizationError
:raise: UnsupportedTokenTypeError
"""
auth = request.headers.get("Authorization")
if not auth:
raise MissingAuthorizationError(
self._default_auth_type, self._default_realm
)
# https://tools.ietf.org/html/rfc6749#section-7.1
token_parts = auth.split(None, 1)
if len(token_parts) != 2:
raise UnsupportedTokenTypeError(
self._default_auth_type, self._default_realm
)
token_type, token_string = token_parts
validator = self.get_token_validator(token_type)
return validator, token_string
def validate_request(self, scopes, request, **kwargs):
"""Validate the request and return a token."""
validator, token_string = self.parse_request_authorization(request)
validator.validate_request(request)
token = validator.authenticate_token(token_string)
validator.validate_token(token, scopes, request, **kwargs)
return token
@@ -0,0 +1,30 @@
from .endpoint import Endpoint
class TokenEndpoint(Endpoint):
"""Base class for token-based endpoints (revocation, introspection).
Subclasses must implement :meth:`authenticate_token` and
:meth:`create_endpoint_response`.
"""
#: Supported token types
SUPPORTED_TOKEN_TYPES = ("access_token", "refresh_token")
#: Allowed client authenticate methods
CLIENT_AUTH_METHODS = ["client_secret_basic"]
def authenticate_endpoint_client(self, request):
"""Authenticate 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):
"""Authenticate and return the token. Subclasses must implement this."""
raise NotImplementedError()
def create_endpoint_response(self, request):
"""Process the request and return response. Subclasses must implement this."""
raise NotImplementedError()
@@ -0,0 +1,42 @@
import base64
import binascii
from urllib.parse import unquote
from authlib.common.encoding import to_unicode
def list_to_scope(scope):
"""Convert a list of scopes to a space separated string."""
if isinstance(scope, (set, tuple, list)):
return " ".join([to_unicode(s) for s in scope])
if scope is None:
return scope
return to_unicode(scope)
def scope_to_list(scope):
"""Convert a space separated string to a list of scopes."""
if isinstance(scope, (tuple, list, set)):
return [to_unicode(s) for s in scope]
elif scope is None:
return None
return scope.strip().split()
def extract_basic_authorization(headers):
auth = headers.get("Authorization")
if not auth or " " not in auth:
return None, None
auth_type, auth_token = auth.split(None, 1)
if auth_type.lower() != "basic":
return None, None
try:
query = to_unicode(base64.b64decode(auth_token))
except (binascii.Error, TypeError):
return None, None
if ":" in query:
username, password = query.split(":", 1)
return unquote(username), unquote(password)
return query, None
@@ -0,0 +1,35 @@
import time
class OAuth2Token(dict):
def __init__(self, params):
if params.get("expires_at") is not None:
try:
params["expires_at"] = int(params["expires_at"])
except ValueError:
# If expires_at is not parseable, fall back to expires_in if available
# Otherwise leave expires_at untouched
if params.get("expires_in"):
params["expires_at"] = int(time.time()) + int(params["expires_in"])
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 expires_at is None:
return None
# Only check expiration if expires_at is an integer
if not isinstance(expires_at, int):
return None
# small timedelta to consider token as expired before it actually expires
expiration_threshold = expires_at - leeway
return expiration_threshold < time.time()
@classmethod
def from_dict(cls, token):
if isinstance(token, dict) and not isinstance(token, cls):
token = cls(token)
return token
@@ -0,0 +1,27 @@
"""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 InsufficientScopeError
from .errors import InvalidTokenError
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",
]
@@ -0,0 +1,88 @@
"""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=None,
):
super().__init__(description, uri, status_code, state)
self.realm = realm
self.extra_attributes = extra_attributes or {}
def get_headers(self):
"""If the protected resource request does not include authentication
credentials or does not contain an access token that enables access
to the protected resource, the resource server MUST include the HTTP
"WWW-Authenticate" response header field; it MAY include it in
response to other conditions as well.
https://tools.ietf.org/html/rfc6750#section-3
"""
headers = super().get_headers()
extras = []
if self.realm:
extras.append(f'realm="{self.realm}"')
if self.extra_attributes:
extras.extend(
[f'{k}="{self.extra_attributes[k]}"' for k in self.extra_attributes]
)
extras.append(f'error="{self.error}"')
error_description = self.get_error_description()
extras.append(f'error_description="{error_description}"')
headers.append(("WWW-Authenticate", "Bearer " + ", ".join(extras)))
return headers
class InsufficientScopeError(OAuth2Error):
"""The request requires higher privileges than provided by the
access token. The resource server SHOULD respond with the HTTP
403 (Forbidden) status code and MAY include the "scope"
attribute with the scope necessary to access the protected
resource.
https://tools.ietf.org/html/rfc6750#section-3.1
"""
error = "insufficient_scope"
description = (
"The request requires higher privileges than provided by the access token."
)
status_code = 403
@@ -0,0 +1,42 @@
from authlib.common.urls import add_params_to_qs
from authlib.common.urls import add_params_to_uri
def add_to_uri(token, uri):
"""Add a Bearer Token to the request URI.
Not recommended, use only if client can't use authorization header or body.
http://www.example.com/path?access_token=h480djs93hd8
"""
return add_params_to_uri(uri, [("access_token", token)])
def add_to_headers(token, headers=None):
"""Add a Bearer Token to the request URI.
Recommended method of passing bearer tokens.
Authorization: Bearer h480djs93hd8
"""
headers = headers or {}
headers["Authorization"] = f"Bearer {token}"
return headers
def add_to_body(token, body=None):
"""Add a Bearer Token to the request body.
access_token=h480djs93hd8
"""
if body is None:
body = ""
return add_params_to_qs(body, [("access_token", token)])
def add_bearer_token(token, uri, headers, body, placement="header"):
if placement in ("uri", "url", "query"):
uri = add_to_uri(token, uri)
elif placement in ("header", "headers"):
headers = add_to_headers(token, headers)
elif placement == "body":
body = add_to_body(token, body)
return uri, headers, body
@@ -0,0 +1,125 @@
from ..rfc6749.errors import InvalidScopeError
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):
"""Get the allowed scope for token generation.
Per RFC 6749 Section 3.3, if the client omits the scope parameter,
the authorization server MUST either process the request using a
pre-defined default value or fail the request indicating an invalid scope.
:param client: the client making the request
:param scope: the requested scope (may be None if omitted)
:return: the allowed scope string
:raises InvalidScopeError: if client.get_allowed_scope returns None
"""
scope = client.get_allowed_scope(scope)
if scope is None:
raise InvalidScopeError()
return scope
def generate(
self,
grant_type,
client,
user=None,
scope=None,
expires_in=None,
include_refresh_token=True,
):
"""Generate a bearer token for OAuth 2.0 authorization token endpoint.
:param client: the client that making the request.
:param grant_type: current requested grant_type.
:param user: current authorized user.
:param expires_in: if provided, use this value as expires_in.
:param scope: current requested scope.
:param include_refresh_token: should refresh_token be included.
:return: Token dict
"""
scope = self.get_allowed_scope(client, scope)
access_token = self.access_token_generator(
client=client, grant_type=grant_type, user=user, scope=scope
)
if expires_in is None:
expires_in = self._get_expires_in(client, grant_type)
token = {
"token_type": "Bearer",
"access_token": access_token,
}
if expires_in:
token["expires_in"] = expires_in
if include_refresh_token and self.refresh_token_generator:
token["refresh_token"] = self.refresh_token_generator(
client=client, grant_type=grant_type, user=user, scope=scope
)
if scope:
token["scope"] = scope
return token
def __call__(
self,
grant_type,
client,
user=None,
scope=None,
expires_in=None,
include_refresh_token=True,
):
return self.generate(
grant_type, client, user, scope, expires_in, include_refresh_token
)
@@ -0,0 +1,42 @@
"""authlib.oauth2.rfc6750.validator.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Validate Bearer Token for in request, scope and token.
"""
from ..rfc6749 import TokenValidator
from .errors import InsufficientScopeError
from .errors import InvalidTokenError
class BearerTokenValidator(TokenValidator):
TOKEN_TYPE = "bearer"
def authenticate_token(self, token_string):
"""A method to query token from database with the given token string.
Developers MUST re-implement this method. For instance::
def authenticate_token(self, token_string):
return get_token_from_database(token_string)
:param token_string: A string to represent the access_token.
:return: token
"""
raise NotImplementedError()
def validate_token(self, token, scopes, request):
"""Check if token is active and matches the requested scopes."""
if not token:
raise InvalidTokenError(
realm=self.realm, extra_attributes=self.extra_attributes
)
if token.is_expired():
raise InvalidTokenError(
realm=self.realm, extra_attributes=self.extra_attributes
)
if token.is_revoked():
raise InvalidTokenError(
realm=self.realm, extra_attributes=self.extra_attributes
)
if self.scope_insufficient(token.get_scope(), scopes):
raise InsufficientScopeError()
@@ -0,0 +1,13 @@
"""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"]
@@ -0,0 +1,24 @@
from authlib.common.urls import add_params_to_qs
def prepare_revoke_token_request(token, token_type_hint=None, body=None, headers=None):
"""Construct request body and headers for revocation endpoint.
:param token: access_token or refresh_token string.
:param token_type_hint: Optional, `access_token` or `refresh_token`.
:param body: current request body.
:param headers: current request headers.
:return: tuple of (body, headers)
https://tools.ietf.org/html/rfc7009#section-2.1
"""
params = [("token", token)]
if token_type_hint:
params.append(("token_type_hint", token_type_hint))
body = add_params_to_qs(body or "", params)
if headers is None:
headers = {}
headers["Content-Type"] = "application/x-www-form-urlencoded"
return body, headers
@@ -0,0 +1,112 @@
from authlib.consts import default_json_headers
from ..rfc6749 import InvalidGrantError
from ..rfc6749 import InvalidRequestError
from ..rfc6749 import TokenEndpoint
from ..rfc6749 import UnsupportedTokenTypeError
class RevocationEndpoint(TokenEndpoint):
"""Implementation of revocation endpoint which is described in
`RFC7009`_.
.. _RFC7009: https://tools.ietf.org/html/rfc7009
"""
#: Endpoint name to be registered
ENDPOINT_NAME = "revocation"
def authenticate_token(self, request, client):
"""The client constructs the request by including the following
parameters using the "application/x-www-form-urlencoded" format in
the HTTP request entity-body:
token
REQUIRED. The token that the client wants to get revoked.
token_type_hint
OPTIONAL. A hint about the type of the token submitted for
revocation.
"""
self.check_params(request, client)
token = self.query_token(
request.form["token"], request.form.get("token_type_hint")
)
if token and not token.check_client(client):
raise InvalidGrantError()
return token
def check_params(self, request, client):
if "token" not in request.form:
raise InvalidRequestError()
hint = request.form.get("token_type_hint")
if hint and hint not in self.SUPPORTED_TOKEN_TYPES:
raise UnsupportedTokenTypeError()
def create_endpoint_response(self, request):
"""Validate revocation request and create the response for revocation.
For example, a client may request the revocation of a refresh token
with the following request::
POST /revoke HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
token=45ghiukldjahdnhzdauz&token_type_hint=refresh_token
:returns: (status_code, body, headers)
"""
# The authorization server first validates the client credentials
client = self.authenticate_endpoint_client(request)
# then verifies whether the token was issued to the client making
# the revocation request
token = self.authenticate_token(request, client)
# the authorization server invalidates the token
if token:
self.revoke_token(token, request)
self.server.send_signal(
"after_revoke_token",
token=token,
client=client,
)
return 200, {}, default_json_headers
def query_token(self, token_string, token_type_hint):
"""Get the token from database/storage by the given token string.
Developers should implement this method::
def query_token(self, token_string, token_type_hint):
if token_type_hint == 'access_token':
return Token.query_by_access_token(token_string)
if token_type_hint == 'refresh_token':
return Token.query_by_refresh_token(token_string)
return Token.query_by_access_token(token_string) or \
Token.query_by_refresh_token(token_string)
"""
raise NotImplementedError()
def revoke_token(self, token, request):
"""Mark token as revoked. Since token MUST be unique, it would be
dangerous to delete it. Consider this situation:
1. Jane obtained a token XYZ
2. Jane revoked (deleted) token XYZ
3. Bob generated a new token XYZ
4. Jane can use XYZ to access Bob's resource
It would be secure to mark a token as revoked::
def revoke_token(self, token, request):
hint = request.form.get("token_type_hint")
if hint == "access_token":
token.access_token_revoked = True
else:
token.access_token_revoked = True
token.refresh_token_revoked = True
token.save()
"""
raise NotImplementedError()
@@ -0,0 +1,3 @@
from .client import AssertionClient
__all__ = ["AssertionClient"]
@@ -0,0 +1,111 @@
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,
client_id=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
self.client_id = client_id
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
if self.client_id:
data["client_id"] = self.client_id
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)
def __del__(self):
if self.session:
del self.session
@@ -0,0 +1,31 @@
"""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 .assertion import client_secret_jwt_sign
from .assertion import private_key_jwt_sign
from .auth import ClientSecretJWT
from .auth import PrivateKeyJWT
from .client import JWTBearerClientAssertion
from .jwt_bearer import JWTBearerGrant
from .token import JWTBearerTokenGenerator
from .validator import JWTBearerToken
from .validator import JWTBearerTokenValidator
__all__ = [
"JWTBearerGrant",
"JWTBearerClientAssertion",
"client_secret_jwt_sign",
"private_key_jwt_sign",
"ClientSecretJWT",
"PrivateKeyJWT",
"JWTBearerToken",
"JWTBearerTokenGenerator",
"JWTBearerTokenValidator",
]
@@ -0,0 +1,90 @@
import time
from joserfc import jwt
from authlib._joserfc_helpers import import_any_key
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 expires_at is None:
expires_at = issued_at + expires_in
payload["iat"] = issued_at
payload["exp"] = expires_at
if claims is None:
claims = {}
# jti uniquely identifies the JWT and prevents replay attacks (RFC 7523 §3)
if "jti" not in claims:
claims["jti"] = generate_token(36)
if claims:
payload.update(claims)
return jwt.encode(header, payload, import_any_key(key), algorithms=[header["alg"]])
def client_secret_jwt_sign(
client_secret, client_id, token_endpoint, alg="HS256", claims=None, **kwargs
):
return _sign(client_secret, client_id, token_endpoint, alg, claims, **kwargs)
def private_key_jwt_sign(
private_key, client_id, token_endpoint, alg="RS256", claims=None, **kwargs
):
return _sign(private_key, client_id, token_endpoint, alg, claims, **kwargs)
def _sign(key, client_id, token_endpoint, alg, claims=None, **kwargs):
# REQUIRED. Issuer. This MUST contain the client_id of the OAuth Client.
issuer = client_id
# REQUIRED. Subject. This MUST contain the client_id of the OAuth Client.
subject = client_id
# The Audience SHOULD be the URL of the Authorization Server's Token Endpoint.
audience = token_endpoint
# jti is required
if claims is None:
claims = {}
if "jti" not in claims:
claims["jti"] = generate_token(36)
return sign_jwt_bearer_assertion(
key=key,
issuer=issuer,
audience=audience,
subject=subject,
claims=claims,
alg=alg,
**kwargs,
)
@@ -0,0 +1,116 @@
from joserfc.jwk import OctKey
from joserfc.jwk import RSAKey
from joserfc.jwk import ECKey
from joserfc.jwk import OKPKey
from authlib.common.urls import add_params_to_qs
from .assertion import client_secret_jwt_sign
from .assertion import 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):
if isinstance(auth.client_secret, OctKey):
key = auth.client_secret
else:
key = OctKey.import_key(auth.client_secret)
return client_secret_jwt_sign(
key,
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):
if isinstance(auth.client_secret, (RSAKey, ECKey, OKPKey)):
key = auth.client_secret
else:
key = RSAKey.import_key(auth.client_secret)
return private_key_jwt_sign(
key,
client_id=auth.client_id,
token_endpoint=token_endpoint,
claims=self.claims,
header=self.headers,
alg=self.alg,
)
@@ -0,0 +1,187 @@
from __future__ import annotations
import logging
from joserfc import jwk
from joserfc import jws
from joserfc import jwt
from joserfc.errors import JoseError
from joserfc.util import to_bytes
from authlib._joserfc_helpers import import_any_key
from authlib.common.encoding import json_loads
from authlib.deprecate import deprecate
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=None, validate_jti=True, leeway=60):
if token_url is not None: # pragma: no cover
deprecate(
"'token_url' is deprecated. Override 'get_audiences' instead.",
version="1.8",
)
self.token_url = token_url
self._validate_jti = validate_jti
# A small allowance of time, typically no more than a few minutes,
# to account for clock skew. The default is 60 seconds.
self.leeway = leeway
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:
headers, claims = self.extract_assertion(assertion)
# "sub" is read before the signature and the claims are verified,
# so it cannot be assumed to be present nor to be a string.
if "sub" not in claims:
raise InvalidClientError(description="Missing claim: 'sub'")
client_id = claims["sub"]
if not isinstance(client_id, str):
raise InvalidClientError(description="Invalid claim: 'sub'")
client = query_client(client_id)
if not client:
raise InvalidClientError(
description="The client does not exist on this server."
)
try:
key = import_any_key(self.resolve_client_public_key(client))
except TypeError: # pragma: no cover
key = import_any_key(self.resolve_client_public_key(client, headers))
deprecate(
"resolve_client_public_key takes only 'client' parameter.",
version="1.8",
)
request.client = client
self.process_assertion_claims(assertion, key)
return self.authenticate_client(request.client)
log.debug("Authenticate via %r failed", self.CLIENT_AUTH_METHOD)
def verify_claims(self, claims: jwt.Claims):
# iss and sub MUST be the client_id
options = {
"iss": {"essential": True},
"sub": {"essential": True},
"aud": {"essential": True, "values": self.get_audiences()},
"exp": {"essential": True},
}
claims_requests = jwt.JWTClaimsRegistry(leeway=self.leeway, **options)
try:
claims_requests.validate(claims)
except JoseError as e:
log.debug("Assertion Error: %r", e)
raise InvalidClientError(description=e.description) from e
if claims["sub"] != claims["iss"]:
raise InvalidClientError(description="Issuer and Subject MUST match.")
if self._validate_jti:
if "jti" not in claims:
raise InvalidClientError(description="Missing JWT ID.")
if not self.validate_jti(claims, claims["jti"]):
raise InvalidClientError(description="JWT ID is used before.")
def get_audiences(self):
"""Return a list of valid audience identifiers for this authorization
server. Per RFC 7523 Section 3, the audience identifies the
authorization server as an intended audience.
Developers MUST implement this method::
def get_audiences(self):
return ["https://example.com/oauth/token", "https://example.com"]
:return: list of valid audience strings
"""
if self.token_url is not None: # pragma: no cover
return [self.token_url]
raise NotImplementedError() # pragma: no cover
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:
token = jwt.decode(assertion, resolve_key)
except JoseError as e:
log.debug("Assertion Error: %r", e)
raise InvalidClientError(description=e.description) from e
self.verify_claims(token.claims)
return token.claims
def authenticate_client(self, client):
if client.check_endpoint_auth_method(self.CLIENT_AUTH_METHOD, "token"):
return client
raise InvalidClientError(
description=f"The client cannot authenticate with method: {self.CLIENT_AUTH_METHOD}"
)
def extract_assertion(self, assertion: str):
try:
obj = jws.extract_compact(to_bytes(assertion))
except JoseError as e:
log.debug("Assertion Error: %r", e)
raise InvalidClientError(description="Invalid JWT assertion.") from e
try:
claims = json_loads(obj.payload)
except ValueError:
raise InvalidClientError(description="Invalid JWT payload.") from None
if not isinstance(claims, dict):
raise InvalidClientError(description="Invalid JWT payload.")
return obj.headers(), claims
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) -> jwk.Key | jwk.KeySet:
"""Resolve the client public key for verifying the JWT signature.
Developers MUST implement this method::
from joserfc.jwk import KeySet
def resolve_client_public_key(self, client):
return KeySet.import_key_set(client.public_jwks)
"""
raise NotImplementedError()
@@ -0,0 +1,285 @@
import logging
from joserfc import jwk
from joserfc import jws
from joserfc import jwt
from joserfc.errors import JoseError
from joserfc.util import to_bytes
from authlib._joserfc_helpers import import_any_key
from authlib.common.encoding import json_loads
from authlib.deprecate import deprecate
from ..rfc6749 import BaseGrant
from ..rfc6749 import InvalidClientError
from ..rfc6749 import InvalidGrantError
from ..rfc6749 import InvalidRequestError
from ..rfc6749 import TokenEndpointMixin
from ..rfc6749 import UnauthorizedClientError
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},
}
# A small allowance of time, typically no more than a few minutes,
# to account for clock skew. The default is 60 seconds.
LEEWAY = 60
@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 verify_claims(self, claims: jwt.Claims):
options = dict(self.CLAIMS_OPTIONS)
audiences = self.get_audiences()
if audiences:
options["aud"] = {"essential": True, "values": audiences}
else:
deprecate(
"'get_audiences' must return a non-empty list. "
"Audience validation will become mandatory.",
version="1.8",
)
claims_requests = jwt.JWTClaimsRegistry(leeway=self.LEEWAY, **options)
try:
claims_requests.validate(claims)
except JoseError as e:
log.debug("Assertion Error: %r", e)
raise InvalidGrantError(description=e.description) from e
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
"""
headers, claims = self.extract_assertion(assertion)
# "iss" is read before the signature and the claims are verified, so it
# cannot be assumed to be present nor to be a string.
if "iss" not in claims:
raise InvalidGrantError(description="Missing claim: 'iss'")
issuer = claims["iss"]
if not isinstance(issuer, str):
raise InvalidGrantError(description="Invalid claim: 'iss'")
client = self.resolve_issuer_client(issuer)
if not client:
raise InvalidGrantError(
description="The client does not exist on this server."
)
if hasattr(self, "resolve_client_key"): # pragma: no cover
key = import_any_key(self.resolve_client_key(client, headers, claims))
deprecate(
"Use resolve_client_public_key instead of resolve_client_key.",
version="1.8",
)
else:
key = import_any_key(self.resolve_client_public_key(client))
try:
token = jwt.decode(assertion, key)
except JoseError as e:
log.debug("Assertion Error: %r", e)
raise InvalidGrantError(description=e.description) from e
except ValueError as e:
log.debug("Assertion Error: %r", e)
raise InvalidGrantError("Invalid JWT assertion") from None
self.verify_claims(token.claims)
return token.claims
def extract_assertion(self, assertion: str):
try:
obj = jws.extract_compact(to_bytes(assertion))
except JoseError as e:
log.debug("Assertion Error: %r", e)
raise InvalidGrantError(description="Invalid JWT assertion.") from e
try:
claims = json_loads(obj.payload)
except ValueError:
raise InvalidGrantError(description="Invalid JWT payload.") from None
if not isinstance(claims, dict):
raise InvalidGrantError(description="Invalid JWT payload.")
return obj.headers(), claims
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(
f"The client is not authorized to use 'grant_type={self.GRANT_TYPE}'"
)
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.payload.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, or ``None`` when no client matches the
issuer. In that case an ``invalid_grant`` error is returned.
"""
raise NotImplementedError()
def resolve_client_public_key(self, client) -> jwk.Key | jwk.KeySet:
"""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_public_key(self, client):
from joserfc import KeySet
key_set = KeySet.import_key_set(client.jwks)
return key_set
:param client: instance of OAuth client model
:return: OctKey, RSAKey, ECKey, OKPKey or KeySet 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 get_audiences(self):
"""Return a list of valid audience identifiers for this authorization
server. Per RFC 7523 Section 3:
The authorization server MUST reject any JWT that does not
contain its own identity as the intended audience.
Developers SHOULD implement this method to return the list of valid
audience values, typically including the token endpoint URL and/or
the issuer identifier. For example::
def get_audiences(self):
return ["https://example.com/oauth/token", "https://example.com"]
If this method returns an empty list, audience value validation is
skipped (only presence is checked).
:return: list of valid audience strings
"""
return []
def has_granted_permission(self, client, user):
"""Check if the client has permission to access the given user's resource.
Developers MUST implement it in subclass, e.g.::
def has_granted_permission(self, client, user):
permission = ClientUserGrant.query(client=client, user=user)
return permission.granted
:param client: instance of OAuth client model
:param user: instance of User model
:return: bool
"""
raise NotImplementedError()
@@ -0,0 +1,108 @@
import time
from joserfc import jwt
from authlib._joserfc_helpers import import_any_key
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 = import_any_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 str(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},
claims=token_data,
key=self.secret_key,
algorithms=[self.alg],
)
token = {
"token_type": "Bearer",
"access_token": access_token,
"expires_in": expires_in,
}
if scope:
token["scope"] = scope
return token
def __call__(
self,
grant_type,
client,
user=None,
scope=None,
expires_in=None,
include_refresh_token=True,
):
# there is absolutely no refresh token in JWT format
return self.generate(grant_type, client, user, scope, expires_in)
@@ -0,0 +1,63 @@
import logging
import time
from joserfc import jwt
from joserfc.errors import JoseError
from authlib._joserfc_helpers import import_any_key
from ..rfc6749 import TokenMixin
from ..rfc6750 import BearerTokenValidator
logger = logging.getLogger(__name__)
class JWTBearerToken(TokenMixin, dict):
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, leeway=60, **extra_attributes):
super().__init__(realm, **extra_attributes)
self.public_key = import_any_key(public_key)
self.leeway = leeway
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: str):
try:
token = jwt.decode(token_string, self.public_key)
except JoseError as error:
logger.debug("Authenticate token failed. %r", error)
return None
claims_requests = jwt.JWTClaimsRegistry(leeway=self.leeway, **self.claims_options)
try:
claims_requests.validate(token.claims)
except JoseError as error:
logger.debug("Authenticate token failed. %r", error)
return None
return JWTBearerToken(token.claims)
@@ -0,0 +1,24 @@
"""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 InvalidClientMetadataError
from .errors import InvalidRedirectURIError
from .errors import InvalidSoftwareStatementError
from .errors import UnapprovedSoftwareStatementError
__all__ = [
"ClientMetadataClaims",
"ClientRegistrationEndpoint",
"InvalidRedirectURIError",
"InvalidClientMetadataError",
"InvalidSoftwareStatementError",
"UnapprovedSoftwareStatementError",
]
@@ -0,0 +1,271 @@
from joserfc.errors import InvalidClaimError
from joserfc.errors import JoseError
from joserfc.jwk import KeySet
from authlib.common.urls import is_valid_url
from authlib.oauth2.claims import BaseClaims
from ..rfc6749 import scope_to_list
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, now=None, leeway=0):
super().validate(now, leeway)
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"
def validate_grant_types(self):
"""Array of OAuth 2.0 grant type strings that the client can use at
the token endpoint.
"""
def validate_response_types(self):
"""Array of the OAuth 2.0 response type strings that the client can
use at the authorization endpoint.
"""
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.
"""
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:
KeySet.import_key_set(jwks)
except (JoseError, ValueError) as exc:
raise InvalidClaimError("jwks") from exc
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, fragments_allowed=False):
raise InvalidClaimError(key)
@classmethod
def get_claims_options(cls, metadata):
"""Generate claims options validation from Authorization Server metadata."""
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(items.split()) for items in 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(items.split()) for items in value] if value else [{"code"}]
)
return all(
response_type in response_types_supported
for response_type in 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
@@ -0,0 +1,194 @@
import binascii
import os
import time
from joserfc import jwt
from joserfc.errors import JoseError
from authlib._joserfc_helpers import import_any_key
from authlib.common.security import generate_token
from authlib.consts import default_json_headers
from authlib.deprecate import deprecate
from ..rfc6749 import AccessDeniedError
from ..rfc6749 import InvalidRequestError
from .claims import ClientMetadataClaims
from .errors import InvalidClientMetadataError
from .errors import InvalidSoftwareStatementError
from .errors import UnapprovedSoftwareStatementError
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"
#: 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=None, claims_classes=None):
self.server = server
self.claims_classes = claims_classes or [ClientMetadataClaims]
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(request)
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.payload.data:
raise InvalidRequestError()
json_data = request.payload.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)
client_metadata = {}
server_metadata = self.get_server_metadata()
for claims_class in self.claims_classes:
options = (
claims_class.get_claims_options(server_metadata)
if hasattr(claims_class, "get_claims_options") and server_metadata
else {}
)
claims = claims_class(json_data, {}, options, server_metadata)
try:
claims.validate()
except JoseError as error:
raise InvalidClientMetadataError(error.description) from error
client_metadata.update(**claims.get_registered_claims())
return client_metadata
def extract_software_statement(self, software_statement, request):
key = self.resolve_public_key(request)
if not key:
raise UnapprovedSoftwareStatementError()
try:
key = import_any_key(key)
algorithms = self.software_statement_alg_values_supported
token = jwt.decode(software_statement, key, algorithms=algorithms)
# there is no need to validate claims
return token.claims
except JoseError as exc:
raise InvalidSoftwareStatementError() from exc
def generate_client_info(self, request):
# https://tools.ietf.org/html/rfc7591#section-3.2.1
try:
client_id = self.generate_client_id(request)
except TypeError: # pragma: no cover
client_id = self.generate_client_id() # type: ignore
deprecate(
"generate_client_id takes a 'request' parameter. "
"It will become mandatory in coming releases",
version="1.8",
)
try:
client_secret = self.generate_client_secret(request)
except TypeError: # pragma: no cover
client_secret = self.generate_client_secret()
deprecate(
"generate_client_secret takes a 'request' parameter. "
"It will become mandatory in coming releases",
version="1.8",
)
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, request):
"""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, request):
"""Generate ``client_secret`` value. Developers MAY rewrite this method
to use their own way to generate ``client_secret``.
"""
return binascii.hexlify(os.urandom(24)).decode("ascii")
def get_server_metadata(self):
"""Return server metadata which includes supported grant types,
response types and etc.
"""
raise NotImplementedError()
def authenticate_token(self, request):
"""Authenticate current credential who is requesting to register a client.
Developers MUST implement this method in subclass::
def authenticate_token(self, request):
auth = request.headers.get("Authorization")
return get_token_by_auth(auth)
:return: token instance
"""
raise NotImplementedError()
def resolve_public_key(self, request):
"""Resolve a public key for decoding ``software_statement``. If
``enable_software_statement=True``, developers MUST implement this
method in subclass::
def resolve_public_key(self, request):
return get_public_key_from_user(request.credential)
:return: JWK or Key string
"""
raise NotImplementedError()
def save_client(self, client_info, client_metadata, request):
"""Save client into database. Developers MUST implement this method
in subclass::
def save_client(self, client_info, client_metadata, request):
client = OAuthClient(
client_id=client_info['client_id'],
client_secret=client_info['client_secret'],
...
)
client.save()
return client
"""
raise NotImplementedError()
@@ -0,0 +1,37 @@
from ..rfc6749 import OAuth2Error
class InvalidRedirectURIError(OAuth2Error):
"""The value of one or more redirection URIs is invalid.
https://tools.ietf.org/html/rfc7591#section-3.2.2.
"""
error = "invalid_redirect_uri"
class InvalidClientMetadataError(OAuth2Error):
"""The value of one of the client metadata fields is invalid and the
server has rejected this request. Note that an authorization
server MAY choose to substitute a valid value for any requested
parameter of a client's metadata.
https://tools.ietf.org/html/rfc7591#section-3.2.2.
"""
error = "invalid_client_metadata"
class InvalidSoftwareStatementError(OAuth2Error):
"""The software statement presented is invalid.
https://tools.ietf.org/html/rfc7591#section-3.2.2.
"""
error = "invalid_software_statement"
class UnapprovedSoftwareStatementError(OAuth2Error):
"""The software statement presented is not approved for use by this
authorization server.
https://tools.ietf.org/html/rfc7591#section-3.2.2.
"""
error = "unapproved_software_statement"
@@ -0,0 +1,12 @@
"""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"]
@@ -0,0 +1,228 @@
from joserfc.errors import JoseError
from authlib.consts import default_json_headers
from ..rfc6749 import AccessDeniedError
from ..rfc6749 import InvalidClientError
from ..rfc6749 import InvalidRequestError
from ..rfc6749 import UnauthorizedClientError
from ..rfc7591 import InvalidClientMetadataError
from ..rfc7591.claims import ClientMetadataClaims
class ClientConfigurationEndpoint:
ENDPOINT_NAME = "client_configuration"
def __init__(self, server=None, claims_classes=None):
self.server = server
self.claims_classes = claims_classes or [ClientMetadataClaims]
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, description="The client does not exist on this server."
)
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,
description="The client does not have permission to read its record.",
)
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.payload.data:
raise InvalidRequestError()
# The client MUST include its 'client_id' field in the request
client_id = request.payload.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.payload.data:
if not client.check_client_secret(request.payload.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.payload.data.copy()
client_metadata = {}
server_metadata = self.get_server_metadata()
for claims_class in self.claims_classes:
options = (
claims_class.get_claims_options(server_metadata)
if hasattr(claims_class, "get_claims_options") and server_metadata
else {}
)
claims = claims_class(json_data, {}, options, server_metadata)
try:
claims.validate()
except JoseError as error:
print(error)
raise InvalidClientMetadataError(error.description) from error
client_metadata.update(**claims.get_registered_claims())
return client_metadata
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.payload.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 whether the current client is allowed to be accessed, edited
or deleted. Developers MUST implement it in subclass, e.g.::
def check_permission(self, client, request):
return client.editable
:return: boolean
"""
raise NotImplementedError()
def delete_client(self, client, request):
"""Delete authorization code from database or cache. Developers MUST
implement it in subclass, e.g.::
def delete_client(self, client, request):
client.delete()
:param client: the instance of OAuth client
:param request: formatted request instance
"""
raise NotImplementedError()
def update_client(self, client, client_metadata, request):
"""Update the client in the database. Developers MUST implement this method
in subclass::
def update_client(self, client, client_metadata, request):
client.set_client_metadata(
{**client.client_metadata, **client_metadata}
)
client.save()
return client
:param client: the instance of OAuth client
:param client_metadata: a dict of the client claims to update
:param request: formatted request instance
:return: client instance
"""
raise NotImplementedError()
def get_server_metadata(self):
"""Return server metadata which includes supported grant types,
response types and etc.
"""
raise NotImplementedError()
@@ -0,0 +1,13 @@
"""authlib.oauth2.rfc7636.
~~~~~~~~~~~~~~~~~~~~~~
This module represents a direct implementation of
Proof Key for Code Exchange by OAuth Public Clients.
https://tools.ietf.org/html/rfc7636
"""
from .challenge import CodeChallenge
from .challenge import create_s256_code_challenge
__all__ = ["CodeChallenge", "create_s256_code_challenge"]
@@ -0,0 +1,156 @@
import hashlib
import re
from authlib.common.encoding import to_bytes
from authlib.common.encoding import to_unicode
from authlib.common.encoding import urlsafe_b64encode
from ..rfc6749 import InvalidGrantError
from ..rfc6749 import InvalidRequestError
from ..rfc6749 import 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_payload",
self.validate_code_challenge,
)
grant.register_hook(
"after_validate_token_request",
self.validate_code_verifier,
)
def validate_code_challenge(self, grant, redirect_uri):
request: OAuth2Request = grant.request
challenge = request.payload.data.get("code_challenge")
method = request.payload.data.get("code_challenge_method")
if not challenge and not method:
return
if not challenge:
raise InvalidRequestError("Missing 'code_challenge'")
if len(request.payload.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.payload.datalist.get("code_challenge_method", [])) > 1:
raise InvalidRequestError("Multiple 'code_challenge_method' in request.")
def validate_code_verifier(self, grant, result):
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
# RFC 9700 Section 4.8: the authorization server MUST ensure that if
# there was no code_challenge in the authorization request, a request
# to the token endpoint containing a code_verifier is rejected.
if not challenge and verifier:
raise InvalidRequestError(
"The authorization request had no 'code_challenge', "
"but a 'code_verifier' was provided."
)
# challenge exists, code_verifier is required
if not verifier:
raise InvalidRequestError("Missing 'code_verifier'")
if not CODE_VERIFIER_PATTERN.match(verifier):
raise InvalidRequestError("Invalid 'code_verifier'")
# 4.6. Server Verifies code_verifier before Returning the Tokens
method = self.get_authorization_code_challenge_method(authorization_code)
if method is None:
method = self.DEFAULT_CODE_CHALLENGE_METHOD
func = self.CODE_CHALLENGE_METHODS.get(method)
if not func:
raise RuntimeError(f"No verify method for '{method}'")
# If the values are not equal, an error response indicating
# "invalid_grant" MUST be returned.
if not func(verifier, challenge):
raise InvalidGrantError(description="Code challenge failed.")
def get_authorization_code_challenge(self, authorization_code):
"""Get "code_challenge" associated with this authorization code.
Developers MAY re-implement it in subclass, the default logic::
def get_authorization_code_challenge(self, authorization_code):
return authorization_code.code_challenge
:param authorization_code: the instance of authorization_code
"""
return authorization_code.code_challenge
def get_authorization_code_challenge_method(self, authorization_code):
"""Get "code_challenge_method" associated with this authorization code.
Developers MAY re-implement it in subclass, the default logic::
def get_authorization_code_challenge_method(self, authorization_code):
return authorization_code.code_challenge_method
:param authorization_code: the instance of authorization_code
"""
return authorization_code.code_challenge_method
@@ -0,0 +1,14 @@
"""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"]
@@ -0,0 +1,132 @@
from authlib.consts import default_json_headers
from ..rfc6749 import InvalidRequestError
from ..rfc6749 import TokenEndpoint
from ..rfc6749 import 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()
@@ -0,0 +1,40 @@
from ..rfc6749 import TokenMixin
class IntrospectionToken(dict, TokenMixin):
def get_client_id(self):
return self.get("client_id")
def get_scope(self):
return self.get("scope")
def get_expires_in(self):
# this method is only used in refresh token,
# no need to implement it
return 0
def get_expires_at(self):
return self.get("exp", 0)
def __getattr__(self, key):
# https://tools.ietf.org/html/rfc7662#section-2.2
available_keys = {
"active",
"scope",
"client_id",
"username",
"token_type",
"exp",
"iat",
"nbf",
"sub",
"aud",
"iss",
"jti",
}
try:
return object.__getattribute__(self, key)
except AttributeError as error:
if key in available_keys:
return self.get(key)
raise error
@@ -0,0 +1,34 @@
from ..rfc6749 import TokenValidator
from ..rfc6750 import InsufficientScopeError
from ..rfc6750 import InvalidTokenError
class IntrospectTokenValidator(TokenValidator):
TOKEN_TYPE = "bearer"
def introspect_token(self, token_string):
"""Request introspection token endpoint with the given token string,
authorization server will return token information in JSON format.
Developers MUST implement this method before using it::
def introspect_token(self, token_string):
# for example, introspection token endpoint has limited
# internal IPs to access, so there is no need to add
# authentication.
url = "https://example.com/oauth/introspect"
resp = requests.post(url, data={"token": token_string})
resp.raise_for_status()
return resp.json()
"""
raise NotImplementedError()
def authenticate_token(self, token_string):
return self.introspect_token(token_string)
def validate_token(self, token, scopes, request):
if not token or not token["active"]:
raise InvalidTokenError(
realm=self.realm, extra_attributes=self.extra_attributes
)
if self.scope_insufficient(token.get("scope"), scopes):
raise InsufficientScopeError()
@@ -0,0 +1,13 @@
"""authlib.oauth2.rfc8414.
~~~~~~~~~~~~~~~~~~~~~~
This module represents a direct implementation of
OAuth 2.0 Authorization Server Metadata.
https://tools.ietf.org/html/rfc8414
"""
from .models import AuthorizationServerMetadata
from .well_known import get_well_known_url
__all__ = ["AuthorizationServerMetadata", "get_well_known_url"]
@@ -0,0 +1,428 @@
from authlib.common.language import is_valid_language_tag
from authlib.common.security import is_secure_transport
from authlib.common.urls import is_valid_url
from authlib.common.urls import urlparse
class AuthorizationServerMetadata(dict):
"""Define Authorization Server Metadata via `Section 2`_ in RFC8414_.
The :meth:`validate` method can compose extension classes via the
``metadata_classes`` parameter::
from authlib.oauth2 import rfc8414, rfc9101
metadata = rfc8414.AuthorizationServerMetadata(data)
metadata.validate(metadata_classes=[rfc9101.AuthorizationServerMetadata])
.. _RFC8414: https://tools.ietf.org/html/rfc8414
.. _`Section 2`: https://tools.ietf.org/html/rfc8414#section-2
"""
REGISTRY_KEYS = [
"issuer",
"authorization_endpoint",
"token_endpoint",
"jwks_uri",
"registration_endpoint",
"scopes_supported",
"response_types_supported",
"response_modes_supported",
"grant_types_supported",
"token_endpoint_auth_methods_supported",
"token_endpoint_auth_signing_alg_values_supported",
"service_documentation",
"ui_locales_supported",
"op_policy_uri",
"op_tos_uri",
"revocation_endpoint",
"revocation_endpoint_auth_methods_supported",
"revocation_endpoint_auth_signing_alg_values_supported",
"introspection_endpoint",
"introspection_endpoint_auth_methods_supported",
"introspection_endpoint_auth_signing_alg_values_supported",
"code_challenge_methods_supported",
]
def validate_issuer(self):
"""REQUIRED. The authorization server's issuer identifier, which is
a URL that uses the "https" scheme and has no query or fragment
components.
"""
issuer = self.get("issuer")
#: 1. REQUIRED
if not issuer:
raise ValueError('"issuer" is required')
parsed = urlparse.urlparse(issuer)
#: 2. uses the "https" scheme
if not is_secure_transport(issuer):
raise ValueError('"issuer" MUST use "https" scheme')
#: 3. has no query or fragment
if parsed.query or parsed.fragment:
raise ValueError('"issuer" has no query or fragment')
def validate_authorization_endpoint(self):
"""URL of the authorization server's authorization endpoint
[RFC6749]. This is REQUIRED unless no grant types are supported
that use the authorization endpoint.
"""
url = self.get("authorization_endpoint")
if url:
if not is_secure_transport(url):
raise ValueError('"authorization_endpoint" MUST use "https" scheme')
return
grant_types_supported = set(self.grant_types_supported)
authorization_grant_types = {"authorization_code", "implicit"}
if grant_types_supported & authorization_grant_types:
raise ValueError('"authorization_endpoint" is required')
def validate_token_endpoint(self):
"""URL of the authorization server's token endpoint [RFC6749]. This
is REQUIRED unless only the implicit grant type is supported.
"""
grant_types_supported = self.get("grant_types_supported")
if (
grant_types_supported
and len(grant_types_supported) == 1
and grant_types_supported[0] == "implicit"
):
return
url = self.get("token_endpoint")
if not url:
raise ValueError('"token_endpoint" is required')
if not is_secure_transport(url):
raise ValueError('"token_endpoint" MUST use "https" scheme')
def validate_jwks_uri(self):
"""OPTIONAL. URL of the authorization server's JWK Set [JWK]
document. The referenced document contains the signing key(s) the
client uses to validate signatures from the authorization server.
This URL MUST use the "https" scheme. The JWK Set MAY also
contain the server's encryption key or keys, which are used by
clients to encrypt requests to the server. When both signing and
encryption keys are made available, a "use" (public key use)
parameter value is REQUIRED for all keys in the referenced JWK Set
to indicate each key's intended usage.
"""
url = self.get("jwks_uri")
if url and not is_secure_transport(url):
raise ValueError('"jwks_uri" MUST use "https" scheme')
def validate_registration_endpoint(self):
"""OPTIONAL. URL of the authorization server's OAuth 2.0 Dynamic
Client Registration endpoint [RFC7591].
"""
url = self.get("registration_endpoint")
if url and not is_secure_transport(url):
raise ValueError('"registration_endpoint" MUST use "https" scheme')
def validate_scopes_supported(self):
"""RECOMMENDED. JSON array containing a list of the OAuth 2.0
[RFC6749] "scope" values that this authorization server supports.
Servers MAY choose not to advertise some supported scope values
even when this parameter is used.
"""
validate_array_value(self, "scopes_supported")
def validate_response_types_supported(self):
"""REQUIRED. JSON array containing a list of the OAuth 2.0
"response_type" values that this authorization server supports.
The array values used are the same as those used with the
"response_types" parameter defined by "OAuth 2.0 Dynamic Client
Registration Protocol" [RFC7591].
"""
response_types_supported = self.get("response_types_supported")
if not response_types_supported:
raise ValueError('"response_types_supported" is required')
if not isinstance(response_types_supported, list):
raise ValueError('"response_types_supported" MUST be JSON array')
def validate_response_modes_supported(self):
"""OPTIONAL. JSON array containing a list of the OAuth 2.0
"response_mode" values that this authorization server supports, as
specified in "OAuth 2.0 Multiple Response Type Encoding Practices"
[OAuth.Responses]. If omitted, the default is "["query",
"fragment"]". The response mode value "form_post" is also defined
in "OAuth 2.0 Form Post Response Mode" [OAuth.Post].
"""
validate_array_value(self, "response_modes_supported")
def validate_grant_types_supported(self):
"""OPTIONAL. JSON array containing a list of the OAuth 2.0 grant
type values that this authorization server supports. The array
values used are the same as those used with the "grant_types"
parameter defined by "OAuth 2.0 Dynamic Client Registration
Protocol" [RFC7591]. If omitted, the default value is
"["authorization_code", "implicit"]".
"""
validate_array_value(self, "grant_types_supported")
def validate_token_endpoint_auth_methods_supported(self):
"""OPTIONAL. JSON array containing a list of client authentication
methods supported by this token endpoint. Client authentication
method values are used in the "token_endpoint_auth_method"
parameter defined in Section 2 of [RFC7591]. If omitted, the
default is "client_secret_basic" -- the HTTP Basic Authentication
Scheme specified in Section 2.3.1 of OAuth 2.0 [RFC6749].
"""
validate_array_value(self, "token_endpoint_auth_methods_supported")
def validate_token_endpoint_auth_signing_alg_values_supported(self):
"""OPTIONAL. JSON array containing a list of the JWS signing
algorithms ("alg" values) supported by the token endpoint for the
signature on the JWT [JWT] used to authenticate the client at the
token endpoint for the "private_key_jwt" and "client_secret_jwt"
authentication methods. This metadata entry MUST be present if
either of these authentication methods are specified in the
"token_endpoint_auth_methods_supported" entry. No default
algorithms are implied if this entry is omitted. Servers SHOULD
support "RS256". The value "none" MUST NOT be used.
"""
_validate_alg_values(
self,
"token_endpoint_auth_signing_alg_values_supported",
self.token_endpoint_auth_methods_supported,
)
def validate_service_documentation(self):
"""OPTIONAL. URL of a page containing human-readable information
that developers might want or need to know when using the
authorization server. In particular, if the authorization server
does not support Dynamic Client Registration, then information on
how to register clients needs to be provided in this
documentation.
"""
value = self.get("service_documentation")
if value and not is_valid_url(value):
raise ValueError('"service_documentation" MUST be a URL')
def validate_ui_locales_supported(self):
"""OPTIONAL. Languages and scripts supported for the user interface,
represented as a JSON array of language tag values from BCP 47
[RFC5646]. If omitted, the set of supported languages and scripts
is unspecified.
"""
validate_language_tags_array(self, "ui_locales_supported")
def validate_op_policy_uri(self):
"""OPTIONAL. URL that the authorization server provides to the
person registering the client to read about the authorization
server's requirements on how the client can use the data provided
by the authorization server. The registration process SHOULD
display this URL to the person registering the client if it is
given. As described in Section 5, despite the identifier
"op_policy_uri" appearing to be OpenID-specific, its usage in this
specification is actually referring to a general OAuth 2.0 feature
that is not specific to OpenID Connect.
"""
value = self.get("op_policy_uri")
if value and not is_valid_url(value):
raise ValueError('"op_policy_uri" MUST be a URL')
def validate_op_tos_uri(self):
"""OPTIONAL. URL that the authorization server provides to the
person registering the client to read about the authorization
server's terms of service. The registration process SHOULD
display this URL to the person registering the client if it is
given. As described in Section 5, despite the identifier
"op_tos_uri", appearing to be OpenID-specific, its usage in this
specification is actually referring to a general OAuth 2.0 feature
that is not specific to OpenID Connect.
"""
value = self.get("op_tos_uri")
if value and not is_valid_url(value):
raise ValueError('"op_tos_uri" MUST be a URL')
def validate_revocation_endpoint(self):
"""OPTIONAL. URL of the authorization server's OAuth 2.0 revocation
endpoint [RFC7009].
"""
url = self.get("revocation_endpoint")
if url and not is_secure_transport(url):
raise ValueError('"revocation_endpoint" MUST use "https" scheme')
def validate_revocation_endpoint_auth_methods_supported(self):
"""OPTIONAL. JSON array containing a list of client authentication
methods supported by this revocation endpoint. The valid client
authentication method values are those registered in the IANA
"OAuth Token Endpoint Authentication Methods" registry
[IANA.OAuth.Parameters]. If omitted, the default is
"client_secret_basic" -- the HTTP Basic Authentication Scheme
specified in Section 2.3.1 of OAuth 2.0 [RFC6749].
"""
validate_array_value(self, "revocation_endpoint_auth_methods_supported")
def validate_revocation_endpoint_auth_signing_alg_values_supported(self):
"""OPTIONAL. JSON array containing a list of the JWS signing
algorithms ("alg" values) supported by the revocation endpoint for
the signature on the JWT [JWT] used to authenticate the client at
the revocation endpoint for the "private_key_jwt" and
"client_secret_jwt" authentication methods. This metadata entry
MUST be present if either of these authentication methods are
specified in the "revocation_endpoint_auth_methods_supported"
entry. No default algorithms are implied if this entry is
omitted. The value "none" MUST NOT be used.
"""
_validate_alg_values(
self,
"revocation_endpoint_auth_signing_alg_values_supported",
self.revocation_endpoint_auth_methods_supported,
)
def validate_introspection_endpoint(self):
"""OPTIONAL. URL of the authorization server's OAuth 2.0
introspection endpoint [RFC7662].
"""
url = self.get("introspection_endpoint")
if url and not is_secure_transport(url):
raise ValueError('"introspection_endpoint" MUST use "https" scheme')
def validate_introspection_endpoint_auth_methods_supported(self):
"""OPTIONAL. JSON array containing a list of client authentication
methods supported by this introspection endpoint. The valid
client authentication method values are those registered in the
IANA "OAuth Token Endpoint Authentication Methods" registry
[IANA.OAuth.Parameters] or those registered in the IANA "OAuth
Access Token Types" registry [IANA.OAuth.Parameters]. (These
values are and will remain distinct, due to Section 7.2.) If
omitted, the set of supported authentication methods MUST be
determined by other means.
"""
validate_array_value(self, "introspection_endpoint_auth_methods_supported")
def validate_introspection_endpoint_auth_signing_alg_values_supported(self):
"""OPTIONAL. JSON array containing a list of the JWS signing
algorithms ("alg" values) supported by the introspection endpoint
for the signature on the JWT [JWT] used to authenticate the client
at the introspection endpoint for the "private_key_jwt" and
"client_secret_jwt" authentication methods. This metadata entry
MUST be present if either of these authentication methods are
specified in the "introspection_endpoint_auth_methods_supported"
entry. No default algorithms are implied if this entry is
omitted. The value "none" MUST NOT be used.
"""
_validate_alg_values(
self,
"introspection_endpoint_auth_signing_alg_values_supported",
self.introspection_endpoint_auth_methods_supported,
)
def validate_code_challenge_methods_supported(self):
"""OPTIONAL. JSON array containing a list of Proof Key for Code
Exchange (PKCE) [RFC7636] code challenge methods supported by this
authorization server. Code challenge method values are used in
the "code_challenge_method" parameter defined in Section 4.3 of
[RFC7636]. The valid code challenge method values are those
registered in the IANA "PKCE Code Challenge Methods" registry
[IANA.OAuth.Parameters]. If omitted, the authorization server
does not support PKCE.
"""
validate_array_value(self, "code_challenge_methods_supported")
@property
def response_modes_supported(self):
#: If omitted, the default is ["query", "fragment"]
return self.get("response_modes_supported", ["query", "fragment"])
@property
def grant_types_supported(self):
#: If omitted, the default value is ["authorization_code", "implicit"]
return self.get("grant_types_supported", ["authorization_code", "implicit"])
@property
def token_endpoint_auth_methods_supported(self):
#: If omitted, the default is "client_secret_basic"
return self.get(
"token_endpoint_auth_methods_supported", ["client_secret_basic"]
)
@property
def revocation_endpoint_auth_methods_supported(self):
#: If omitted, the default is "client_secret_basic"
return self.get(
"revocation_endpoint_auth_methods_supported", ["client_secret_basic"]
)
@property
def introspection_endpoint_auth_methods_supported(self):
#: If omitted, the set of supported authentication methods MUST be
#: determined by other means
#: here, we use "client_secret_basic"
return self.get(
"introspection_endpoint_auth_methods_supported", ["client_secret_basic"]
)
def validate(self, metadata_classes=None):
"""Validate all server metadata values.
:param metadata_classes: Optional list of metadata extension classes
to validate. Example::
from authlib.oauth2 import rfc9101
from authlib.oidc import discovery
metadata = discovery.OpenIDProviderMetadata(data)
metadata.validate(
metadata_classes=[rfc9101.AuthorizationServerMetadata]
)
"""
for key in self.REGISTRY_KEYS:
object.__getattribute__(self, f"validate_{key}")()
if metadata_classes:
for cls in metadata_classes:
instance = cls(self)
for key in cls.REGISTRY_KEYS:
object.__getattribute__(instance, f"validate_{key}")()
def __getattr__(self, key):
try:
return object.__getattribute__(self, key)
except AttributeError as error:
if key in self.REGISTRY_KEYS:
return self.get(key)
raise error
def _validate_alg_values(data, key, auth_methods_supported):
value = data.get(key)
if value and not isinstance(value, list):
raise ValueError(f'"{key}" MUST be JSON array')
auth_methods = set(auth_methods_supported)
jwt_auth_methods = {"private_key_jwt", "client_secret_jwt"}
if auth_methods & jwt_auth_methods:
if not value:
raise ValueError(f'"{key}" is required')
if value and "none" in value:
raise ValueError(f'the value "none" MUST NOT be used in "{key}"')
def validate_array_value(metadata, key):
values = metadata.get(key)
if values is not None and not isinstance(values, list):
raise ValueError(f'"{key}" MUST be JSON array')
def validate_language_tags_array(metadata, key):
validate_array_value(metadata, key)
values = metadata.get(key)
if values is not None:
for tag in values:
if not is_valid_language_tag(tag):
raise ValueError(f'"{key}" MUST contain BCP 47 language tags')
def validate_boolean_value(metadata, key):
if key not in metadata:
return
if metadata[key] not in (True, False):
raise ValueError(f'"{key}" MUST be boolean')
@@ -0,0 +1,22 @@
from authlib.common.urls import urlparse
def get_well_known_url(issuer, external=False, suffix="oauth-authorization-server"):
"""Get well-known URI with issuer via `Section 3.1`_.
.. _`Section 3.1`: https://tools.ietf.org/html/rfc8414#section-3.1
:param issuer: URL of the issuer
:param external: return full external url or not
:param suffix: well-known URI suffix for RFC8414
:return: URL
"""
parsed = urlparse.urlparse(issuer)
path = parsed.path
if path and path != "/":
url_path = f"/.well-known/{suffix}{path}"
else:
url_path = f"/.well-known/{suffix}"
if not external:
return url_path
return parsed.scheme + "://" + parsed.netloc + url_path
@@ -0,0 +1,28 @@
"""authlib.oauth2.rfc8628.
~~~~~~~~~~~~~~~~~~~~~~
This module represents an implementation of
OAuth 2.0 Device Authorization Grant.
https://tools.ietf.org/html/rfc8628
"""
from .device_code import DEVICE_CODE_GRANT_TYPE
from .device_code import DeviceCodeGrant
from .endpoint import DeviceAuthorizationEndpoint
from .errors import AuthorizationPendingError
from .errors import ExpiredTokenError
from .errors import SlowDownError
from .models import DeviceCredentialDict
from .models import DeviceCredentialMixin
__all__ = [
"DeviceAuthorizationEndpoint",
"DeviceCodeGrant",
"DEVICE_CODE_GRANT_TYPE",
"DeviceCredentialMixin",
"DeviceCredentialDict",
"AuthorizationPendingError",
"SlowDownError",
"ExpiredTokenError",
]
@@ -0,0 +1,185 @@
import logging
from ..rfc6749 import BaseGrant
from ..rfc6749 import TokenEndpointMixin
from ..rfc6749.errors import AccessDeniedError
from ..rfc6749.errors import InvalidRequestError
from ..rfc6749.errors import UnauthorizedClientError
from ..rfc6749.hooks import hooked
from .errors import AuthorizationPendingError
from .errors import ExpiredTokenError
from .errors import SlowDownError
log = logging.getLogger(__name__)
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
class DeviceCodeGrant(BaseGrant, TokenEndpointMixin):
"""This OAuth 2.0 [RFC6749] protocol extension enables OAuth clients to
request user authorization from applications on devices that have
limited input capabilities or lack a suitable browser. Such devices
include smart TVs, media consoles, picture frames, and printers,
which lack an easy input method or a suitable browser required for
traditional OAuth interactions. Here is the authorization flow::
+----------+ +----------------+
| |>---(A)-- Client Identifier --->| |
| | | |
| |<---(B)-- Device Code, ---<| |
| | User Code, | |
| Device | & Verification URI | |
| Client | | |
| | [polling] | |
| |>---(E)-- Device Code --->| |
| | & Client Identifier | |
| | | Authorization |
| |<---(F)-- Access Token ---<| Server |
+----------+ (& Optional Refresh Token) | |
v | |
: | |
(C) User Code & Verification URI | |
: | |
v | |
+----------+ | |
| End User | | |
| at |<---(D)-- End user reviews --->| |
| Browser | authorization request | |
+----------+ +----------------+
This DeviceCodeGrant is the implementation of step (E) and (F).
(E) While the end user reviews the client's request (step D), the
client repeatedly polls the authorization server to find out if
the user completed the user authorization step. The client
includes the device code and its client identifier.
(F) The authorization server validates the device code provided by
the client and responds with the access token if the client is
granted access, an error if they are denied access, or an
indication that the client should continue to poll.
"""
GRANT_TYPE = DEVICE_CODE_GRANT_TYPE
TOKEN_ENDPOINT_AUTH_METHODS = ["client_secret_basic", "client_secret_post", "none"]
def validate_token_request(self):
"""After displaying instructions to the user, the client creates an
access token request and sends it to the token endpoint with the
following parameters:
grant_type
REQUIRED. Value MUST be set to
"urn:ietf:params:oauth:grant-type:device_code".
device_code
REQUIRED. The device verification code, "device_code" from the
device authorization response.
client_id
REQUIRED if the client is not authenticating with the
authorization server as described in Section 3.2.1. of [RFC6749].
The client identifier as described in Section 2.2 of [RFC6749].
For example, the client makes the following HTTPS request::
POST /token HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code
&device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS
&client_id=1406020730
"""
device_code = self.request.payload.data.get("device_code")
if not device_code:
raise InvalidRequestError("Missing 'device_code' in payload")
client = self.authenticate_token_endpoint_client()
if not client.check_grant_type(self.GRANT_TYPE):
raise UnauthorizedClientError(
f"The client is not authorized to use 'response_type={self.GRANT_TYPE}'",
)
credential = self.query_device_credential(device_code)
if not credential:
raise InvalidRequestError("Invalid 'device_code' in payload")
if credential.get_client_id() != client.get_client_id():
raise UnauthorizedClientError()
user = self.validate_device_credential(credential)
self.request.user = user
self.request.client = client
self.request.credential = credential
@hooked
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.
"""
client = self.request.client
scope = self.request.credential.get_scope()
token = self.generate_token(
user=self.request.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)
return 200, token, self.TOKEN_RESPONSE_HEADER
def validate_device_credential(self, credential):
if credential.is_expired():
raise ExpiredTokenError()
user_code = credential.get_user_code()
user_grant = self.query_user_grant(user_code)
if user_grant is not None:
user, approved = user_grant
if not approved:
raise AccessDeniedError()
return user
if self.should_slow_down(credential):
raise SlowDownError()
raise AuthorizationPendingError()
def query_device_credential(self, device_code):
"""Get device credential from previously savings via ``DeviceAuthorizationEndpoint``.
Developers MUST implement it in subclass::
def query_device_credential(self, device_code):
return DeviceCredential.get(device_code)
:param device_code: a string represent the code.
:return: DeviceCredential instance
"""
raise NotImplementedError()
def query_user_grant(self, user_code):
"""Get user and grant via the given user code. Developers MUST
implement it in subclass::
def query_user_grant(self, user_code):
# e.g. we saved user grant info in redis
data = redis.get("oauth_user_grant:" + user_code)
if not data:
return None
user_id, allowed = data.split()
user = User.get(user_id)
return user, bool(allowed)
Note, user grant information is saved by verification endpoint.
"""
raise NotImplementedError()
def should_slow_down(self, credential):
"""The authorization request is still pending and polling should
continue, but the interval MUST be increased by 5 seconds for this
and all subsequent requests.
"""
raise NotImplementedError()
@@ -0,0 +1,172 @@
from authlib.common.security import generate_token
from authlib.common.urls import add_params_to_uri
from authlib.consts import default_json_headers
class DeviceAuthorizationEndpoint:
"""This OAuth 2.0 [RFC6749] protocol extension enables OAuth clients to
request user authorization from applications on devices that have
limited input capabilities or lack a suitable browser. Such devices
include smart TVs, media consoles, picture frames, and printers,
which lack an easy input method or a suitable browser required for
traditional OAuth interactions. Here is the authorization flow::
+----------+ +----------------+
| |>---(A)-- Client Identifier --->| |
| | | |
| |<---(B)-- Device Code, ---<| |
| | User Code, | |
| Device | & Verification URI | |
| Client | | |
| | [polling] | |
| |>---(E)-- Device Code --->| |
| | & Client Identifier | |
| | | Authorization |
| |<---(F)-- Access Token ---<| Server |
+----------+ (& Optional Refresh Token) | |
v | |
: | |
(C) User Code & Verification URI | |
: | |
v | |
+----------+ | |
| End User | | |
| at |<---(D)-- End user reviews --->| |
| Browser | authorization request | |
+----------+ +----------------+
This DeviceAuthorizationEndpoint is the implementation of step (A) and (B).
(A) The client requests access from the authorization server and
includes its client identifier in the request.
(B) The authorization server issues a device code and an end-user
code and provides the end-user verification URI.
"""
ENDPOINT_NAME = "device_authorization"
CLIENT_AUTH_METHODS = ["client_secret_basic", "client_secret_post", "none"]
#: customize "user_code" type, string or digital
USER_CODE_TYPE = "string"
#: The lifetime in seconds of the "device_code" and "user_code"
EXPIRES_IN = 1800
#: The minimum amount of time in seconds that the client SHOULD
#: wait between polling requests to the token endpoint.
INTERVAL = 5
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_client(self, request):
"""client_id is REQUIRED **if the client is not** authenticating with the
authorization server as described in Section 3.2.1. of [RFC6749].
This means the endpoint support "none" authentication method. In this case,
this endpoint's auth methods are:
- client_secret_basic
- client_secret_post
- none
Developers change the value of ``CLIENT_AUTH_METHODS`` in subclass. For
instance::
class MyDeviceAuthorizationEndpoint(DeviceAuthorizationEndpoint):
# only support ``client_secret_basic`` auth method
CLIENT_AUTH_METHODS = ["client_secret_basic"]
"""
client = self.server.authenticate_client(
request, self.CLIENT_AUTH_METHODS, self.ENDPOINT_NAME
)
request.client = client
return client
def create_endpoint_response(self, request):
# https://tools.ietf.org/html/rfc8628#section-3.1
self.authenticate_client(request)
self.server.validate_requested_scope(request.payload.scope)
device_code = self.generate_device_code()
user_code = self.generate_user_code()
verification_uri = self.get_verification_uri()
verification_uri_complete = add_params_to_uri(
verification_uri, [("user_code", user_code)]
)
data = {
"device_code": device_code,
"user_code": user_code,
"verification_uri": verification_uri,
"verification_uri_complete": verification_uri_complete,
"expires_in": self.EXPIRES_IN,
"interval": self.INTERVAL,
}
self.save_device_credential(
request.client.get_client_id(), request.payload.scope, data
)
return 200, data, default_json_headers
def generate_user_code(self):
"""A method to generate ``user_code`` value for device authorization
endpoint. This method will generate a random string like MQNA-JPOZ.
Developers can rewrite this method to create their own ``user_code``.
"""
# https://tools.ietf.org/html/rfc8628#section-6.1
if self.USER_CODE_TYPE == "digital":
return create_digital_user_code()
return create_string_user_code()
def generate_device_code(self):
"""A method to generate ``device_code`` value for device authorization
endpoint. This method will generate a random string of 42 characters.
Developers can rewrite this method to create their own ``device_code``.
"""
return generate_token(42)
def get_verification_uri(self):
"""Define the ``verification_uri`` of device authorization endpoint.
Developers MUST implement this method in subclass::
def get_verification_uri(self):
return "https://your-company.com/active"
"""
raise NotImplementedError()
def save_device_credential(self, client_id, scope, data):
"""Save device token into database for later use. Developers MUST
implement this method in subclass::
def save_device_credential(self, client_id, scope, data):
item = DeviceCredential(client_id=client_id, scope=scope, **data)
item.save()
"""
raise NotImplementedError()
def create_string_user_code():
base = "BCDFGHJKLMNPQRSTVWXZ"
return "-".join([generate_token(4, base), generate_token(4, base)])
def create_digital_user_code():
base = "0123456789"
return "-".join(
[
generate_token(3, base),
generate_token(3, base),
generate_token(3, base),
]
)
@@ -0,0 +1,30 @@
from ..rfc6749.errors import OAuth2Error
# https://tools.ietf.org/html/rfc8628#section-3.5
class AuthorizationPendingError(OAuth2Error):
"""The authorization request is still pending as the end user hasn't
yet completed the user-interaction steps (Section 3.3).
"""
error = "authorization_pending"
class SlowDownError(OAuth2Error):
"""A variant of "authorization_pending", the authorization request is
still pending and polling should continue, but the interval MUST
be increased by 5 seconds for this and all subsequent requests.
"""
error = "slow_down"
class ExpiredTokenError(OAuth2Error):
"""The "device_code" has expired, and the device authorization
session has concluded. The client MAY commence a new device
authorization request but SHOULD wait for user interaction before
restarting to avoid unnecessary polling.
"""
error = "expired_token"
@@ -0,0 +1,38 @@
import time
class DeviceCredentialMixin:
def get_client_id(self):
raise NotImplementedError()
def get_scope(self):
raise NotImplementedError()
def get_user_code(self):
raise NotImplementedError()
def is_expired(self):
raise NotImplementedError()
class DeviceCredentialDict(dict, DeviceCredentialMixin):
def get_client_id(self):
return self["client_id"]
def get_scope(self):
return self.get("scope")
def get_user_code(self):
return self["user_code"]
def get_nonce(self):
return self.get("nonce")
def get_auth_time(self):
return self.get("auth_time")
def is_expired(self):
expires_at = self.get("expires_at")
if expires_at is not None:
return expires_at < time.time()
return False
@@ -0,0 +1,8 @@
"""authlib.oauth2.rfc8693.
~~~~~~~~~~~~~~~~~~~~~~
This module represents an implementation of
OAuth 2.0 Token Exchange.
https://tools.ietf.org/html/rfc8693
"""
@@ -0,0 +1,11 @@
from .introspection import JWTIntrospectionEndpoint
from .revocation import JWTRevocationEndpoint
from .token import JWTBearerTokenGenerator
from .token_validator import JWTBearerTokenValidator
__all__ = [
"JWTBearerTokenGenerator",
"JWTBearerTokenValidator",
"JWTIntrospectionEndpoint",
"JWTRevocationEndpoint",
]
@@ -0,0 +1,35 @@
from joserfc.errors import InvalidClaimError
from joserfc.jwt import JWTClaimsRegistry
from authlib.oauth2.claims import JWTClaims
class JWTAccessTokenClaimsValidator(JWTClaimsRegistry):
def validate_auth_time(self, auth_time):
if not isinstance(auth_time, (int, float)):
raise InvalidClaimError("auth_time")
self.check_value("auth_time", auth_time)
def validate_amr(self, amr):
if not isinstance(amr, list):
raise InvalidClaimError("amr", amr)
class JWTAccessTokenClaims(JWTClaims):
registry_cls = JWTAccessTokenClaimsValidator
REGISTERED_CLAIMS = JWTClaims.REGISTERED_CLAIMS + [
"client_id",
"auth_time",
"acr",
"amr",
"scope",
"groups",
"roles",
"entitlements",
]
def validate(self, **kwargs):
typ = self.header.get("typ")
if typ and typ.lower() not in ("at+jwt", "application/at+jwt"):
raise InvalidClaimError("typ")
super().validate(**kwargs)
@@ -0,0 +1,129 @@
from joserfc.errors import ExpiredTokenError
from joserfc.errors import InvalidClaimError
from authlib.common.errors import ContinueIteration
from authlib.consts import default_json_headers
from authlib.oauth2.rfc6750.errors import InvalidTokenError
from ..rfc7662 import IntrospectionEndpoint
from .claims import JWTAccessTokenClaims
from .token_validator import JWTBearerTokenValidator
class JWTIntrospectionEndpoint(IntrospectionEndpoint):
r"""JWTIntrospectionEndpoint inherits from :ref:`specs/rfc7662`
:class:`~authlib.oauth2.rfc7662.IntrospectionEndpoint` and implements the machinery
to automatically process the JWT access tokens.
:param issuer: The issuer identifier for which tokens will be introspected.
:param \\*\\*kwargs: Other parameters are inherited from
:class:`~authlib.oauth2.rfc7662.introspection.IntrospectionEndpoint`.
::
class MyJWTAccessTokenIntrospectionEndpoint(JWTIntrospectionEndpoint):
def get_jwks(self): ...
def get_username(self, user_id): ...
# endpoint dedicated to JWT access token introspection
authorization_server.register_endpoint(
MyJWTAccessTokenIntrospectionEndpoint(
issuer="https://authorization-server.example.org",
)
)
# another endpoint dedicated to refresh token introspection
authorization_server.register_endpoint(MyRefreshTokenIntrospectionEndpoint)
"""
#: Endpoint name to be registered
ENDPOINT_NAME = "introspection"
def __init__(self, issuer, server=None, *args, **kwargs):
super().__init__(*args, server=server, **kwargs)
self.issuer = issuer
def create_endpoint_response(self, request):
""""""
# 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 authenticate_token(self, request, client):
""""""
self.check_params(request, client)
# do not attempt to decode refresh_tokens
if request.form.get("token_type_hint") not in ("access_token", None):
raise ContinueIteration()
validator = JWTBearerTokenValidator(issuer=self.issuer, resource_server=None)
validator.get_jwks = self.get_jwks
try:
token = validator.authenticate_token(request.form["token"])
# if the token is not a JWT, fall back to the regular flow
except InvalidTokenError as exc:
raise ContinueIteration() from exc
if token and self.check_permission(token, client, request):
return token
def create_introspection_payload(self, token: JWTAccessTokenClaims):
if not token:
return {"active": False}
try:
token.validate()
except ExpiredTokenError:
return {"active": False}
except InvalidClaimError as exc:
if exc.claim == "iss":
raise ContinueIteration() from exc
raise InvalidTokenError() from exc
payload = {
"active": True,
"token_type": "Bearer",
"client_id": token["client_id"],
"scope": token["scope"],
"sub": token["sub"],
"aud": token["aud"],
"iss": token["iss"],
"exp": token["exp"],
"iat": token["iat"],
}
if username := self.get_username(token["sub"]):
payload["username"] = username
return payload
def get_jwks(self):
"""Return the JWKs that will be used to check the JWT access token signature.
Developers MUST re-implement this method::
def get_jwks(self):
return load_jwks("jwks.json")
"""
raise NotImplementedError()
def get_username(self, user_id: str) -> str:
"""Returns an username from a user ID.
Developers MAY re-implement this method::
def get_username(self, user_id):
return User.get(id=user_id).username
"""
return None
@@ -0,0 +1,74 @@
from authlib.common.errors import ContinueIteration
from authlib.oauth2.rfc6750.errors import InvalidTokenError
from authlib.oauth2.rfc9068.token_validator import JWTBearerTokenValidator
from ..rfc6749 import UnsupportedTokenTypeError
from ..rfc7009 import RevocationEndpoint
class JWTRevocationEndpoint(RevocationEndpoint):
r"""JWTRevocationEndpoint inherits from `RFC7009`_
:class:`~authlib.oauth2.rfc7009.RevocationEndpoint`.
The JWT access tokens cannot be revoked.
If the submitted token is a JWT access token, then revocation returns
a `invalid_token_error`.
:param issuer: The issuer identifier.
:param \\*\\*kwargs: Other parameters are inherited from
:class:`~authlib.oauth2.rfc7009.RevocationEndpoint`.
Plain text access tokens and other kind of tokens such as refresh_tokens
will be ignored by this endpoint and passed to the next revocation endpoint::
class MyJWTAccessTokenRevocationEndpoint(JWTRevocationEndpoint):
def get_jwks(self): ...
# endpoint dedicated to JWT access token revokation
authorization_server.register_endpoint(
MyJWTAccessTokenRevocationEndpoint(
issuer="https://authorization-server.example.org",
)
)
# another endpoint dedicated to refresh token revokation
authorization_server.register_endpoint(MyRefreshTokenRevocationEndpoint)
.. _RFC7009: https://tools.ietf.org/html/rfc7009
"""
def __init__(self, issuer, server=None, *args, **kwargs):
super().__init__(*args, server=server, **kwargs)
self.issuer = issuer
def authenticate_token(self, request, client):
""""""
self.check_params(request, client)
# do not attempt to revoke refresh_tokens
if request.form.get("token_type_hint") not in ("access_token", None):
raise ContinueIteration()
validator = JWTBearerTokenValidator(issuer=self.issuer, resource_server=None)
validator.get_jwks = self.get_jwks
try:
validator.authenticate_token(request.form["token"])
# if the token is not a JWT, fall back to the regular flow
except InvalidTokenError as exc:
raise ContinueIteration() from exc
# JWT access token cannot be revoked
raise UnsupportedTokenTypeError()
def get_jwks(self):
"""Return the JWKs that will be used to check the JWT access token signature.
Developers MUST re-implement this method::
def get_jwks(self):
return load_jwks("jwks.json")
"""
raise NotImplementedError()
@@ -0,0 +1,217 @@
import time
from joserfc import jwt
from authlib._joserfc_helpers import import_any_key
from authlib.common.security import generate_token
from authlib.oauth2.rfc6750.token import BearerTokenGenerator
class JWTBearerTokenGenerator(BearerTokenGenerator):
r"""A JWT formatted access token generator.
:param issuer: The issuer identifier. Will appear in the JWT ``iss`` claim.
:param \\*\\*kwargs: Other parameters are inherited from
:class:`~authlib.oauth2.rfc6750.token.BearerTokenGenerator`.
This token generator can be registered into the authorization server::
class MyJWTBearerTokenGenerator(JWTBearerTokenGenerator):
def get_jwks(self): ...
def get_extra_claims(self, client, grant_type, user, scope): ...
authorization_server.register_token_generator(
"default",
MyJWTBearerTokenGenerator(
issuer="https://authorization-server.example.org"
),
)
"""
def __init__(
self,
issuer,
alg="RS256",
refresh_token_generator=None,
expires_generator=None,
):
super().__init__(
self.access_token_generator, refresh_token_generator, expires_generator
)
self.issuer = issuer
self.alg = alg
def get_jwks(self):
"""Return the JWKs that will be used to sign the JWT access token.
Developers MUST re-implement this method::
def get_jwks(self):
return load_jwks("jwks.json")
"""
raise NotImplementedError()
def get_extra_claims(self, client, grant_type, user, scope):
"""Return extra claims to add in the JWT access token. Developers MAY
re-implement this method to add identity claims like the ones in
:ref:`specs/oidc` ID Token, or any other arbitrary claims::
def get_extra_claims(self, client, grant_type, user, scope):
return generate_user_info(user, scope)
"""
return {}
def get_audiences(self, client, user, scope) -> str | list[str]:
"""Return the audience for the token. By default this simply returns
the client ID. Developers MAY re-implement this method to add extra
audiences::
def get_audiences(self, client, user, scope):
return [
client.get_client_id(),
resource_server.get_id(),
]
"""
return client.get_client_id()
def get_acr(self, user) -> str | None:
"""Authentication Context Class Reference.
Returns a user-defined case sensitive string indicating the class of
authentication the used performed. Token audience may refuse to give access to
some resources if some ACR criteria are not met.
:ref:`specs/oidc` defines one special value: ``0`` means that the user
authentication did not respect `ISO29115`_ level 1, and will be refused monetary
operations. Developers MAY re-implement this method::
def get_acr(self, user):
if user.insecure_session():
return "0"
return "urn:mace:incommon:iap:silver"
.. _ISO29115: https://www.iso.org/standard/45138.html
"""
return None
def get_auth_time(self, user) -> int | None:
"""User authentication time.
Time when the End-User authentication occurred. Its value is a JSON number
representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC
until the date/time. Developers MAY re-implement this method::
def get_auth_time(self, user):
return datetime.timestamp(user.get_auth_time())
"""
return None
def get_amr(self, user) -> list[str] | None:
"""Authentication Methods References.
Defined by :ref:`specs/oidc` as an option list of user-defined case-sensitive
strings indication which authentication methods have been used to authenticate
the user. Developers MAY re-implement this method::
def get_amr(self, user):
return ["2FA"] if user.has_2fa_enabled() else []
"""
return None
def get_jti(self, client, grant_type, user, scope) -> str:
"""JWT ID.
Create an unique identifier for the token. Developers MAY re-implement
this method::
def get_jti(self, client, grant_type, user scope):
return generate_random_string(16)
"""
return generate_token(16)
def access_token_generator(self, client, grant_type, user, scope):
now = int(time.time())
expires_in = now + self._get_expires_in(client, grant_type)
token_data = {
"iss": self.issuer,
"exp": expires_in,
"client_id": client.get_client_id(),
"iat": now,
"jti": self.get_jti(client, grant_type, user, scope),
"scope": scope,
}
# In cases of access tokens obtained through grants where a resource owner is
# involved, such as the authorization code grant, the value of 'sub' SHOULD
# correspond to the subject identifier of the resource owner.
if user:
token_data["sub"] = str(user.get_user_id())
# In cases of access tokens obtained through grants where no resource owner is
# involved, such as the client credentials grant, the value of 'sub' SHOULD
# correspond to an identifier the authorization server uses to indicate the
# client application.
else:
token_data["sub"] = client.get_client_id()
# If the request includes a 'resource' parameter (as defined in [RFC8707]), the
# resulting JWT access token 'aud' claim SHOULD have the same value as the
# 'resource' parameter in the request.
# TODO: Implement this with RFC8707
if False: # pragma: no cover
...
# If the request does not include a 'resource' parameter, the authorization
# server MUST use a default resource indicator in the 'aud' claim. If a 'scope'
# parameter is present in the request, the authorization server SHOULD use it to
# infer the value of the default resource indicator to be used in the 'aud'
# claim. The mechanism through which scopes are associated with default resource
# indicator values is outside the scope of this specification.
else:
token_data["aud"] = self.get_audiences(client, user, scope)
# If the values in the 'scope' parameter refer to different default resource
# indicator values, the authorization server SHOULD reject the request with
# 'invalid_scope' as described in Section 4.1.2.1 of [RFC6749].
# TODO: Implement this with RFC8707
if auth_time := self.get_auth_time(user):
token_data["auth_time"] = auth_time
# The meaning and processing of acr Claim Values is out of scope for this
# specification.
if acr := self.get_acr(user):
token_data["acr"] = acr
# The definition of particular values to be used in the amr Claim is beyond the
# scope of this specification.
if amr := self.get_amr(user):
token_data["amr"] = amr
# Authorization servers MAY return arbitrary attributes not defined in any
# existing specification, as long as the corresponding claim names are collision
# resistant or the access tokens are meant to be used only within a private
# subsystem. Please refer to Sections 4.2 and 4.3 of [RFC7519] for details.
token_data.update(self.get_extra_claims(client, grant_type, user, scope))
# This specification registers the 'application/at+jwt' media type, which can
# be used to indicate that the content is a JWT access token. JWT access tokens
# MUST include this media type in the 'typ' header parameter to explicitly
# declare that the JWT represents an access token complying with this profile.
# Per the definition of 'typ' in Section 4.1.9 of [RFC7515], it is RECOMMENDED
# that the 'application/' prefix be omitted. Therefore, the 'typ' value used
# SHOULD be 'at+jwt'.
header = {"alg": self.alg, "typ": "at+jwt"}
key = import_any_key(self.get_jwks())
access_token = jwt.encode(
header,
token_data,
key=key,
)
return access_token
@@ -0,0 +1,167 @@
"""authlib.oauth2.rfc9068.token_validator.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Implementation of Validating JWT Access Tokens per `Section 4`_.
.. _`Section 7`: https://www.rfc-editor.org/rfc/rfc9068.html#name-validating-jwt-access-token
"""
from joserfc import jwt
from joserfc.errors import JoseError
from authlib._joserfc_helpers import import_any_key
from authlib.oauth2.claims import ClaimsOption
from authlib.oauth2.rfc6750.errors import InsufficientScopeError
from authlib.oauth2.rfc6750.errors import InvalidTokenError
from authlib.oauth2.rfc6750.validator import BearerTokenValidator
from .claims import JWTAccessTokenClaims
class JWTBearerTokenValidator(BearerTokenValidator):
"""JWTBearerTokenValidator can protect your resource server endpoints.
:param issuer: The issuer from which tokens will be accepted.
:param resource_server: An identifier for the current resource server,
which must appear in the JWT ``aud`` claim.
Developers needs to implement the missing methods::
class MyJWTBearerTokenValidator(JWTBearerTokenValidator):
def get_jwks(self): ...
require_oauth = ResourceProtector()
require_oauth.register_token_validator(
MyJWTBearerTokenValidator(
issuer="https://authorization-server.example.org",
resource_server="https://resource-server.example.org",
)
)
You can then protect resources depending on the JWT `scope`, `groups`,
`roles` or `entitlements` claims::
@require_oauth(
scope="profile",
groups="admins",
roles="student",
entitlements="captain",
)
def resource_endpoint(): ...
"""
def __init__(self, issuer, resource_server, *args, **kwargs):
self.issuer = issuer
self.resource_server = resource_server
super().__init__(*args, **kwargs)
def get_jwks(self):
"""Return the JWKs that will be used to check the JWT access token signature.
Developers MUST re-implement this method. Typically the JWKs are statically
stored in the resource server configuration, or dynamically downloaded and
cached using :ref:`specs/rfc8414`::
def get_jwks(self):
if "jwks" in cache:
return cache.get("jwks")
server_metadata = get_server_metadata(self.issuer)
jwks_uri = server_metadata.get("jwks_uri")
cache["jwks"] = requests.get(jwks_uri).json()
return cache["jwks"]
"""
raise NotImplementedError()
def validate_iss(self, claims, iss: "str") -> bool:
# The issuer identifier for the authorization server (which is typically
# obtained during discovery) MUST exactly match the value of the 'iss'
# claim.
return iss == self.issuer
def authenticate_token(self, token_string):
""""""
# empty docstring avoids to display the irrelevant parent docstring
claims_options: dict[str, ClaimsOption] = {
"iss": {"essential": True, "validate": self.validate_iss},
"exp": {"essential": True},
"aud": {"essential": True, "value": self.resource_server},
"sub": {"essential": True},
"client_id": {"essential": True},
"iat": {"essential": True},
"jti": {"essential": True},
"auth_time": {"essential": False},
"acr": {"essential": False},
"amr": {"essential": False},
"scope": {"essential": False},
"groups": {"essential": False},
"roles": {"essential": False},
"entitlements": {"essential": False},
}
key = import_any_key(self.get_jwks())
# If the JWT access token is encrypted, decrypt it using the keys and algorithms
# that the resource server specified during registration. If encryption was
# negotiated with the authorization server at registration time and the incoming
# JWT access token is not encrypted, the resource server SHOULD reject it.
# The resource server MUST validate the signature of all incoming JWT access
# tokens according to [RFC7515] using the algorithm specified in the JWT 'alg'
# Header Parameter. The resource server MUST reject any JWT in which the value
# of 'alg' is 'none'. The resource server MUST use the keys provided by the
# authorization server.
try:
token = jwt.decode(token_string, key=key)
return JWTAccessTokenClaims(token.claims, token.header, claims_options)
except JoseError as exc:
raise InvalidTokenError(
realm=self.realm, extra_attributes=self.extra_attributes
) from exc
def validate_token(
self,
token: JWTAccessTokenClaims,
scopes,
request,
groups=None,
roles=None,
entitlements=None,
):
""""""
# empty docstring avoids to display the irrelevant parent docstring
try:
token.validate()
except JoseError as exc:
raise InvalidTokenError(
realm=self.realm, extra_attributes=self.extra_attributes
) from exc
# If an authorization request includes a scope parameter, the corresponding
# issued JWT access token SHOULD include a 'scope' claim as defined in Section
# 4.2 of [RFC8693]. All the individual scope strings in the 'scope' claim MUST
# have meaning for the resources indicated in the 'aud' claim. See Section 5 for
# more considerations about the relationship between scope strings and resources
# indicated by the 'aud' claim.
if self.scope_insufficient(token.get("scope", []), scopes):
raise InsufficientScopeError()
# Many authorization servers embed authorization attributes that go beyond the
# delegated scenarios described by [RFC7519] in the access tokens they issue.
# Typical examples include resource owner memberships in roles and groups that
# are relevant to the resource being accessed, entitlements assigned to the
# resource owner for the targeted resource that the authorization server knows
# about, and so on. An authorization server wanting to include such attributes
# in a JWT access token SHOULD use the 'groups', 'roles', and 'entitlements'
# attributes of the 'User' resource schema defined by Section 4.1.2 of
# [RFC7643]) as claim types.
if self.scope_insufficient(token.get("groups"), groups):
raise InvalidTokenError()
if self.scope_insufficient(token.get("roles"), roles):
raise InvalidTokenError()
if self.scope_insufficient(token.get("entitlements"), entitlements):
raise InvalidTokenError()
@@ -0,0 +1,9 @@
from .authorization_server import JWTAuthenticationRequest
from .discovery import AuthorizationServerMetadata
from .registration import ClientMetadataClaims
__all__ = [
"AuthorizationServerMetadata",
"JWTAuthenticationRequest",
"ClientMetadataClaims",
]
@@ -0,0 +1,279 @@
from joserfc import jwt
from joserfc.errors import DecodeError
from joserfc.errors import JoseError
from joserfc.errors import UnsupportedAlgorithmError
from joserfc.jws import JWSRegistry
from authlib._joserfc_helpers import import_any_key
from ..rfc6749 import AuthorizationServer
from ..rfc6749 import ClientMixin
from ..rfc6749 import InvalidClientError
from ..rfc6749 import InvalidRequestError
from ..rfc6749.requests import BasicOAuth2Payload
from ..rfc6749.requests import OAuth2Request
from .errors import InvalidRequestObjectError
from .errors import InvalidRequestUriError
from .errors import RequestNotSupportedError
from .errors import RequestUriNotSupportedError
class JWTAuthenticationRequest:
"""Authorization server extension implementing the support
for JWT secured authentication request, as defined in :rfc:`RFC9101 <9101>`.
:param support_request: Whether to enable support for the ``request`` parameter.
:param support_request_uri: Whether to enable support for the ``request_uri`` parameter.
This extension is intended to be inherited and registered into the authorization server::
class JWTAuthenticationRequest(rfc9101.JWTAuthenticationRequest):
def resolve_client_public_key(self, client: ClientMixin):
return get_jwks_for_client(client)
def get_request_object(self, request_uri: str):
try:
return requests.get(request_uri).text
except requests.Exception:
return None
def get_server_metadata(self):
return {
"issuer": ...,
"authorization_endpoint": ...,
"require_signed_request_object": ...,
}
def get_client_require_signed_request_object(self, client: ClientMixin):
return client.require_signed_request_object
authorization_server.register_extension(JWTAuthenticationRequest())
"""
claims_validator = jwt.JWTClaimsRegistry(
client_id={"essential": True},
)
def __init__(self, support_request: bool = True, support_request_uri: bool = True):
self.support_request = support_request
self.support_request_uri = support_request_uri
def __call__(self, authorization_server: AuthorizationServer):
authorization_server.register_hook(
"before_get_authorization_grant", self.parse_authorization_request
)
def get_request_object_signing_algorithms(self, client):
"""Return the supported algorithms for verifying the ``request_object`` JWT signature.
By default, this method will only return the recommended algorithms. If signed request
object is not required, "none" algorithm will be included.
Developers can override this method to customize the supported algorithms::
def get_request_object_signing_algorithms(self, client):
return ["RS256"]
"""
metadata = self.get_server_metadata()
algorithms = metadata.get("request_object_signing_alg_values_supported")
if not algorithms:
require_signed1 = self.get_client_require_signed_request_object(client)
require_signed2 = metadata.get("require_signed_request_object", False)
if require_signed1 or require_signed2:
algorithms = JWSRegistry.recommended
else:
algorithms = [*JWSRegistry.recommended, "none"]
return algorithms
def parse_authorization_request(
self, authorization_server: AuthorizationServer, request: OAuth2Request
):
client_id = request.payload.client_id
if client_id is None:
raise InvalidClientError(
status_code=404,
description="Missing 'client_id' parameter.",
)
client = authorization_server.query_client(client_id)
if not client:
raise InvalidClientError(
status_code=404,
description="The client does not exist on this server.",
)
if not self._shoud_proceed_with_request_object(request, client):
return
raw_request_object = self._get_raw_request_object(request)
request_object = self._decode_request_object(
request, client, raw_request_object
)
payload = BasicOAuth2Payload(request_object.claims)
request.payload = payload
def _shoud_proceed_with_request_object(
self,
request: OAuth2Request,
client: ClientMixin,
) -> bool:
if "request" in request.payload.data and "request_uri" in request.payload.data:
raise InvalidRequestError(
"The 'request' and 'request_uri' parameters are mutually exclusive.",
state=request.payload.state,
)
if "request" in request.payload.data:
if not self.support_request:
raise RequestNotSupportedError(state=request.payload.state)
return True
if "request_uri" in request.payload.data:
if not self.support_request_uri:
raise RequestUriNotSupportedError(state=request.payload.state)
return True
# When the value of it [require_signed_request_object] as client metadata is true,
# then the server MUST reject the authorization request
# from the client that does not conform to this specification.
if self.get_client_require_signed_request_object(client):
raise InvalidRequestError(
"Authorization requests for this client must use signed request objects.",
state=request.payload.state,
)
# When the value of it [require_signed_request_object] as server metadata is true,
# then the server MUST reject the authorization request
# from any client that does not conform to this specification.
metadata = self.get_server_metadata()
if metadata and metadata.get("require_signed_request_object", False):
raise InvalidRequestError(
"Authorization requests for this server must use signed request objects.",
state=request.payload.state,
)
return False
def _get_raw_request_object(self, request: OAuth2Request) -> str:
if "request_uri" in request.payload.data:
raw_request_object = self.get_request_object(
request.payload.data["request_uri"]
)
if not raw_request_object:
raise InvalidRequestUriError(state=request.payload.state)
else:
raw_request_object = request.payload.data["request"]
return raw_request_object
def _decode_request_object(
self, request, client: ClientMixin, raw_request_object: str
):
jwks = self.resolve_client_public_key(client)
key = import_any_key(jwks)
algorithms = self.get_request_object_signing_algorithms(client)
try:
request_object = jwt.decode(raw_request_object, key, algorithms=algorithms)
self.claims_validator.validate(request_object.claims)
except UnsupportedAlgorithmError as error:
raise InvalidRequestError(
"Authorization requests must be signed with supported algorithms.",
state=request.payload.state,
) from error
except DecodeError as error:
raise InvalidRequestObjectError(state=request.payload.state) from error
except JoseError as error:
raise InvalidRequestObjectError(
description=error.description or InvalidRequestObjectError.description,
state=request.payload.state,
) from error
# The client ID values in the client_id request parameter and in
# the Request Object client_id claim MUST be identical.
if request_object.claims["client_id"] != request.payload.client_id:
raise InvalidRequestError(
"The 'client_id' claim from the request parameters "
"and the request object claims don't match.",
state=request.payload.state,
)
# The Request Object MAY be sent by value, as described in Section 5.1,
# or by reference, as described in Section 5.2. request and
# request_uri parameters MUST NOT be included in Request Objects.
if "request" in request_object.claims or "request_uri" in request_object.claims:
raise InvalidRequestError(
"The 'request' and 'request_uri' parameters must not be included in the request object.",
state=request.payload.state,
)
return request_object
def get_request_object(self, request_uri: str):
"""Download the request object at ``request_uri``.
This method must be implemented if the ``request_uri`` parameter is supported::
class JWTAuthenticationRequest(rfc9101.JWTAuthenticationRequest):
def get_request_object(self, request_uri: str):
try:
return requests.get(request_uri).text
except requests.Exception:
return None
"""
raise NotImplementedError()
def resolve_client_public_key(self, client: ClientMixin):
"""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::
from joserfc import KeySet
class JWTAuthenticationRequest(rfc9101.JWTAuthenticationRequest):
def resolve_client_public_key(self, client):
if client.jwks_uri:
data = requests.get(client.jwks_uri).json()
return KeySet.import_key_set(data)
return KeySet.import_key_set(client.jwks)
"""
raise NotImplementedError()
def get_server_metadata(self) -> dict:
"""Return server metadata which includes supported grant types,
response types and etc.
When the ``require_signed_request_object`` claim is :data:`True`,
all clients require that authorization requests
use request objects, and an error will be returned when the authorization
request payload is passed in the request body or query string::
class JWTAuthenticationRequest(rfc9101.JWTAuthenticationRequest):
def get_server_metadata(self):
return {
"issuer": ...,
"authorization_endpoint": ...,
"require_signed_request_object": ...,
"request_object_signing_alg_values_supported": ["RS256", ...],
}
"""
return {} # pragma: no cover
def get_client_require_signed_request_object(self, client: ClientMixin) -> bool:
"""Return the 'require_signed_request_object' client metadata.
When :data:`True`, the client requires that authorization requests
use request objects, and an error will be returned when the authorization
request payload is passed in the request body or query string::
class JWTAuthenticationRequest(rfc9101.JWTAuthenticationRequest):
def get_client_require_signed_request_object(self, client):
return client.require_signed_request_object
If not implemented, the value is considered as :data:`False`.
"""
return False # pragma: no cover
@@ -0,0 +1,21 @@
from authlib.oauth2.rfc8414.models import validate_boolean_value
class AuthorizationServerMetadata(dict):
"""Authorization Server Metadata extension for RFC9101 (JAR).
This class can be used with
:meth:`~authlib.oauth2.rfc8414.AuthorizationServerMetadata.validate`
to validate JAR-specific metadata::
from authlib.oauth2 import rfc8414, rfc9101
metadata = rfc8414.AuthorizationServerMetadata(data)
metadata.validate(metadata_classes=[rfc9101.AuthorizationServerMetadata])
"""
REGISTRY_KEYS = ["require_signed_request_object"]
def validate_require_signed_request_object(self):
"""Indicates where authorization request needs to be protected as Request Object and provided through either request or request_uri parameter."""
validate_boolean_value(self, "require_signed_request_object")
@@ -0,0 +1,34 @@
from ..base import OAuth2Error
__all__ = [
"InvalidRequestUriError",
"InvalidRequestObjectError",
"RequestNotSupportedError",
"RequestUriNotSupportedError",
]
class InvalidRequestUriError(OAuth2Error):
error = "invalid_request_uri"
description = "The request_uri in the authorization request returns an error or contains invalid data."
status_code = 400
class InvalidRequestObjectError(OAuth2Error):
error = "invalid_request_object"
description = "The request parameter contains an invalid Request Object."
status_code = 400
class RequestNotSupportedError(OAuth2Error):
error = "request_not_supported"
description = (
"The authorization server does not support the use of the request parameter."
)
status_code = 400
class RequestUriNotSupportedError(OAuth2Error):
error = "request_uri_not_supported"
description = "The authorization server does not support the use of the request_uri parameter."
status_code = 400
@@ -0,0 +1,43 @@
from joserfc.errors import InvalidClaimError
from authlib.oauth2.claims import BaseClaims
class ClientMetadataClaims(BaseClaims):
"""Additional client metadata can be used with :ref:`specs/rfc7591` and :ref:`specs/rfc7592` endpoints.
This can be used with::
server.register_endpoint(
ClientRegistrationEndpoint(
claims_classes=[
rfc7591.ClientMetadataClaims,
rfc9101.ClientMetadataClaims,
]
)
)
server.register_endpoint(
ClientRegistrationEndpoint(
claims_classes=[
rfc7591.ClientMetadataClaims,
rfc9101.ClientMetadataClaims,
]
)
)
"""
REGISTERED_CLAIMS = [
"require_signed_request_object",
]
def validate(self, now=None, leeway=0):
super().validate(now, leeway)
self.validate_require_signed_request_object()
def validate_require_signed_request_object(self):
self.setdefault("require_signed_request_object", False)
if not isinstance(self["require_signed_request_object"], bool):
raise InvalidClaimError("require_signed_request_object")
@@ -0,0 +1,4 @@
from .discovery import AuthorizationServerMetadata
from .parameter import IssuerParameter
__all__ = ["AuthorizationServerMetadata", "IssuerParameter"]
@@ -0,0 +1,25 @@
from authlib.oauth2.rfc8414.models import validate_boolean_value
class AuthorizationServerMetadata(dict):
"""Authorization Server Metadata extension for RFC9207.
This class can be used with
:meth:`~authlib.oauth2.rfc8414.AuthorizationServerMetadata.validate`
to validate RFC9207-specific metadata::
from authlib.oauth2 import rfc8414, rfc9207
metadata = rfc8414.AuthorizationServerMetadata(data)
metadata.validate(metadata_classes=[rfc9207.AuthorizationServerMetadata])
"""
REGISTRY_KEYS = ["authorization_response_iss_parameter_supported"]
def validate_authorization_response_iss_parameter_supported(self):
"""Boolean parameter indicating whether the authorization server
provides the iss parameter in the authorization response.
If omitted, the default value is false.
"""
validate_boolean_value(self, "authorization_response_iss_parameter_supported")
@@ -0,0 +1,43 @@
from authlib.common.urls import add_params_to_uri
from authlib.deprecate import deprecate
from authlib.oauth2.rfc6749.grants import BaseGrant
class IssuerParameter:
def __call__(self, authorization_server):
if isinstance(authorization_server, BaseGrant):
deprecate(
"IssueParameter should be used as an authorization server extension with 'authorization_server.register_extension(IssueParameter())'.",
version="1.8",
)
authorization_server.register_hook(
"after_authorization_response",
self.add_issuer_parameter,
)
else:
authorization_server.register_hook(
"after_create_authorization_response",
self.add_issuer_parameter,
)
def add_issuer_parameter(self, authorization_server, response):
if self.get_issuer() and response.location:
# RFC9207 §2
# In authorization responses to the client, including error responses,
# an authorization server supporting this specification MUST indicate
# its identity by including the iss parameter in the response.
new_location = add_params_to_uri(
response.location, {"iss": self.get_issuer()}
)
response.location = new_location
def get_issuer(self) -> str | None:
"""Return the issuer URL.
Developers MAY implement this method if they want to support :rfc:`RFC9207 <9207>`::
def get_issuer(self) -> str:
return "https://auth.example.org"
"""
return None