first commit
This commit is contained in:
@@ -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()
|
||||
+155
@@ -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
|
||||
Reference in New Issue
Block a user