first commit

This commit is contained in:
linkitapps
2026-09-14 23:22:10 +09:00
commit 9f9c8fb69b
4207 changed files with 1333187 additions and 0 deletions
@@ -0,0 +1,20 @@
"""
authlib
~~~~~~~
The ultimate Python library in building OAuth 1.0, OAuth 2.0 and OpenID
Connect clients and providers. It covers from low level specification
implementation to high level framework integrations.
:copyright: (c) 2017 by Hsiaoming Yang.
:license: BSD, see LICENSE for more details.
"""
from .consts import author
from .consts import homepage
from .consts import version
__version__ = version
__homepage__ = homepage
__author__ = author
__license__ = "BSD-3-Clause"
@@ -0,0 +1,48 @@
import sys
from typing import Any
from joserfc.jwk import KeySet
from joserfc.jwk import import_key
from authlib.common.encoding import json_loads
from authlib.deprecate import deprecate
def import_any_key(data: Any):
if "authlib.jose" in sys.modules:
from authlib.jose.rfc7518 import ECKey
from authlib.jose.rfc7518 import OctKey
from authlib.jose.rfc7518 import RSAKey
from authlib.jose.rfc8037 import OKPKey
if isinstance(data, (OctKey, RSAKey, ECKey, OKPKey)):
deprecate("Please use joserfc to import keys.", version="2.0.0")
return import_key(data.as_dict(is_private=not data.public_only))
if (
isinstance(data, str)
and data.strip().startswith("{")
and data.strip().endswith("}")
):
deprecate(
"Please use OctKey, RSAKey, ECKey, OKPKey, and KeySet directly.",
version="2.0.0",
)
data = json_loads(data)
if isinstance(data, (str, bytes)):
deprecate(
"Please use OctKey, RSAKey, ECKey, OKPKey, and KeySet directly.",
version="2.0.0",
)
return import_key(data)
elif isinstance(data, dict):
if "keys" in data:
deprecate(
"Please `KeySet.import_key_set` from `joserfc.jwk` to import jwks.",
version="2.0.0",
)
return KeySet.import_key_set(data)
return import_key(data)
return data
@@ -0,0 +1,66 @@
import base64
import json
import struct
def to_bytes(x, charset="utf-8", errors="strict"):
if x is None:
return None
if isinstance(x, bytes):
return x
if isinstance(x, str):
return x.encode(charset, errors)
if isinstance(x, (int, float)):
return str(x).encode(charset, errors)
return bytes(x)
def to_unicode(x, charset="utf-8", errors="strict"):
if x is None or isinstance(x, str):
return x
if isinstance(x, bytes):
return x.decode(charset, errors)
return str(x)
def to_native(x, encoding="ascii"):
if isinstance(x, str):
return x
return x.decode(encoding)
def json_loads(s):
return json.loads(s)
def json_dumps(data, ensure_ascii=False):
return json.dumps(data, ensure_ascii=ensure_ascii, separators=(",", ":"))
def urlsafe_b64decode(s):
s += b"=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
def urlsafe_b64encode(s):
return base64.urlsafe_b64encode(s).rstrip(b"=")
def base64_to_int(s):
data = urlsafe_b64decode(to_bytes(s, charset="ascii"))
buf = struct.unpack(f"{len(data)}B", data)
return int("".join([f"{byte:02x}" for byte in buf]), 16)
def int_to_base64(num):
if num < 0:
raise ValueError("Must be a positive integer")
s = num.to_bytes((num.bit_length() + 7) // 8, "big", signed=False)
return to_unicode(urlsafe_b64encode(s))
def json_b64encode(text):
if isinstance(text, dict):
text = json_dumps(text)
return urlsafe_b64encode(to_bytes(text))
@@ -0,0 +1,62 @@
from authlib.consts import default_json_headers
class AuthlibBaseError(Exception):
"""Base Exception for all errors in Authlib."""
#: short-string error code
error = None
#: long-string to describe this error
description = ""
#: web page that describes this error
uri = None
def __init__(self, error=None, description=None, uri=None):
if error is not None:
self.error = error
if description is not None:
self.description = description
if uri is not None:
self.uri = uri
message = f"{self.error}: {self.description}"
super().__init__(message)
def __repr__(self):
return f'<{self.__class__.__name__} "{self.error}">'
class AuthlibHTTPError(AuthlibBaseError):
#: HTTP status code
status_code = 400
def __init__(self, error=None, description=None, uri=None, status_code=None):
super().__init__(error, description, uri)
if status_code is not None:
self.status_code = status_code
def get_error_description(self):
return self.description
def get_body(self):
error = [("error", self.error)]
if self.description:
error.append(("error_description", self.description))
if self.uri:
error.append(("error_uri", self.uri))
return error
def get_headers(self):
return default_json_headers[:]
def __call__(self, uri=None):
self.uri = uri
body = dict(self.get_body())
headers = self.get_headers()
return self.status_code, body, headers
class ContinueIteration(AuthlibBaseError):
pass
@@ -0,0 +1,13 @@
import re
# Structurally validates BCP 47 language tags (RFC 5646).
# Accepts private-use tags (x-...) and standard tags (2-8 alpha + optional subtags).
# Does not validate subtags against the IANA registry.
_LANGUAGE_TAG_RE = re.compile(
r"^(x(-[a-zA-Z0-9]{1,8})+|[a-zA-Z]{2,8}(-[a-zA-Z0-9]{1,8})*)$"
)
def is_valid_language_tag(tag):
"""Return True if tag is a structurally valid BCP 47 language tag."""
return isinstance(tag, str) and bool(_LANGUAGE_TAG_RE.match(tag))
@@ -0,0 +1,48 @@
import ipaddress
import os
import random
import string
from urllib.parse import urlsplit
UNICODE_ASCII_CHARACTER_SET = string.ascii_letters + string.digits
def generate_token(length=30, chars=UNICODE_ASCII_CHARACTER_SET):
rand = random.SystemRandom()
return "".join(rand.choice(chars) for _ in range(length))
def is_secure_transport(uri):
"""Check if the uri is over ssl."""
if os.getenv("AUTHLIB_INSECURE_TRANSPORT"):
return True
try:
parts = urlsplit(uri)
except ValueError:
return False
if not parts.hostname:
return False
if parts.scheme == "https":
return True
if parts.scheme != "http":
return False
# rfc8252 §7.3: native apps may use http for loopback redirection URIs.
if parts.hostname == "localhost":
return True
try:
address = ipaddress.ip_address(parts.hostname)
except ValueError:
return False
# IPv6Address.is_loopback only accounts for the mapped IPv4 address since
# CPython 3.10.16, 3.11.11 and 3.12.4.
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
address = address.ipv4_mapped
return address.is_loopback
@@ -0,0 +1,155 @@
"""authlib.util.urls.
~~~~~~~~~~~~~~~~~
Wrapper functions for URL encoding and decoding.
"""
import re
import urllib.parse as urlparse
from urllib.parse import quote as _quote
from urllib.parse import unquote as _unquote
from urllib.parse import urlencode as _urlencode
from .encoding import to_bytes
from .encoding import to_unicode
always_safe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-"
urlencoded = set(always_safe) | set("=&;:%+~,*@!()/?")
INVALID_HEX_PATTERN = re.compile(r"%[^0-9A-Fa-f]|%[0-9A-Fa-f][^0-9A-Fa-f]")
def url_encode(params):
encoded = []
for k, v in params:
encoded.append((to_bytes(k), to_bytes(v)))
return to_unicode(_urlencode(encoded))
def url_decode(query):
"""Decode a query string in x-www-form-urlencoded format into a sequence
of two-element tuples.
Unlike urlparse.parse_qsl(..., strict_parsing=True) urldecode will enforce
correct formatting of the query string by validation. If validation fails
a ValueError will be raised. urllib.parse_qsl will only raise errors if
any of name-value pairs omits the equals sign.
"""
# Check if query contains invalid characters
if query and not set(query) <= urlencoded:
error = (
"Error trying to decode a non urlencoded string. "
"Found invalid characters: %s "
"in the string: '%s'. "
"Please ensure the request/response body is "
"x-www-form-urlencoded."
)
raise ValueError(error % (set(query) - urlencoded, query))
# Check for correctly hex encoded values using a regular expression
# All encoded values begin with % followed by two hex characters
# correct = %00, %A0, %0A, %FF
# invalid = %G0, %5H, %PO
if INVALID_HEX_PATTERN.search(query):
raise ValueError("Invalid hex encoding in query string.")
# We encode to utf-8 prior to parsing because parse_qsl behaves
# differently on unicode input in python 2 and 3.
# Python 2.7
# >>> urlparse.parse_qsl(u'%E5%95%A6%E5%95%A6')
# u'\xe5\x95\xa6\xe5\x95\xa6'
# Python 2.7, non unicode input gives the same
# >>> urlparse.parse_qsl('%E5%95%A6%E5%95%A6')
# '\xe5\x95\xa6\xe5\x95\xa6'
# but now we can decode it to unicode
# >>> urlparse.parse_qsl('%E5%95%A6%E5%95%A6').decode('utf-8')
# u'\u5566\u5566'
# Python 3.3 however
# >>> urllib.parse.parse_qsl(u'%E5%95%A6%E5%95%A6')
# u'\u5566\u5566'
# We want to allow queries such as "c2" whereas urlparse.parse_qsl
# with the strict_parsing flag will not.
params = urlparse.parse_qsl(query, keep_blank_values=True)
# unicode all the things
decoded = []
for k, v in params:
decoded.append((to_unicode(k), to_unicode(v)))
return decoded
def add_params_to_qs(query, params):
"""Extend a query with a list of two-tuples."""
if isinstance(params, dict):
params = params.items()
qs = urlparse.parse_qsl(query, keep_blank_values=True)
qs.extend(params)
return url_encode(qs)
def add_params_to_uri(uri, params, fragment=False):
"""Add a list of two-tuples to the uri query components."""
sch, net, path, par, query, fra = urlparse.urlparse(uri)
if fragment:
fra = add_params_to_qs(fra, params)
else:
query = add_params_to_qs(query, params)
return urlparse.urlunparse((sch, net, path, par, query, fra))
def quote(s, safe=b"/"):
return to_unicode(_quote(to_bytes(s), safe))
def unquote(s):
return to_unicode(_unquote(s))
def quote_url(s):
return quote(s, b"~@#$&()*!+=:;,.?/'")
def extract_params(raw):
"""Extract parameters and return them as a list of 2-tuples.
Will successfully extract parameters from urlencoded query strings,
dicts, or lists of 2-tuples. Empty strings/dicts/lists will return an
empty list of parameters. Any other input will result in a return
value of None.
"""
if isinstance(raw, (list, tuple)):
try:
raw = dict(raw)
except (TypeError, ValueError):
return None
if isinstance(raw, dict):
params = []
for k, v in raw.items():
params.append((to_unicode(k), to_unicode(v)))
return params
if not raw:
return None
try:
return url_decode(raw)
except ValueError:
return None
def is_valid_url(url: str, fragments_allowed=True):
try:
parsed = urlparse.urlparse(url)
except ValueError:
return False
# The userinfo component can disguise the actual host,
# as in "https://provider.test@attacker.test/".
if "@" in parsed.netloc:
return False
return bool(
parsed.scheme and parsed.hostname and (fragments_allowed or not parsed.fragment)
)
+11
View File
@@ -0,0 +1,11 @@
name = "Authlib"
version = "1.8.0"
author = "Hsiaoming Yang <me@lepture.com>"
homepage = "https://authlib.org"
default_user_agent = f"{name}/{version} (+{homepage})"
default_json_headers = [
("Content-Type", "application/json"),
("Cache-Control", "no-store"),
("Pragma", "no-cache"),
]
@@ -0,0 +1,15 @@
import warnings
class AuthlibDeprecationWarning(DeprecationWarning):
pass
warnings.simplefilter("always", AuthlibDeprecationWarning)
def deprecate(message, version=None, stacklevel=3):
if version:
message += f"\nIt will be compatible before version {version}."
warnings.warn(AuthlibDeprecationWarning(message), stacklevel=stacklevel)
@@ -0,0 +1,29 @@
from .errors import InvalidTokenError
from .errors import MismatchingStateError
from .errors import MissingRequestTokenError
from .errors import MissingTokenError
from .errors import OAuthError
from .errors import TokenExpiredError
from .errors import UnsupportedTokenTypeError
from .framework_integration import FrameworkIntegration
from .registry import BaseOAuth
from .sync_app import BaseApp
from .sync_app import OAuth1Mixin
from .sync_app import OAuth2Mixin
from .sync_openid import OpenIDMixin
__all__ = [
"BaseOAuth",
"BaseApp",
"OAuth1Mixin",
"OAuth2Mixin",
"OpenIDMixin",
"FrameworkIntegration",
"OAuthError",
"MissingRequestTokenError",
"MissingTokenError",
"TokenExpiredError",
"InvalidTokenError",
"UnsupportedTokenTypeError",
"MismatchingStateError",
]
@@ -0,0 +1,152 @@
import logging
import time
from authlib.common.urls import urlparse
from .errors import MissingRequestTokenError
from .errors import MissingTokenError
from .sync_app import OAuth1Base
from .sync_app import OAuth2Base
log = logging.getLogger(__name__)
__all__ = ["AsyncOAuth1Mixin", "AsyncOAuth2Mixin"]
class AsyncOAuth1Mixin(OAuth1Base):
async def request(self, method, url, token=None, **kwargs):
async with self._get_oauth_client() as session:
return await _http_request(self, session, method, url, token, kwargs)
async def create_authorization_url(self, redirect_uri=None, **kwargs):
"""Generate the authorization url and state for HTTP redirect.
:param redirect_uri: Callback or redirect URI for authorization.
:param kwargs: Extra parameters to include.
:return: dict
"""
if not self.authorize_url:
raise RuntimeError('Missing "authorize_url" value')
if self.authorize_params:
kwargs.update(self.authorize_params)
async with self._get_oauth_client() as client:
client.redirect_uri = redirect_uri
params = {}
if self.request_token_params:
params.update(self.request_token_params)
request_token = await client.fetch_request_token(
self.request_token_url, **params
)
log.debug(f"Fetch request token: {request_token!r}")
url = client.create_authorization_url(self.authorize_url, **kwargs)
state = request_token["oauth_token"]
return {"url": url, "request_token": request_token, "state": state}
async def fetch_access_token(self, request_token=None, **kwargs):
"""Fetch access token in one step.
:param request_token: A previous request token for OAuth 1.
:param kwargs: Extra parameters to fetch access token.
:return: A token dict.
"""
async with self._get_oauth_client() as client:
if request_token is None:
raise MissingRequestTokenError()
# merge request token with verifier
token = {}
token.update(request_token)
token.update(kwargs)
client.token = token
params = self.access_token_params or {}
token = await client.fetch_access_token(self.access_token_url, **params)
return token
class AsyncOAuth2Mixin(OAuth2Base):
async def _on_update_token(self, token, refresh_token=None, access_token=None):
if self._update_token:
await self._update_token(
token,
refresh_token=refresh_token,
access_token=access_token,
)
async def load_server_metadata(self):
if self._server_metadata_url and "_loaded_at" not in self.server_metadata:
async with self._get_session() as client:
resp = await client.request(
"GET", self._server_metadata_url, withhold_token=True
)
resp.raise_for_status()
metadata = resp.json()
metadata["_loaded_at"] = time.time()
self.server_metadata.update(metadata)
return self.server_metadata
async def request(self, method, url, token=None, **kwargs):
metadata = await self.load_server_metadata()
async with self._get_oauth_client(**metadata) as session:
return await _http_request(self, session, method, url, token, kwargs)
async def create_authorization_url(self, redirect_uri=None, **kwargs):
"""Generate the authorization url and state for HTTP redirect.
:param redirect_uri: Callback or redirect URI for authorization.
:param kwargs: Extra parameters to include.
:return: dict
"""
metadata = await self.load_server_metadata()
authorization_endpoint = self.authorize_url or metadata.get(
"authorization_endpoint"
)
if not authorization_endpoint:
raise RuntimeError('Missing "authorize_url" value')
if self.authorize_params:
kwargs.update(self.authorize_params)
async with self._get_oauth_client(**metadata) as client:
client.redirect_uri = redirect_uri
return self._create_oauth2_authorization_url(
client, authorization_endpoint, **kwargs
)
async def fetch_access_token(self, redirect_uri=None, **kwargs):
"""Fetch access token in the final step.
:param redirect_uri: Callback or Redirect URI that is used in
previous :meth:`authorize_redirect`.
:param kwargs: Extra parameters to fetch access token.
:return: A token dict.
"""
metadata = await self.load_server_metadata()
token_endpoint = self.access_token_url or metadata.get("token_endpoint")
async with self._get_oauth_client(**metadata) as client:
if redirect_uri is not None:
client.redirect_uri = redirect_uri
params = {}
if self.access_token_params:
params.update(self.access_token_params)
params.update(kwargs)
token = await client.fetch_token(token_endpoint, **params)
return token
async def _http_request(ctx, session, method, url, token, kwargs):
request = kwargs.pop("request", None)
withhold_token = kwargs.get("withhold_token")
if ctx.api_base_url and not url.startswith(("https://", "http://")):
url = urlparse.urljoin(ctx.api_base_url, url)
if withhold_token:
return await session.request(method, url, **kwargs)
if token is None and ctx._fetch_token and request:
token = await ctx._fetch_token(request)
if token is None:
raise MissingTokenError()
session.token = token
return await session.request(method, url, **kwargs)
@@ -0,0 +1,124 @@
from joserfc import jwt
from joserfc.errors import InvalidKeyIdError
from joserfc.jwk import KeySet
from joserfc.jws import JWSRegistry
from authlib.common.security import generate_token
from authlib.common.urls import add_params_to_uri
from authlib.oidc.core import CodeIDToken
from authlib.oidc.core import ImplicitIDToken
from authlib.oidc.core import UserInfo
__all__ = ["AsyncOpenIDMixin"]
class AsyncOpenIDMixin:
async def fetch_jwk_set(self, force=False):
metadata = await self.load_server_metadata()
jwk_set = metadata.get("jwks")
if jwk_set and not force:
return jwk_set
uri = metadata.get("jwks_uri")
if not uri:
raise RuntimeError('Missing "jwks_uri" in metadata')
async with self._get_session() as client:
resp = await client.request("GET", uri, withhold_token=True)
resp.raise_for_status()
jwk_set = resp.json()
self.server_metadata["jwks"] = jwk_set
return jwk_set
async def userinfo(self, **kwargs):
"""Fetch user info from ``userinfo_endpoint``."""
metadata = await self.load_server_metadata()
resp = await self.get(metadata["userinfo_endpoint"], **kwargs)
resp.raise_for_status()
data = resp.json()
return UserInfo(data)
async def parse_id_token(
self, token, nonce, claims_options=None, claims_cls=None, leeway=120
):
"""Return an instance of UserInfo from token's ``id_token``."""
claims_params = dict(
nonce=nonce,
client_id=self.client_id,
)
if claims_cls is None:
if "access_token" in token:
claims_params["access_token"] = token["access_token"]
claims_cls = CodeIDToken
else:
claims_cls = ImplicitIDToken
metadata = await self.load_server_metadata()
if claims_options is None and "issuer" in metadata:
claims_options = {"iss": {"values": [metadata["issuer"]]}}
alg_values = metadata.get("id_token_signing_alg_values_supported")
if not alg_values:
alg_values = ["RS256"]
jwks = await self.fetch_jwk_set()
key_set = KeySet.import_key_set(jwks)
try:
token = jwt.decode(
token["id_token"],
key=key_set,
registry=JWSRegistry(algorithms=alg_values, strict_check_header=False),
)
except InvalidKeyIdError:
jwks = await self.fetch_jwk_set(force=True)
key_set = KeySet.import_key_set(jwks)
token = jwt.decode(
token["id_token"],
key=key_set,
registry=JWSRegistry(algorithms=alg_values, strict_check_header=False),
)
claims = claims_cls(token.claims, token.header, claims_options, claims_params)
# https://github.com/authlib/authlib/issues/259
if claims.get("nonce_supported") is False:
claims.params["nonce"] = None
claims.validate(leeway=leeway)
return UserInfo(claims)
async def create_logout_url(
self,
post_logout_redirect_uri=None,
id_token_hint=None,
state=None,
**kwargs,
):
"""Generate the end session URL for RP-Initiated Logout.
:param post_logout_redirect_uri: URI to redirect after logout.
:param id_token_hint: ID Token previously issued to the RP.
:param state: Opaque value for maintaining state.
:param kwargs: Extra parameters (client_id, logout_hint, ui_locales).
:return: dict with 'url' and 'state' keys.
"""
metadata = await self.load_server_metadata()
end_session_endpoint = metadata.get("end_session_endpoint")
if not end_session_endpoint:
raise RuntimeError('Missing "end_session_endpoint" in metadata')
params = {}
if id_token_hint:
params["id_token_hint"] = id_token_hint
if post_logout_redirect_uri:
params["post_logout_redirect_uri"] = post_logout_redirect_uri
if state is None:
state = generate_token(20)
params["state"] = state
for key in ("client_id", "logout_hint", "ui_locales"):
if key in kwargs:
params[key] = kwargs[key]
url = add_params_to_uri(end_session_endpoint, params)
return {"url": url, "state": state}
@@ -0,0 +1,30 @@
from authlib.common.errors import AuthlibBaseError
class OAuthError(AuthlibBaseError):
error = "oauth_error"
class MissingRequestTokenError(OAuthError):
error = "missing_request_token"
class MissingTokenError(OAuthError):
error = "missing_token"
class TokenExpiredError(OAuthError):
error = "token_expired"
class InvalidTokenError(OAuthError):
error = "token_invalid"
class UnsupportedTokenTypeError(OAuthError):
error = "unsupported_token_type"
class MismatchingStateError(OAuthError):
error = "mismatching_state"
description = "CSRF Warning! State not equal in request and response."
@@ -0,0 +1,65 @@
import json
import time
class FrameworkIntegration:
expires_in = 3600
def __init__(self, name, cache=None):
self.name = name
self.cache = cache
def _get_cache_data(self, key):
value = self.cache.get(key)
if not value:
return None
try:
return json.loads(value)
except (TypeError, ValueError):
return None
def _clear_session_state(self, session):
now = time.time()
prefix = f"_state_{self.name}"
for key in dict(session):
if key.startswith(prefix):
value = session[key]
exp = value.get("exp")
if not exp or exp < now:
session.pop(key)
def get_state_data(self, session, state):
key = f"_state_{self.name}_{state}"
session_data = session.get(key)
if not session_data:
return None
if self.cache:
cached_value = self._get_cache_data(key)
else:
cached_value = session_data
if cached_value:
return cached_value.get("data")
return None
def set_state_data(self, session, state, data):
key = f"_state_{self.name}_{state}"
now = time.time()
if self.cache:
self.cache.set(key, json.dumps({"data": data}), self.expires_in)
session[key] = {"exp": now + self.expires_in}
else:
session[key] = {"data": data, "exp": now + self.expires_in}
def clear_state_data(self, session, state):
key = f"_state_{self.name}_{state}"
if self.cache:
self.cache.delete(key)
session.pop(key, None)
self._clear_session_state(session)
def update_token(self, token, refresh_token=None, access_token=None):
raise NotImplementedError()
@staticmethod
def load_config(oauth, name, params):
raise NotImplementedError()
@@ -0,0 +1,139 @@
import functools
from .framework_integration import FrameworkIntegration
__all__ = ["BaseOAuth"]
OAUTH_CLIENT_PARAMS = (
"client_id",
"client_secret",
"request_token_url",
"request_token_params",
"access_token_url",
"access_token_params",
"refresh_token_url",
"refresh_token_params",
"authorize_url",
"authorize_params",
"api_base_url",
"client_kwargs",
"server_metadata_url",
)
class BaseOAuth:
"""Registry for oauth clients.
Create an instance for registry::
oauth = OAuth()
"""
oauth1_client_cls = None
oauth2_client_cls = None
framework_integration_cls = FrameworkIntegration
def __init__(self, cache=None, fetch_token=None, update_token=None):
self._registry = {}
self._clients = {}
self.cache = cache
self.fetch_token = fetch_token
self.update_token = update_token
def create_client(self, name):
"""Create or get the given named OAuth client. For instance, the
OAuth registry has ``.register`` a twitter client, developers may
access the client with::
client = oauth.create_client("twitter")
:param: name: Name of the remote application
:return: OAuth remote app
"""
if name in self._clients:
return self._clients[name]
if name not in self._registry:
return None
overwrite, config = self._registry[name]
client_cls = config.pop("client_cls", None)
if client_cls and client_cls.OAUTH_APP_CONFIG:
kwargs = client_cls.OAUTH_APP_CONFIG
kwargs.update(config)
else:
kwargs = config
kwargs = self.generate_client_kwargs(name, overwrite, **kwargs)
framework = self.framework_integration_cls(name, self.cache)
if client_cls:
client = client_cls(framework, name, **kwargs)
elif kwargs.get("request_token_url"):
client = self.oauth1_client_cls(framework, name, **kwargs)
else:
client = self.oauth2_client_cls(framework, name, **kwargs)
self._clients[name] = client
return client
def register(self, name, overwrite=False, **kwargs):
"""Registers a new remote application.
:param name: Name of the remote application.
:param overwrite: Overwrite existing config with framework settings.
:param kwargs: Parameters for :class:`RemoteApp`.
Find parameters for the given remote app class. When a remote app is
registered, it can be accessed with *named* attribute::
oauth.register('twitter', client_id='', ...)
oauth.twitter.get('timeline')
"""
self._registry[name] = (overwrite, kwargs)
return self.create_client(name)
def generate_client_kwargs(self, name, overwrite, **kwargs):
fetch_token = kwargs.pop("fetch_token", None)
update_token = kwargs.pop("update_token", None)
config = self.load_config(name, OAUTH_CLIENT_PARAMS)
if config:
kwargs = _config_client(config, kwargs, overwrite)
if not fetch_token and self.fetch_token:
fetch_token = functools.partial(self.fetch_token, name)
kwargs["fetch_token"] = fetch_token
if not kwargs.get("request_token_url"):
if not update_token and self.update_token:
update_token = functools.partial(self.update_token, name)
kwargs["update_token"] = update_token
return kwargs
def load_config(self, name, params):
return self.framework_integration_cls.load_config(self, name, params)
def __getattr__(self, key):
try:
return object.__getattribute__(self, key)
except AttributeError as exc:
if key in self._registry:
return self.create_client(key)
raise AttributeError(f"No such client: {key}") from exc
def _config_client(config, kwargs, overwrite):
for k in OAUTH_CLIENT_PARAMS:
v = config.get(k, None)
if k not in kwargs:
kwargs[k] = v
elif overwrite and v:
if isinstance(kwargs[k], dict):
kwargs[k].update(v)
else:
kwargs[k] = v
return kwargs
@@ -0,0 +1,382 @@
import logging
import time
from authlib.common.security import generate_token
from authlib.common.urls import urlparse
from authlib.consts import default_user_agent
from .errors import MismatchingStateError
from .errors import MissingRequestTokenError
from .errors import MissingTokenError
log = logging.getLogger(__name__)
class BaseApp:
client_cls = None
OAUTH_APP_CONFIG = None
def request(self, method, url, token=None, **kwargs):
raise NotImplementedError()
def get(self, url, **kwargs):
"""Invoke GET http request.
If ``api_base_url`` configured, shortcut is available::
client.get("users/lepture")
"""
return self.request("GET", url, **kwargs)
def post(self, url, **kwargs):
"""Invoke POST http request.
If ``api_base_url`` configured, shortcut is available::
client.post("timeline", json={"text": "Hi"})
"""
return self.request("POST", url, **kwargs)
def patch(self, url, **kwargs):
"""Invoke PATCH http request.
If ``api_base_url`` configured, shortcut is available::
client.patch("profile", json={"name": "Hsiaoming Yang"})
"""
return self.request("PATCH", url, **kwargs)
def put(self, url, **kwargs):
"""Invoke PUT http request.
If ``api_base_url`` configured, shortcut is available::
client.put("profile", json={"name": "Hsiaoming Yang"})
"""
return self.request("PUT", url, **kwargs)
def delete(self, url, **kwargs):
"""Invoke DELETE http request.
If ``api_base_url`` configured, shortcut is available::
client.delete("posts/123")
"""
return self.request("DELETE", url, **kwargs)
class _RequestMixin:
def _get_requested_token(self, request):
if self._fetch_token and request:
return self._fetch_token(request)
def _send_token_request(self, session, method, url, token, kwargs):
request = kwargs.pop("request", None)
withhold_token = kwargs.get("withhold_token")
if self.api_base_url and not url.startswith(("https://", "http://")):
url = urlparse.urljoin(self.api_base_url, url)
if withhold_token:
return session.request(method, url, **kwargs)
if token is None:
token = self._get_requested_token(request)
if token is None:
raise MissingTokenError()
session.token = token
return session.request(method, url, **kwargs)
class OAuth1Base:
client_cls = None
def __init__(
self,
framework,
name=None,
fetch_token=None,
client_id=None,
client_secret=None,
request_token_url=None,
request_token_params=None,
access_token_url=None,
access_token_params=None,
authorize_url=None,
authorize_params=None,
api_base_url=None,
client_kwargs=None,
user_agent=None,
**kwargs,
):
self.framework = framework
self.name = name
self.client_id = client_id
self.client_secret = client_secret
self.request_token_url = request_token_url
self.request_token_params = request_token_params
self.access_token_url = access_token_url
self.access_token_params = access_token_params
self.authorize_url = authorize_url
self.authorize_params = authorize_params
self.api_base_url = api_base_url
self.client_kwargs = client_kwargs or {}
self._fetch_token = fetch_token
self._user_agent = user_agent or default_user_agent
self._kwargs = kwargs
def _get_oauth_client(self):
session = self.client_cls(
self.client_id, self.client_secret, **self.client_kwargs
)
session.headers["User-Agent"] = self._user_agent
return session
class OAuth1Mixin(_RequestMixin, OAuth1Base):
def request(self, method, url, token=None, **kwargs):
with self._get_oauth_client() as session:
return self._send_token_request(session, method, url, token, kwargs)
def create_authorization_url(self, redirect_uri=None, **kwargs):
"""Generate the authorization url and state for HTTP redirect.
:param redirect_uri: Callback or redirect URI for authorization.
:param kwargs: Extra parameters to include.
:return: dict
"""
if not self.authorize_url:
raise RuntimeError('Missing "authorize_url" value')
if self.authorize_params:
kwargs.update(self.authorize_params)
with self._get_oauth_client() as client:
client.redirect_uri = redirect_uri
params = self.request_token_params or {}
request_token = client.fetch_request_token(self.request_token_url, **params)
log.debug(f"Fetch request token: {request_token!r}")
url = client.create_authorization_url(self.authorize_url, **kwargs)
state = request_token["oauth_token"]
return {"url": url, "request_token": request_token, "state": state}
def fetch_access_token(self, request_token=None, **kwargs):
"""Fetch access token in one step.
:param request_token: A previous request token for OAuth 1.
:param kwargs: Extra parameters to fetch access token.
:return: A token dict.
"""
with self._get_oauth_client() as client:
if request_token is None:
raise MissingRequestTokenError()
# merge request token with verifier
token = {}
token.update(request_token)
token.update(kwargs)
client.token = token
params = self.access_token_params or {}
token = client.fetch_access_token(self.access_token_url, **params)
return token
class OAuth2Base:
client_cls = None
def __init__(
self,
framework,
name=None,
fetch_token=None,
update_token=None,
client_id=None,
client_secret=None,
access_token_url=None,
access_token_params=None,
authorize_url=None,
authorize_params=None,
api_base_url=None,
client_kwargs=None,
server_metadata_url=None,
compliance_fix=None,
client_auth_methods=None,
user_agent=None,
**kwargs,
):
self.framework = framework
self.name = name
self.client_id = client_id
self.client_secret = client_secret
self.access_token_url = access_token_url
self.access_token_params = access_token_params
self.authorize_url = authorize_url
self.authorize_params = authorize_params
self.api_base_url = api_base_url
self.client_kwargs = client_kwargs or {}
self.compliance_fix = compliance_fix
self.client_auth_methods = client_auth_methods
self._fetch_token = fetch_token
self._update_token = update_token
self._user_agent = user_agent or default_user_agent
self._server_metadata_url = server_metadata_url
self.server_metadata = kwargs
def _on_update_token(self, token, refresh_token=None, access_token=None):
raise NotImplementedError()
def _get_session(self):
session = self.client_cls(**self.client_kwargs)
session.headers["User-Agent"] = self._user_agent
return session
def _get_oauth_client(self, **metadata):
client_kwargs = {}
client_kwargs.update(self.client_kwargs)
client_kwargs.update(metadata)
if self.authorize_url:
client_kwargs["authorization_endpoint"] = self.authorize_url
if self.access_token_url:
client_kwargs["token_endpoint"] = self.access_token_url
session = self.client_cls(
client_id=self.client_id,
client_secret=self.client_secret,
update_token=self._on_update_token,
**client_kwargs,
)
if self.client_auth_methods:
for f in self.client_auth_methods:
session.register_client_auth_method(f)
if self.compliance_fix:
self.compliance_fix(session)
session.headers["User-Agent"] = self._user_agent
return session
@staticmethod
def _format_state_params(state_data, params):
if state_data is None:
raise MismatchingStateError()
code_verifier = state_data.get("code_verifier")
if code_verifier:
params["code_verifier"] = code_verifier
redirect_uri = state_data.get("redirect_uri")
if redirect_uri:
params["redirect_uri"] = redirect_uri
return params
@staticmethod
def _create_oauth2_authorization_url(client, authorization_endpoint, **kwargs):
rv = {}
if client.code_challenge_method:
code_verifier = kwargs.get("code_verifier")
if not code_verifier:
code_verifier = generate_token(48)
kwargs["code_verifier"] = code_verifier
rv["code_verifier"] = code_verifier
log.debug(f"Using code_verifier: {code_verifier!r}")
scope = kwargs.get("scope", client.scope)
scope = (
(scope if isinstance(scope, (list, tuple)) else scope.split())
if scope
else None
)
if scope and "openid" in scope:
# this is an OpenID Connect service
nonce = kwargs.get("nonce")
if not nonce:
nonce = generate_token(20)
kwargs["nonce"] = nonce
rv["nonce"] = nonce
url, state = client.create_authorization_url(authorization_endpoint, **kwargs)
rv["url"] = url
rv["state"] = state
return rv
class OAuth2Mixin(_RequestMixin, OAuth2Base):
def _on_update_token(self, token, refresh_token=None, access_token=None):
if callable(self._update_token):
self._update_token(
token,
refresh_token=refresh_token,
access_token=access_token,
)
self.framework.update_token(
token,
refresh_token=refresh_token,
access_token=access_token,
)
def request(self, method, url, token=None, **kwargs):
metadata = self.load_server_metadata()
with self._get_oauth_client(**metadata) as session:
return self._send_token_request(session, method, url, token, kwargs)
def load_server_metadata(self):
if self._server_metadata_url and "_loaded_at" not in self.server_metadata:
with self._get_session() as session:
resp = session.request(
"GET", self._server_metadata_url, withhold_token=True
)
resp.raise_for_status()
metadata = resp.json()
metadata["_loaded_at"] = time.time()
self.server_metadata.update(metadata)
return self.server_metadata
def create_authorization_url(self, redirect_uri=None, **kwargs):
"""Generate the authorization url and state for HTTP redirect.
:param redirect_uri: Callback or redirect URI for authorization.
:param kwargs: Extra parameters to include.
:return: dict
"""
metadata = self.load_server_metadata()
authorization_endpoint = self.authorize_url or metadata.get(
"authorization_endpoint"
)
if not authorization_endpoint:
raise RuntimeError('Missing "authorize_url" value')
if self.authorize_params:
kwargs.update(self.authorize_params)
with self._get_oauth_client(**metadata) as client:
if redirect_uri is not None:
client.redirect_uri = redirect_uri
return self._create_oauth2_authorization_url(
client, authorization_endpoint, **kwargs
)
def fetch_access_token(self, redirect_uri=None, **kwargs):
"""Fetch access token in the final step.
:param redirect_uri: Callback or Redirect URI that is used in
previous :meth:`authorize_redirect`.
:param kwargs: Extra parameters to fetch access token.
:return: A token dict.
"""
metadata = self.load_server_metadata()
token_endpoint = self.access_token_url or metadata.get("token_endpoint")
with self._get_oauth_client(**metadata) as client:
if redirect_uri is not None:
client.redirect_uri = redirect_uri
params = {}
if self.access_token_params:
params.update(self.access_token_params)
params.update(kwargs)
token = client.fetch_token(token_endpoint, **params)
return token
@@ -0,0 +1,123 @@
from joserfc import jwt
from joserfc.errors import InvalidKeyIdError
from joserfc.jwk import KeySet
from joserfc.jws import JWSRegistry
from authlib.common.security import generate_token
from authlib.common.urls import add_params_to_uri
from authlib.oidc.core import CodeIDToken
from authlib.oidc.core import ImplicitIDToken
from authlib.oidc.core import UserInfo
class OpenIDMixin:
def fetch_jwk_set(self, force=False):
metadata = self.load_server_metadata()
jwk_set = metadata.get("jwks")
if jwk_set and not force:
return jwk_set
uri = metadata.get("jwks_uri")
if not uri:
raise RuntimeError('Missing "jwks_uri" in metadata')
with self._get_session() as session:
resp = session.request("GET", uri, withhold_token=True)
resp.raise_for_status()
jwk_set = resp.json()
self.server_metadata["jwks"] = jwk_set
return jwk_set
def userinfo(self, **kwargs):
"""Fetch user info from ``userinfo_endpoint``."""
metadata = self.load_server_metadata()
resp = self.get(metadata["userinfo_endpoint"], **kwargs)
resp.raise_for_status()
data = resp.json()
return UserInfo(data)
def parse_id_token(
self, token, nonce, claims_options=None, claims_cls=None, leeway=120
):
"""Return an instance of UserInfo from token's ``id_token``."""
if "id_token" not in token:
return None
claims_params = dict(
nonce=nonce,
client_id=self.client_id,
)
if claims_cls is None:
if "access_token" in token:
claims_params["access_token"] = token["access_token"]
claims_cls = CodeIDToken
else:
claims_cls = ImplicitIDToken
metadata = self.load_server_metadata()
if claims_options is None and "issuer" in metadata:
claims_options = {"iss": {"values": [metadata["issuer"]]}}
alg_values = metadata.get("id_token_signing_alg_values_supported")
key_set = KeySet.import_key_set(self.fetch_jwk_set())
try:
token = jwt.decode(
token["id_token"],
key=key_set,
registry=JWSRegistry(algorithms=alg_values, strict_check_header=False),
)
except InvalidKeyIdError:
key_set = KeySet.import_key_set(self.fetch_jwk_set(force=True))
token = jwt.decode(
token["id_token"],
key=key_set,
registry=JWSRegistry(algorithms=alg_values, strict_check_header=False),
)
claims = claims_cls(token.claims, token.header, claims_options, claims_params)
# https://github.com/authlib/authlib/issues/259
if claims.get("nonce_supported") is False:
claims.params["nonce"] = None
claims.validate(leeway=leeway)
return UserInfo(claims)
def create_logout_url(
self,
post_logout_redirect_uri=None,
id_token_hint=None,
state=None,
**kwargs,
):
"""Generate the end session URL for RP-Initiated Logout.
:param post_logout_redirect_uri: URI to redirect after logout.
:param id_token_hint: ID Token previously issued to the RP.
:param state: Opaque value for maintaining state.
:param kwargs: Extra parameters (client_id, logout_hint, ui_locales).
:return: dict with 'url' and 'state' keys.
"""
metadata = self.load_server_metadata()
end_session_endpoint = metadata.get("end_session_endpoint")
if not end_session_endpoint:
raise RuntimeError('Missing "end_session_endpoint" in metadata')
params = {}
if id_token_hint:
params["id_token_hint"] = id_token_hint
if post_logout_redirect_uri:
params["post_logout_redirect_uri"] = post_logout_redirect_uri
if state is None:
state = generate_token(20)
params["state"] = state
for key in ("client_id", "logout_hint", "ui_locales"):
if key in kwargs:
params[key] = kwargs[key]
url = add_params_to_uri(end_session_endpoint, params)
return {"url": url, "state": state}
@@ -0,0 +1,22 @@
from ..base_client import BaseOAuth
from ..base_client import OAuthError
from .apps import DjangoOAuth1App
from .apps import DjangoOAuth2App
from .integration import DjangoIntegration
from .integration import token_update
class OAuth(BaseOAuth):
oauth1_client_cls = DjangoOAuth1App
oauth2_client_cls = DjangoOAuth2App
framework_integration_cls = DjangoIntegration
__all__ = [
"OAuth",
"DjangoOAuth1App",
"DjangoOAuth2App",
"DjangoIntegration",
"token_update",
"OAuthError",
]
@@ -0,0 +1,143 @@
from django.http import HttpResponseRedirect
from ..base_client import BaseApp
from ..base_client import OAuth1Mixin
from ..base_client import OAuth2Mixin
from ..base_client import OAuthError
from ..base_client import OpenIDMixin
from ..requests_client import OAuth1Session
from ..requests_client import OAuth2Session
class DjangoAppMixin:
def save_authorize_data(self, request, **kwargs):
state = kwargs.pop("state", None)
if state:
self.framework.set_state_data(request.session, state, kwargs)
else:
raise RuntimeError("Missing state value")
def authorize_redirect(self, request, redirect_uri=None, **kwargs):
"""Create a HTTP Redirect for Authorization Endpoint.
:param request: HTTP request instance from Django view.
:param redirect_uri: Callback or redirect URI for authorization.
:param kwargs: Extra parameters to include.
:return: A HTTP redirect response.
"""
rv = self.create_authorization_url(redirect_uri, **kwargs)
self.save_authorize_data(request, redirect_uri=redirect_uri, **rv)
return HttpResponseRedirect(rv["url"])
class DjangoOAuth1App(DjangoAppMixin, OAuth1Mixin, BaseApp):
client_cls = OAuth1Session
def authorize_access_token(self, request, **kwargs):
"""Fetch access token in one step.
:param request: HTTP request instance from Django view.
:return: A token dict.
"""
params = request.GET.dict()
state = params.get("oauth_token")
if not state:
raise OAuthError(description='Missing "oauth_token" parameter')
data = self.framework.get_state_data(request.session, state)
if not data:
raise OAuthError(description='Missing "request_token" in temporary data')
params["request_token"] = data["request_token"]
params.update(kwargs)
self.framework.clear_state_data(request.session, state)
return self.fetch_access_token(**params)
class DjangoOAuth2App(DjangoAppMixin, OAuth2Mixin, OpenIDMixin, BaseApp):
client_cls = OAuth2Session
def logout_redirect(
self, request, post_logout_redirect_uri=None, id_token_hint=None, **kwargs
):
"""Create a HTTP Redirect for End Session Endpoint (RP-Initiated Logout).
:param request: HTTP request instance from Django view.
:param post_logout_redirect_uri: URI to redirect after logout.
:param id_token_hint: ID Token previously issued to the RP.
:param kwargs: Extra parameters (state, client_id, logout_hint, ui_locales).
:return: A HTTP redirect response.
"""
result = self.create_logout_url(
post_logout_redirect_uri=post_logout_redirect_uri,
id_token_hint=id_token_hint,
**kwargs,
)
if result.get("state"):
self.framework.set_state_data(
request.session,
result["state"],
{
"post_logout_redirect_uri": post_logout_redirect_uri,
},
)
return HttpResponseRedirect(result["url"])
def validate_logout_response(self, request):
"""Validate the state parameter from the logout callback.
:param request: HTTP request instance from Django view.
:return: The state data dict.
:raises OAuthError: If state is missing or invalid.
"""
state = request.GET.get("state")
if not state:
raise OAuthError(description='Missing "state" parameter')
state_data = self.framework.get_state_data(request.session, state)
if not state_data:
raise OAuthError(description='Invalid "state" parameter')
self.framework.clear_state_data(request.session, state)
return state_data
def authorize_access_token(self, request, **kwargs):
"""Fetch access token in one step.
:param request: HTTP request instance from Django view.
:return: A token dict.
"""
if request.method == "GET":
error = request.GET.get("error")
if error:
description = request.GET.get("error_description")
raise OAuthError(error=error, description=description)
params = {
"code": request.GET.get("code"),
"state": request.GET.get("state"),
}
else:
params = {
"code": request.POST.get("code"),
"state": request.POST.get("state"),
}
state_data = self.framework.get_state_data(request.session, params.get("state"))
self.framework.clear_state_data(request.session, params.get("state"))
params = self._format_state_params(state_data, params)
claims_options = kwargs.pop("claims_options", None)
claims_cls = kwargs.pop("claims_cls", None)
leeway = kwargs.pop("leeway", 120)
token = self.fetch_access_token(**params, **kwargs)
if "id_token" in token and "nonce" in state_data:
userinfo = self.parse_id_token(
token,
nonce=state_data["nonce"],
claims_options=claims_options,
claims_cls=claims_cls,
leeway=leeway,
)
token["userinfo"] = userinfo
return token
@@ -0,0 +1,23 @@
from django.conf import settings
from django.dispatch import Signal
from ..base_client import FrameworkIntegration
token_update = Signal()
class DjangoIntegration(FrameworkIntegration):
def update_token(self, token, refresh_token=None, access_token=None):
token_update.send(
sender=self.__class__,
name=self.name,
token=token,
refresh_token=refresh_token,
access_token=access_token,
)
@staticmethod
def load_config(oauth, name, params):
config = getattr(settings, "AUTHLIB_OAUTH_CLIENTS", None)
if config:
return config.get(name)
@@ -0,0 +1,5 @@
from .authorization_server import BaseServer
from .authorization_server import CacheAuthorizationServer
from .resource_protector import ResourceProtector
__all__ = ["BaseServer", "CacheAuthorizationServer", "ResourceProtector"]
@@ -0,0 +1,128 @@
import logging
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse
from authlib.common.security import generate_token
from authlib.common.urls import url_encode
from authlib.oauth1 import AuthorizationServer as _AuthorizationServer
from authlib.oauth1 import OAuth1Request
from authlib.oauth1 import TemporaryCredential
from .nonce import exists_nonce_in_cache
log = logging.getLogger(__name__)
class BaseServer(_AuthorizationServer):
def __init__(self, client_model, token_model, token_generator=None):
self.client_model = client_model
self.token_model = token_model
if token_generator is None:
def token_generator():
return {
"oauth_token": generate_token(42),
"oauth_token_secret": generate_token(48),
}
self.token_generator = token_generator
self._config = getattr(settings, "AUTHLIB_OAUTH1_PROVIDER", {})
self._nonce_expires_in = self._config.get("nonce_expires_in", 86400)
methods = self._config.get("signature_methods")
if methods:
self.SUPPORTED_SIGNATURE_METHODS = methods
def get_client_by_id(self, client_id):
try:
return self.client_model.objects.get(client_id=client_id)
except self.client_model.DoesNotExist:
return None
def exists_nonce(self, nonce, request):
return exists_nonce_in_cache(nonce, request, self._nonce_expires_in)
def create_token_credential(self, request):
temporary_credential = request.credential
token = self.token_generator()
item = self.token_model(
oauth_token=token["oauth_token"],
oauth_token_secret=token["oauth_token_secret"],
user_id=temporary_credential.get_user_id(),
client_id=temporary_credential.get_client_id(),
)
item.save()
return item
def check_authorization_request(self, request):
req = self.create_oauth1_request(request)
self.validate_authorization_request(req)
return req
def create_oauth1_request(self, request):
if request.method == "POST":
body = request.POST.dict()
else:
body = None
url = request.build_absolute_uri()
return OAuth1Request(request.method, url, body, request.headers)
def handle_response(self, status_code, payload, headers):
resp = HttpResponse(url_encode(payload), status=status_code)
for k, v in headers:
resp[k] = v
return resp
class CacheAuthorizationServer(BaseServer):
def __init__(self, client_model, token_model, token_generator=None):
super().__init__(client_model, token_model, token_generator)
self._temporary_expires_in = self._config.get(
"temporary_credential_expires_in", 86400
)
self._temporary_credential_key_prefix = self._config.get(
"temporary_credential_key_prefix", "temporary_credential:"
)
def create_temporary_credential(self, request):
key_prefix = self._temporary_credential_key_prefix
token = self.token_generator()
client_id = request.client_id
redirect_uri = request.redirect_uri
key = key_prefix + token["oauth_token"]
token["client_id"] = client_id
if redirect_uri:
token["oauth_callback"] = redirect_uri
cache.set(key, token, timeout=self._temporary_expires_in)
return TemporaryCredential(token)
def get_temporary_credential(self, request):
if not request.token:
return None
key_prefix = self._temporary_credential_key_prefix
key = key_prefix + request.token
value = cache.get(key)
if value:
return TemporaryCredential(value)
def delete_temporary_credential(self, request):
if request.token:
key_prefix = self._temporary_credential_key_prefix
key = key_prefix + request.token
cache.delete(key)
def create_authorization_verifier(self, request):
key_prefix = self._temporary_credential_key_prefix
verifier = generate_token(36)
credential = request.credential
user = request.user
key = key_prefix + credential.get_oauth_token()
credential["oauth_verifier"] = verifier
credential["user_id"] = user.pk
cache.set(key, credential, timeout=self._temporary_expires_in)
return verifier
@@ -0,0 +1,15 @@
from django.core.cache import cache
def exists_nonce_in_cache(nonce, request, timeout):
key_prefix = "nonce:"
timestamp = request.timestamp
client_id = request.client_id
token = request.token
key = f"{key_prefix}{nonce}-{timestamp}-{client_id}"
if token:
key = f"{key}-{token}"
rv = bool(cache.get(key))
cache.set(key, 1, timeout=timeout)
return rv
@@ -0,0 +1,70 @@
import functools
from django.conf import settings
from django.http import JsonResponse
from authlib.oauth1 import ResourceProtector as _ResourceProtector
from authlib.oauth1.errors import OAuth1Error
from .nonce import exists_nonce_in_cache
class ResourceProtector(_ResourceProtector):
def __init__(self, client_model, token_model):
self.client_model = client_model
self.token_model = token_model
config = getattr(settings, "AUTHLIB_OAUTH1_PROVIDER", {})
methods = config.get("signature_methods", [])
if methods and isinstance(methods, (list, tuple)):
self.SUPPORTED_SIGNATURE_METHODS = methods
self._nonce_expires_in = config.get("nonce_expires_in", 86400)
def get_client_by_id(self, client_id):
try:
return self.client_model.objects.get(client_id=client_id)
except self.client_model.DoesNotExist:
return None
def get_token_credential(self, request):
try:
return self.token_model.objects.get(
client_id=request.client_id, oauth_token=request.token
)
except self.token_model.DoesNotExist:
return None
def exists_nonce(self, nonce, request):
return exists_nonce_in_cache(nonce, request, self._nonce_expires_in)
def acquire_credential(self, request):
if request.method in ["POST", "PUT"]:
body = request.POST.dict()
else:
body = None
url = request.build_absolute_uri()
req = self.validate_request(request.method, url, body, request.headers)
return req.credential
def __call__(self, realm=None):
def decorator(f):
@functools.wraps(f)
def decorated(request, *args, **kwargs):
try:
credential = self.acquire_credential(request)
request.oauth1_credential = credential
except OAuth1Error as error:
body = dict(error.get_body())
resp = JsonResponse(body, status=error.status_code)
resp["Cache-Control"] = "no-store"
resp["Pragma"] = "no-cache"
return resp
return f(request, *args, **kwargs)
return decorated
if callable(realm):
return decorator(realm)
return decorator
@@ -0,0 +1,9 @@
# flake8: noqa
from .authorization_server import AuthorizationServer
from .endpoints import RevocationEndpoint
from .resource_protector import BearerTokenValidator
from .resource_protector import ResourceProtector
from .signals import client_authenticated
from .signals import token_authenticated
from .signals import token_revoked
@@ -0,0 +1,122 @@
from django.conf import settings
from django.http import HttpResponse
from django.utils.module_loading import import_string
from authlib.common.encoding import json_dumps
from authlib.common.security import generate_token as _generate_token
from authlib.oauth2 import AuthorizationServer as _AuthorizationServer
from authlib.oauth2.rfc6750 import BearerTokenGenerator
from .requests import DjangoJsonRequest
from .requests import DjangoOAuth2Request
from .signals import client_authenticated
from .signals import token_revoked
class AuthorizationServer(_AuthorizationServer):
"""Django implementation of :class:`authlib.oauth2.rfc6749.AuthorizationServer`.
Initialize it with client model and token model::
from authlib.integrations.django_oauth2 import AuthorizationServer
from your_project.models import OAuth2Client, OAuth2Token
server = AuthorizationServer(OAuth2Client, OAuth2Token)
"""
def __init__(self, client_model, token_model):
super().__init__()
self.client_model = client_model
self.token_model = token_model
self.load_config(getattr(settings, "AUTHLIB_OAUTH2_PROVIDER", {}))
def load_config(self, config):
self.config = config
scopes_supported = self.config.get("scopes_supported")
self.scopes_supported = scopes_supported
# add default token generator
self.register_token_generator("default", self.create_bearer_token_generator())
def query_client(self, client_id):
"""Default method for ``AuthorizationServer.query_client``. Developers MAY
rewrite this function to meet their own needs.
"""
try:
return self.client_model.objects.get(client_id=client_id)
except self.client_model.DoesNotExist:
return None
def save_token(self, token, request):
"""Default method for ``AuthorizationServer.save_token``. Developers MAY
rewrite this function to meet their own needs.
"""
client = request.client
if request.user:
user_id = request.user.pk
else:
user_id = client.user_id
item = self.token_model(client_id=client.client_id, user_id=user_id, **token)
item.save()
return item
def create_oauth2_request(self, request):
return DjangoOAuth2Request(request)
def create_json_request(self, request):
return DjangoJsonRequest(request)
def handle_response(self, status_code, payload, headers):
if isinstance(payload, dict):
payload = json_dumps(payload)
resp = HttpResponse(payload, status=status_code)
for k, v in headers:
resp[k] = v
return resp
def send_signal(self, name, *args, **kwargs):
if name == "after_authenticate_client":
client_authenticated.send(*args, sender=self.__class__, **kwargs)
elif name == "after_revoke_token":
token_revoked.send(*args, sender=self.__class__, **kwargs)
def create_bearer_token_generator(self):
"""Default method to create BearerToken generator."""
conf = self.config.get("access_token_generator", True)
access_token_generator = create_token_generator(conf, 42)
conf = self.config.get("refresh_token_generator", False)
refresh_token_generator = create_token_generator(conf, 48)
conf = self.config.get("token_expires_in")
expires_generator = create_token_expires_in_generator(conf)
return BearerTokenGenerator(
access_token_generator=access_token_generator,
refresh_token_generator=refresh_token_generator,
expires_generator=expires_generator,
)
def create_token_generator(token_generator_conf, length=42):
if callable(token_generator_conf):
return token_generator_conf
if isinstance(token_generator_conf, str):
return import_string(token_generator_conf)
elif token_generator_conf is True:
def token_generator(*args, **kwargs):
return _generate_token(length)
return token_generator
def create_token_expires_in_generator(expires_in_conf=None):
data = {}
data.update(BearerTokenGenerator.GRANT_TYPES_EXPIRES_IN)
if expires_in_conf:
data.update(expires_in_conf)
def expires_in(client, grant_type):
return data.get(grant_type, BearerTokenGenerator.DEFAULT_EXPIRES_IN)
return expires_in
@@ -0,0 +1,56 @@
from authlib.oauth2.rfc7009 import RevocationEndpoint as _RevocationEndpoint
class RevocationEndpoint(_RevocationEndpoint):
"""The revocation endpoint for OAuth authorization servers allows clients
to notify the authorization server that a previously obtained refresh or
access token is no longer needed.
Register it into authorization server, and create token endpoint response
for token revocation::
from django.views.decorators.http import require_http_methods
# see register into authorization server instance
server.register_endpoint(RevocationEndpoint)
@require_http_methods(["POST"])
def revoke_token(request):
return server.create_endpoint_response(
RevocationEndpoint.ENDPOINT_NAME, request
)
"""
def query_token(self, token, token_type_hint):
"""Query requested token from database."""
token_model = self.server.token_model
if token_type_hint == "access_token":
rv = _query_access_token(token_model, token)
elif token_type_hint == "refresh_token":
rv = _query_refresh_token(token_model, token)
else:
rv = _query_access_token(token_model, token)
if not rv:
rv = _query_refresh_token(token_model, token)
return rv
def revoke_token(self, token, request):
"""Mark the give token as revoked."""
token.revoked = True
token.save()
def _query_access_token(token_model, token):
try:
return token_model.objects.get(access_token=token)
except token_model.DoesNotExist:
return None
def _query_refresh_token(token_model, token):
try:
return token_model.objects.get(refresh_token=token)
except token_model.DoesNotExist:
return None
@@ -0,0 +1,65 @@
from collections import defaultdict
from django.http import HttpRequest
from django.utils.functional import cached_property
from authlib.common.encoding import json_loads
from authlib.oauth2.rfc6749 import JsonPayload
from authlib.oauth2.rfc6749 import JsonRequest
from authlib.oauth2.rfc6749 import OAuth2Payload
from authlib.oauth2.rfc6749 import OAuth2Request
class DjangoOAuth2Payload(OAuth2Payload):
def __init__(self, request: HttpRequest):
self._request = request
@cached_property
def data(self):
data = {}
data.update(self._request.GET.dict())
data.update(self._request.POST.dict())
return data
@cached_property
def datalist(self):
values = defaultdict(list)
for k in self._request.GET:
values[k].extend(self._request.GET.getlist(k))
for k in self._request.POST:
values[k].extend(self._request.POST.getlist(k))
return values
class DjangoOAuth2Request(OAuth2Request):
def __init__(self, request: HttpRequest):
super().__init__(
method=request.method,
uri=request.build_absolute_uri(),
headers=request.headers,
)
self.payload = DjangoOAuth2Payload(request)
self._request = request
@property
def args(self):
return self._request.GET
@property
def form(self):
return self._request.POST
class DjangoJsonPayload(JsonPayload):
def __init__(self, request: HttpRequest):
self._request = request
@cached_property
def data(self):
return json_loads(self._request.body)
class DjangoJsonRequest(JsonRequest):
def __init__(self, request: HttpRequest):
super().__init__(request.method, request.build_absolute_uri(), request.headers)
self.payload = DjangoJsonPayload(request)
@@ -0,0 +1,76 @@
import functools
from django.http import JsonResponse
from authlib.oauth2 import OAuth2Error
from authlib.oauth2 import ResourceProtector as _ResourceProtector
from authlib.oauth2.rfc6749 import MissingAuthorizationError
from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator
from .requests import DjangoJsonRequest
from .signals import token_authenticated
class ResourceProtector(_ResourceProtector):
def acquire_token(self, request, scopes=None, **kwargs):
"""A method to acquire current valid token with the given scope.
:param request: Django HTTP request instance
:param scopes: a list of scope values
:return: token object
"""
req = DjangoJsonRequest(request)
# backward compatibility
kwargs["scopes"] = scopes
for claim in kwargs:
if isinstance(kwargs[claim], str):
kwargs[claim] = [kwargs[claim]]
token = self.validate_request(request=req, **kwargs)
token_authenticated.send(sender=self.__class__, token=token)
return token
def __call__(self, scopes=None, optional=False, **kwargs):
claims = kwargs
claims["scopes"] = scopes if not callable(scopes) else None
def decorator(f):
@functools.wraps(f)
def decorated(request, *args, **kwargs):
try:
token = self.acquire_token(request, **claims)
request.oauth_token = token
except MissingAuthorizationError as error:
if optional:
request.oauth_token = None
return f(request, *args, **kwargs)
return return_error_response(error)
except OAuth2Error as error:
return return_error_response(error)
return f(request, *args, **kwargs)
return decorated
if callable(scopes):
return decorator(scopes)
return decorator
class BearerTokenValidator(_BearerTokenValidator):
def __init__(self, token_model, realm=None, **extra_attributes):
self.token_model = token_model
super().__init__(realm, **extra_attributes)
def authenticate_token(self, token_string):
try:
return self.token_model.objects.get(access_token=token_string)
except self.token_model.DoesNotExist:
return None
def return_error_response(error):
body = dict(error.get_body())
resp = JsonResponse(body, status=error.status_code)
headers = error.get_headers()
for k, v in headers:
resp[k] = v
return resp
@@ -0,0 +1,10 @@
from django.dispatch import Signal
#: signal when client is authenticated
client_authenticated = Signal()
#: signal when token is revoked
token_revoked = Signal()
#: signal when token is authenticated
token_authenticated = Signal()
@@ -0,0 +1,59 @@
from werkzeug.local import LocalProxy
from ..base_client import BaseOAuth
from ..base_client import OAuthError
from .apps import FlaskOAuth1App
from .apps import FlaskOAuth2App
from .integration import FlaskIntegration
from .integration import token_update
class OAuth(BaseOAuth):
oauth1_client_cls = FlaskOAuth1App
oauth2_client_cls = FlaskOAuth2App
framework_integration_cls = FlaskIntegration
def __init__(self, app=None, cache=None, fetch_token=None, update_token=None):
super().__init__(
cache=cache, fetch_token=fetch_token, update_token=update_token
)
self.app = app
if app:
self.init_app(app)
def init_app(self, app, cache=None, fetch_token=None, update_token=None):
"""Initialize lazy for Flask app. This is usually used for Flask application
factory pattern.
"""
self.app = app
if cache is not None:
self.cache = cache
if fetch_token:
self.fetch_token = fetch_token
if update_token:
self.update_token = update_token
app.extensions = getattr(app, "extensions", {})
app.extensions["authlib.integrations.flask_client"] = self
def create_client(self, name):
if not self.app:
raise RuntimeError("OAuth is not init with Flask app.")
return super().create_client(name)
def register(self, name, overwrite=False, **kwargs):
self._registry[name] = (overwrite, kwargs)
if self.app:
return self.create_client(name)
return LocalProxy(lambda: self.create_client(name))
__all__ = [
"OAuth",
"FlaskIntegration",
"FlaskOAuth1App",
"FlaskOAuth2App",
"token_update",
"OAuthError",
]
@@ -0,0 +1,164 @@
from flask import g
from flask import redirect
from flask import request
from flask import session
from ..base_client import BaseApp
from ..base_client import OAuth1Mixin
from ..base_client import OAuth2Mixin
from ..base_client import OAuthError
from ..base_client import OpenIDMixin
from ..requests_client import OAuth1Session
from ..requests_client import OAuth2Session
class FlaskAppMixin:
@property
def token(self):
attr = f"_oauth_token_{self.name}"
token = g.get(attr)
if token:
return token
if self._fetch_token:
token = self._fetch_token()
self.token = token
return token
@token.setter
def token(self, token):
attr = f"_oauth_token_{self.name}"
setattr(g, attr, token)
def _get_requested_token(self, *args, **kwargs):
return self.token
def save_authorize_data(self, **kwargs):
state = kwargs.pop("state", None)
if state:
self.framework.set_state_data(session, state, kwargs)
else:
raise RuntimeError("Missing state value")
def authorize_redirect(self, redirect_uri=None, **kwargs):
"""Create a HTTP Redirect for Authorization Endpoint.
:param redirect_uri: Callback or redirect URI for authorization.
:param kwargs: Extra parameters to include.
:return: A HTTP redirect response.
"""
rv = self.create_authorization_url(redirect_uri, **kwargs)
self.save_authorize_data(redirect_uri=redirect_uri, **rv)
return redirect(rv["url"])
class FlaskOAuth1App(FlaskAppMixin, OAuth1Mixin, BaseApp):
client_cls = OAuth1Session
def authorize_access_token(self, **kwargs):
"""Fetch access token in one step.
:return: A token dict.
"""
params = request.args.to_dict(flat=True)
state = params.get("oauth_token")
if not state:
raise OAuthError(description='Missing "oauth_token" parameter')
data = self.framework.get_state_data(session, state)
if not data:
raise OAuthError(description='Missing "request_token" in temporary data')
params["request_token"] = data["request_token"]
params.update(kwargs)
self.framework.clear_state_data(session, state)
token = self.fetch_access_token(**params)
self.token = token
return token
class FlaskOAuth2App(FlaskAppMixin, OAuth2Mixin, OpenIDMixin, BaseApp):
client_cls = OAuth2Session
def logout_redirect(
self, post_logout_redirect_uri=None, id_token_hint=None, **kwargs
):
"""Create a HTTP Redirect for End Session Endpoint (RP-Initiated Logout).
:param post_logout_redirect_uri: URI to redirect after logout.
:param id_token_hint: ID Token previously issued to the RP.
:param kwargs: Extra parameters (state, client_id, logout_hint, ui_locales).
:return: A HTTP redirect response.
"""
result = self.create_logout_url(
post_logout_redirect_uri=post_logout_redirect_uri,
id_token_hint=id_token_hint,
**kwargs,
)
if result.get("state"):
self.framework.set_state_data(
session,
result["state"],
{
"post_logout_redirect_uri": post_logout_redirect_uri,
},
)
return redirect(result["url"])
def validate_logout_response(self):
"""Validate the state parameter from the logout callback.
:return: The state data dict.
:raises OAuthError: If state is missing or invalid.
"""
state = request.args.get("state")
if not state:
raise OAuthError(description='Missing "state" parameter')
state_data = self.framework.get_state_data(session, state)
if not state_data:
raise OAuthError(description='Invalid "state" parameter')
self.framework.clear_state_data(session, state)
return state_data
def authorize_access_token(self, **kwargs):
"""Fetch access token in one step.
:return: A token dict.
"""
if request.method == "GET":
error = request.args.get("error")
if error:
description = request.args.get("error_description")
raise OAuthError(error=error, description=description)
params = {
"code": request.args.get("code"),
"state": request.args.get("state"),
}
else:
params = {
"code": request.form.get("code"),
"state": request.form.get("state"),
}
state_data = self.framework.get_state_data(session, params.get("state"))
self.framework.clear_state_data(session, params.get("state"))
params = self._format_state_params(state_data, params)
claims_options = kwargs.pop("claims_options", None)
claims_cls = kwargs.pop("claims_cls", None)
leeway = kwargs.pop("leeway", 120)
token = self.fetch_access_token(**params, **kwargs)
self.token = token
if "id_token" in token and "nonce" in state_data:
userinfo = self.parse_id_token(
token,
nonce=state_data["nonce"],
claims_options=claims_options,
claims_cls=claims_cls,
leeway=leeway,
)
token["userinfo"] = userinfo
return token
@@ -0,0 +1,29 @@
from flask import current_app
from flask.signals import Namespace
from ..base_client import FrameworkIntegration
_signal = Namespace()
#: signal when token is updated
token_update = _signal.signal("token_update")
class FlaskIntegration(FrameworkIntegration):
def update_token(self, token, refresh_token=None, access_token=None):
token_update.send(
current_app._get_current_object(),
name=self.name,
token=token,
refresh_token=refresh_token,
access_token=access_token,
)
@staticmethod
def load_config(oauth, name, params):
rv = {}
for k in params:
conf_key = f"{name}_{k}".upper()
v = oauth.app.config.get(conf_key, None)
if v is not None:
rv[k] = v
return rv
@@ -0,0 +1,8 @@
# flake8: noqa
from .authorization_server import AuthorizationServer
from .cache import create_exists_nonce_func
from .cache import register_nonce_hooks
from .cache import register_temporary_credential_hooks
from .resource_protector import ResourceProtector
from .resource_protector import current_credential
@@ -0,0 +1,168 @@
import logging
from flask import Response
from flask import request as flask_req
from werkzeug.utils import import_string
from authlib.common.security import generate_token
from authlib.common.urls import url_encode
from authlib.oauth1 import AuthorizationServer as _AuthorizationServer
from authlib.oauth1 import OAuth1Request
log = logging.getLogger(__name__)
class AuthorizationServer(_AuthorizationServer):
"""Flask implementation of :class:`authlib.rfc5849.AuthorizationServer`.
Initialize it with Flask app instance, client model class and cache::
server = AuthorizationServer(app=app, query_client=query_client)
# or initialize lazily
server = AuthorizationServer()
server.init_app(app, query_client=query_client)
:param app: A Flask app instance
:param query_client: A function to get client by client_id. The client
model class MUST implement the methods described by
:class:`~authlib.oauth1.rfc5849.ClientMixin`.
:param token_generator: A function to generate token
"""
def __init__(self, app=None, query_client=None, token_generator=None):
self.app = app
self.query_client = query_client
self.token_generator = token_generator
self._hooks = {
"exists_nonce": None,
"create_temporary_credential": None,
"get_temporary_credential": None,
"delete_temporary_credential": None,
"create_authorization_verifier": None,
"create_token_credential": None,
}
if app is not None:
self.init_app(app)
def init_app(self, app, query_client=None, token_generator=None):
if query_client is not None:
self.query_client = query_client
if token_generator is not None:
self.token_generator = token_generator
if self.token_generator is None:
self.token_generator = self.create_token_generator(app)
methods = app.config.get("OAUTH1_SUPPORTED_SIGNATURE_METHODS")
if methods and isinstance(methods, (list, tuple)):
self.SUPPORTED_SIGNATURE_METHODS = methods
self.app = app
def register_hook(self, name, func):
if name not in self._hooks:
raise ValueError('Invalid "name" of hook')
self._hooks[name] = func
def create_token_generator(self, app):
token_generator = app.config.get("OAUTH1_TOKEN_GENERATOR")
if isinstance(token_generator, str):
token_generator = import_string(token_generator)
else:
length = app.config.get("OAUTH1_TOKEN_LENGTH", 42)
def token_generator():
return generate_token(length)
secret_generator = app.config.get("OAUTH1_TOKEN_SECRET_GENERATOR")
if isinstance(secret_generator, str):
secret_generator = import_string(secret_generator)
else:
length = app.config.get("OAUTH1_TOKEN_SECRET_LENGTH", 48)
def secret_generator():
return generate_token(length)
def create_token():
return {
"oauth_token": token_generator(),
"oauth_token_secret": secret_generator(),
}
return create_token
def get_client_by_id(self, client_id):
return self.query_client(client_id)
def exists_nonce(self, nonce, request):
func = self._hooks["exists_nonce"]
if callable(func):
timestamp = request.timestamp
client_id = request.client_id
token = request.token
return func(nonce, timestamp, client_id, token)
raise RuntimeError('"exists_nonce" hook is required.')
def create_temporary_credential(self, request):
func = self._hooks["create_temporary_credential"]
if callable(func):
token = self.token_generator()
return func(token, request.client_id, request.redirect_uri)
raise RuntimeError('"create_temporary_credential" hook is required.')
def get_temporary_credential(self, request):
func = self._hooks["get_temporary_credential"]
if callable(func):
return func(request.token)
raise RuntimeError('"get_temporary_credential" hook is required.')
def delete_temporary_credential(self, request):
func = self._hooks["delete_temporary_credential"]
if callable(func):
return func(request.token)
raise RuntimeError('"delete_temporary_credential" hook is required.')
def create_authorization_verifier(self, request):
func = self._hooks["create_authorization_verifier"]
if callable(func):
verifier = generate_token(36)
func(request.credential, request.user, verifier)
return verifier
raise RuntimeError('"create_authorization_verifier" hook is required.')
def create_token_credential(self, request):
func = self._hooks["create_token_credential"]
if callable(func):
temporary_credential = request.credential
token = self.token_generator()
return func(token, temporary_credential)
raise RuntimeError('"create_token_credential" hook is required.')
def check_authorization_request(self):
req = self.create_oauth1_request(None)
self.validate_authorization_request(req)
return req
def create_authorization_response(self, request=None, grant_user=None):
return super().create_authorization_response(request, grant_user)
def create_token_response(self, request=None):
return super().create_token_response(request)
def create_oauth1_request(self, request):
if request is None:
request = flask_req
if request.method in ("POST", "PUT"):
body = request.form.to_dict(flat=True)
else:
body = None
return OAuth1Request(request.method, request.url, body, request.headers)
def handle_response(self, status_code, payload, headers):
return Response(url_encode(payload), status=status_code, headers=headers)
@@ -0,0 +1,88 @@
from authlib.oauth1 import TemporaryCredential
def register_temporary_credential_hooks(
authorization_server, cache, key_prefix="temporary_credential:"
):
"""Register temporary credential related hooks to authorization server.
:param authorization_server: AuthorizationServer instance
:param cache: Cache instance
:param key_prefix: key prefix for temporary credential
"""
def create_temporary_credential(token, client_id, redirect_uri):
key = key_prefix + token["oauth_token"]
token["client_id"] = client_id
if redirect_uri:
token["oauth_callback"] = redirect_uri
cache.set(key, token, timeout=86400) # cache for one day
return TemporaryCredential(token)
def get_temporary_credential(oauth_token):
if not oauth_token:
return None
key = key_prefix + oauth_token
value = cache.get(key)
if value:
return TemporaryCredential(value)
def delete_temporary_credential(oauth_token):
if oauth_token:
key = key_prefix + oauth_token
cache.delete(key)
def create_authorization_verifier(credential, grant_user, verifier):
key = key_prefix + credential.get_oauth_token()
credential["oauth_verifier"] = verifier
credential["user_id"] = grant_user.get_user_id()
cache.set(key, credential, timeout=86400)
return credential
authorization_server.register_hook(
"create_temporary_credential", create_temporary_credential
)
authorization_server.register_hook(
"get_temporary_credential", get_temporary_credential
)
authorization_server.register_hook(
"delete_temporary_credential", delete_temporary_credential
)
authorization_server.register_hook(
"create_authorization_verifier", create_authorization_verifier
)
def create_exists_nonce_func(cache, key_prefix="nonce:", expires=86400):
"""Create an ``exists_nonce`` function that can be used in hooks and
resource protector.
:param cache: Cache instance
:param key_prefix: key prefix for temporary credential
:param expires: Expire time for nonce
"""
def exists_nonce(nonce, timestamp, client_id, oauth_token):
key = f"{key_prefix}{nonce}-{timestamp}-{client_id}"
if oauth_token:
key = f"{key}-{oauth_token}"
rv = cache.has(key)
cache.set(key, 1, timeout=expires)
return rv
return exists_nonce
def register_nonce_hooks(
authorization_server, cache, key_prefix="nonce:", expires=86400
):
"""Register nonce related hooks to authorization server.
:param authorization_server: AuthorizationServer instance
:param cache: Cache instance
:param key_prefix: key prefix for temporary credential
:param expires: Expire time for nonce
"""
exists_nonce = create_exists_nonce_func(cache, key_prefix, expires)
authorization_server.register_hook("exists_nonce", exists_nonce)
@@ -0,0 +1,123 @@
import functools
from flask import Response
from flask import g
from flask import json
from flask import request as _req
from werkzeug.local import LocalProxy
from authlib.consts import default_json_headers
from authlib.oauth1 import ResourceProtector as _ResourceProtector
from authlib.oauth1.errors import OAuth1Error
class ResourceProtector(_ResourceProtector):
"""A protecting method for resource servers. Initialize a resource
protector with the these method:
1. query_client
2. query_token,
3. exists_nonce
Usually, a ``query_client`` method would look like (if using SQLAlchemy)::
def query_client(client_id):
return Client.query.filter_by(client_id=client_id).first()
A ``query_token`` method accept two parameters, ``client_id`` and ``oauth_token``::
def query_token(client_id, oauth_token):
return Token.query.filter_by(
client_id=client_id, oauth_token=oauth_token
).first()
And for ``exists_nonce``, if using cache, we have a built-in hook to create this method::
from authlib.integrations.flask_oauth1 import create_exists_nonce_func
exists_nonce = create_exists_nonce_func(cache)
Then initialize the resource protector with those methods::
require_oauth = ResourceProtector(
app,
query_client=query_client,
query_token=query_token,
exists_nonce=exists_nonce,
)
"""
def __init__(
self, app=None, query_client=None, query_token=None, exists_nonce=None
):
self.query_client = query_client
self.query_token = query_token
self._exists_nonce = exists_nonce
self.app = app
if app:
self.init_app(app)
def init_app(self, app, query_client=None, query_token=None, exists_nonce=None):
if query_client is not None:
self.query_client = query_client
if query_token is not None:
self.query_token = query_token
if exists_nonce is not None:
self._exists_nonce = exists_nonce
methods = app.config.get("OAUTH1_SUPPORTED_SIGNATURE_METHODS")
if methods and isinstance(methods, (list, tuple)):
self.SUPPORTED_SIGNATURE_METHODS = methods
self.app = app
def get_client_by_id(self, client_id):
return self.query_client(client_id)
def get_token_credential(self, request):
return self.query_token(request.client_id, request.token)
def exists_nonce(self, nonce, request):
if not self._exists_nonce:
raise RuntimeError('"exists_nonce" function is required.')
timestamp = request.timestamp
client_id = request.client_id
token = request.token
return self._exists_nonce(nonce, timestamp, client_id, token)
def acquire_credential(self):
req = self.validate_request(
_req.method, _req.url, _req.form.to_dict(flat=True), _req.headers
)
g.authlib_server_oauth1_credential = req.credential
return req.credential
def __call__(self, scope=None):
def decorator(f):
@functools.wraps(f)
def decorated(*args, **kwargs):
try:
self.acquire_credential()
except OAuth1Error as error:
body = dict(error.get_body())
return Response(
json.dumps(body),
status=error.status_code,
headers=default_json_headers,
)
return f(*args, **kwargs)
return decorated
if callable(scope):
return decorator(scope)
return decorator
def _get_current_credential():
return g.get("authlib_server_oauth1_credential")
current_credential = LocalProxy(_get_current_credential)
@@ -0,0 +1,8 @@
# flake8: noqa
from .authorization_server import AuthorizationServer
from .resource_protector import ResourceProtector
from .resource_protector import current_token
from .signals import client_authenticated
from .signals import token_authenticated
from .signals import token_revoked
@@ -0,0 +1,165 @@
from flask import Response
from flask import json
from flask import request as flask_req
from werkzeug.utils import import_string
from authlib.common.security import generate_token
from authlib.oauth2 import AuthorizationServer as _AuthorizationServer
from authlib.oauth2.rfc6750 import BearerTokenGenerator
from .requests import FlaskJsonRequest
from .requests import FlaskOAuth2Request
from .signals import client_authenticated
from .signals import token_revoked
class AuthorizationServer(_AuthorizationServer):
"""Flask implementation of :class:`authlib.oauth2.rfc6749.AuthorizationServer`.
Initialize it with ``query_client``, ``save_token`` methods and Flask
app instance::
def query_client(client_id):
return Client.query.filter_by(client_id=client_id).first()
def save_token(token, request):
if request.user:
user_id = request.user.id
else:
user_id = None
client = request.client
tok = Token(client_id=client.client_id, user_id=user.id, **token)
db.session.add(tok)
db.session.commit()
server = AuthorizationServer(app, query_client, save_token)
# or initialize lazily
server = AuthorizationServer()
server.init_app(app, query_client, save_token)
"""
def __init__(self, app=None, query_client=None, save_token=None):
super().__init__()
self._query_client = query_client
self._save_token = save_token
self._error_uris = None
if app is not None:
self.init_app(app)
def init_app(self, app, query_client=None, save_token=None):
"""Initialize later with Flask app instance."""
if query_client is not None:
self._query_client = query_client
if save_token is not None:
self._save_token = save_token
self.load_config(app.config)
def load_config(self, config):
self.register_token_generator(
"default", self.create_bearer_token_generator(config)
)
self.scopes_supported = config.get("OAUTH2_SCOPES_SUPPORTED")
self._error_uris = config.get("OAUTH2_ERROR_URIS")
def query_client(self, client_id):
return self._query_client(client_id)
def save_token(self, token, request):
return self._save_token(token, request)
def get_error_uri(self, request, error):
if self._error_uris:
uris = dict(self._error_uris)
return uris.get(error.error)
def create_oauth2_request(self, request):
return FlaskOAuth2Request(flask_req)
def create_json_request(self, request):
return FlaskJsonRequest(flask_req)
def handle_response(self, status_code, payload, headers):
if isinstance(payload, dict):
payload = json.dumps(payload)
return Response(payload, status=status_code, headers=headers)
def send_signal(self, name, *args, **kwargs):
if name == "after_authenticate_client":
client_authenticated.send(self, *args, **kwargs)
elif name == "after_revoke_token":
token_revoked.send(self, *args, **kwargs)
def create_bearer_token_generator(self, config):
"""Create a generator function for generating ``token`` value. This
method will create a Bearer Token generator with
:class:`authlib.oauth2.rfc6750.BearerToken`.
Configurable settings:
1. OAUTH2_ACCESS_TOKEN_GENERATOR: Boolean or import string, default is True.
2. OAUTH2_REFRESH_TOKEN_GENERATOR: Boolean or import string, default is False.
3. OAUTH2_TOKEN_EXPIRES_IN: Dict or import string, default is None.
By default, it will not generate ``refresh_token``, which can be turn on by
configure ``OAUTH2_REFRESH_TOKEN_GENERATOR``.
Here are some examples of the token generator::
OAUTH2_ACCESS_TOKEN_GENERATOR = "your_project.generators.gen_token"
# and in module `your_project.generators`, you can define:
def gen_token(client, grant_type, user, scope):
# generate token according to these parameters
token = create_random_token()
return f"{client.id}-{user.id}-{token}"
Here is an example of ``OAUTH2_TOKEN_EXPIRES_IN``::
OAUTH2_TOKEN_EXPIRES_IN = {
"authorization_code": 864000,
"urn:ietf:params:oauth:grant-type:jwt-bearer": 3600,
}
"""
conf = config.get("OAUTH2_ACCESS_TOKEN_GENERATOR", True)
access_token_generator = create_token_generator(conf, 42)
conf = config.get("OAUTH2_REFRESH_TOKEN_GENERATOR", False)
refresh_token_generator = create_token_generator(conf, 48)
expires_conf = config.get("OAUTH2_TOKEN_EXPIRES_IN")
expires_generator = create_token_expires_in_generator(expires_conf)
return BearerTokenGenerator(
access_token_generator, refresh_token_generator, expires_generator
)
def create_token_expires_in_generator(expires_in_conf=None):
if isinstance(expires_in_conf, str):
return import_string(expires_in_conf)
data = {}
data.update(BearerTokenGenerator.GRANT_TYPES_EXPIRES_IN)
if isinstance(expires_in_conf, dict):
data.update(expires_in_conf)
def expires_in(client, grant_type):
return data.get(grant_type, BearerTokenGenerator.DEFAULT_EXPIRES_IN)
return expires_in
def create_token_generator(token_generator_conf, length=42):
if callable(token_generator_conf):
return token_generator_conf
if isinstance(token_generator_conf, str):
return import_string(token_generator_conf)
elif token_generator_conf is True:
def token_generator(*args, **kwargs):
return generate_token(length)
return token_generator
@@ -0,0 +1,40 @@
import importlib.metadata
from werkzeug.exceptions import HTTPException
_version = importlib.metadata.version("werkzeug").split(".")[0]
if _version in ("0", "1"):
class _HTTPException(HTTPException):
def __init__(self, code, body, headers, response=None):
super().__init__(None, response)
self.code = code
self.body = body
self.headers = headers
def get_body(self, environ=None):
return self.body
def get_headers(self, environ=None):
return self.headers
else:
class _HTTPException(HTTPException):
def __init__(self, code, body, headers, response=None):
super().__init__(None, response)
self.code = code
self.body = body
self.headers = headers
def get_body(self, environ=None, scope=None):
return self.body
def get_headers(self, environ=None, scope=None):
return self.headers
def raise_http_exception(status, body, headers):
raise _HTTPException(status, body, headers)
@@ -0,0 +1,57 @@
from collections import defaultdict
from functools import cached_property
from flask.wrappers import Request
from authlib.oauth2.rfc6749 import JsonPayload
from authlib.oauth2.rfc6749 import JsonRequest
from authlib.oauth2.rfc6749 import OAuth2Payload
from authlib.oauth2.rfc6749 import OAuth2Request
class FlaskOAuth2Payload(OAuth2Payload):
def __init__(self, request: Request):
self._request = request
@property
def data(self):
return self._request.values
@cached_property
def datalist(self):
values = defaultdict(list)
for k in self.data:
values[k].extend(self.data.getlist(k))
return values
class FlaskOAuth2Request(OAuth2Request):
def __init__(self, request: Request):
super().__init__(
method=request.method, uri=request.url, headers=request.headers
)
self._request = request
self.payload = FlaskOAuth2Payload(request)
@property
def args(self):
return self._request.args
@property
def form(self):
return self._request.form
class FlaskJsonPayload(JsonPayload):
def __init__(self, request: Request):
self._request = request
@property
def data(self):
return self._request.get_json()
class FlaskJsonRequest(JsonRequest):
def __init__(self, request: Request):
super().__init__(request.method, request.url, request.headers)
self.payload = FlaskJsonPayload(request)
@@ -0,0 +1,122 @@
import functools
from contextlib import contextmanager
from flask import g
from flask import json
from flask import request as _req
from werkzeug.local import LocalProxy
from authlib.oauth2 import OAuth2Error
from authlib.oauth2 import ResourceProtector as _ResourceProtector
from authlib.oauth2.rfc6749 import MissingAuthorizationError
from .errors import raise_http_exception
from .requests import FlaskJsonRequest
from .signals import token_authenticated
class ResourceProtector(_ResourceProtector):
"""A protecting method for resource servers. Creating a ``require_oauth``
decorator easily with ResourceProtector::
from authlib.integrations.flask_oauth2 import ResourceProtector
require_oauth = ResourceProtector()
# add bearer token validator
from authlib.oauth2.rfc6750 import BearerTokenValidator
from project.models import Token
class MyBearerTokenValidator(BearerTokenValidator):
def authenticate_token(self, token_string):
return Token.query.filter_by(access_token=token_string).first()
require_oauth.register_token_validator(MyBearerTokenValidator())
# protect resource with require_oauth
@app.route("/user")
@require_oauth(["profile"])
def user_profile():
user = User.get(current_token.user_id)
return jsonify(user.to_dict())
"""
def raise_error_response(self, error):
"""Raise HTTPException for OAuth2Error. Developers can re-implement
this method to customize the error response.
:param error: OAuth2Error
:raise: HTTPException
"""
status = error.status_code
body = json.dumps(dict(error.get_body()))
headers = error.get_headers()
raise_http_exception(status, body, headers)
def acquire_token(self, scopes=None, **kwargs):
"""A method to acquire current valid token with the given scope.
:param scopes: a list of scope values
:return: token object
"""
request = FlaskJsonRequest(_req)
# backward compatibility
kwargs["scopes"] = scopes
for claim in kwargs:
if isinstance(kwargs[claim], str):
kwargs[claim] = [kwargs[claim]]
token = self.validate_request(request=request, **kwargs)
token_authenticated.send(self, token=token)
g.authlib_server_oauth2_token = token
return token
@contextmanager
def acquire(self, scopes=None):
"""The with statement of ``require_oauth``. Instead of using a
decorator, you can use a with statement instead::
@app.route("/api/user")
def user_api():
with require_oauth.acquire("profile") as token:
user = User.get(token.user_id)
return jsonify(user.to_dict())
"""
try:
yield self.acquire_token(scopes)
except OAuth2Error as error:
self.raise_error_response(error)
def __call__(self, scopes=None, optional=False, **kwargs):
claims = kwargs
claims["scopes"] = scopes if not callable(scopes) else None
def decorator(f):
@functools.wraps(f)
def decorated(*args, **kwargs):
try:
self.acquire_token(**claims)
except MissingAuthorizationError as error:
if optional:
return f(*args, **kwargs)
self.raise_error_response(error)
except OAuth2Error as error:
self.raise_error_response(error)
return f(*args, **kwargs)
return decorated
if callable(scopes):
return decorator(scopes)
return decorator
def _get_current_token():
return g.get("authlib_server_oauth2_token")
current_token = LocalProxy(_get_current_token)
@@ -0,0 +1,12 @@
from flask.signals import Namespace
_signal = Namespace()
#: signal when client is authenticated
client_authenticated = _signal.signal("client_authenticated")
#: signal when token is revoked
token_revoked = _signal.signal("token_revoked")
#: signal when token is authenticated
token_authenticated = _signal.signal("token_authenticated")
@@ -0,0 +1,36 @@
from authlib.oauth1 import SIGNATURE_HMAC_SHA1
from authlib.oauth1 import SIGNATURE_PLAINTEXT
from authlib.oauth1 import SIGNATURE_RSA_SHA1
from authlib.oauth1 import SIGNATURE_TYPE_BODY
from authlib.oauth1 import SIGNATURE_TYPE_HEADER
from authlib.oauth1 import SIGNATURE_TYPE_QUERY
from ..base_client import OAuthError
from .assertion_client import AssertionClient
from .assertion_client import AsyncAssertionClient
from .oauth1_client import AsyncOAuth1Client
from .oauth1_client import OAuth1Auth
from .oauth1_client import OAuth1Client
from .oauth2_client import AsyncOAuth2Client
from .oauth2_client import OAuth2Auth
from .oauth2_client import OAuth2Client
from .oauth2_client import OAuth2ClientAuth
__all__ = [
"OAuthError",
"OAuth1Auth",
"AsyncOAuth1Client",
"OAuth1Client",
"SIGNATURE_HMAC_SHA1",
"SIGNATURE_RSA_SHA1",
"SIGNATURE_PLAINTEXT",
"SIGNATURE_TYPE_HEADER",
"SIGNATURE_TYPE_QUERY",
"SIGNATURE_TYPE_BODY",
"OAuth2Auth",
"OAuth2ClientAuth",
"OAuth2Client",
"AsyncOAuth2Client",
"AssertionClient",
"AsyncAssertionClient",
]
@@ -0,0 +1,18 @@
"""Compatibility shim for httpx2 with legacy httpx fallback.
Falls back to httpx when httpx2 is unavailable. The fallback is deprecated
and will be removed in a future release.
"""
from authlib.deprecate import deprecate
try:
import httpx2
except ImportError:
import httpx as httpx2
deprecate(
"The httpx module is deprecated; please use httpx2 instead."
)
__all__ = ["httpx2"]
@@ -0,0 +1,123 @@
from authlib.oauth2.rfc7521 import AssertionClient as _AssertionClient
from authlib.oauth2.rfc7523 import JWTBearerGrant
from ..base_client import OAuthError
from ._compat import httpx2
from .oauth2_client import OAuth2Auth
from .utils import extract_client_kwargs
USE_CLIENT_DEFAULT = httpx2.USE_CLIENT_DEFAULT
Response = httpx2.Response
__all__ = ["AsyncAssertionClient"]
class AsyncAssertionClient(_AssertionClient, httpx2.AsyncClient):
token_auth_class = OAuth2Auth
oauth_error_class = OAuthError
JWT_BEARER_GRANT_TYPE = JWTBearerGrant.GRANT_TYPE
ASSERTION_METHODS = {
JWT_BEARER_GRANT_TYPE: JWTBearerGrant.sign,
}
DEFAULT_GRANT_TYPE = JWT_BEARER_GRANT_TYPE
def __init__(
self,
token_endpoint,
issuer,
subject,
audience=None,
grant_type=None,
claims=None,
token_placement="header",
scope=None,
client_id=None,
**kwargs,
):
client_kwargs = extract_client_kwargs(kwargs)
httpx2.AsyncClient.__init__(self, **client_kwargs)
_AssertionClient.__init__(
self,
session=None,
token_endpoint=token_endpoint,
issuer=issuer,
subject=subject,
audience=audience,
grant_type=grant_type,
claims=claims,
token_placement=token_placement,
scope=scope,
client_id=client_id,
**kwargs,
)
async def request(
self, method, url, withhold_token=False, auth=USE_CLIENT_DEFAULT, **kwargs
) -> Response:
"""Send request with auto refresh token feature."""
if not withhold_token and auth is USE_CLIENT_DEFAULT:
if not self.token or self.token.is_expired():
await self.refresh_token()
auth = self.token_auth
return await super().request(method, url, auth=auth, **kwargs)
async def _refresh_token(self, data):
resp = await self.request(
"POST", self.token_endpoint, data=data, withhold_token=True
)
return self.parse_response_token(resp)
class AssertionClient(_AssertionClient, httpx2.Client):
token_auth_class = OAuth2Auth
oauth_error_class = OAuthError
JWT_BEARER_GRANT_TYPE = JWTBearerGrant.GRANT_TYPE
ASSERTION_METHODS = {
JWT_BEARER_GRANT_TYPE: JWTBearerGrant.sign,
}
DEFAULT_GRANT_TYPE = JWT_BEARER_GRANT_TYPE
def __init__(
self,
token_endpoint,
issuer,
subject,
audience=None,
grant_type=None,
claims=None,
token_placement="header",
scope=None,
client_id=None,
**kwargs,
):
client_kwargs = extract_client_kwargs(kwargs)
httpx2.Client.__init__(self, **client_kwargs)
_AssertionClient.__init__(
self,
session=self,
token_endpoint=token_endpoint,
issuer=issuer,
subject=subject,
audience=audience,
grant_type=grant_type,
claims=claims,
token_placement=token_placement,
scope=scope,
client_id=client_id,
**kwargs,
)
def request(
self, method, url, withhold_token=False, auth=USE_CLIENT_DEFAULT, **kwargs
):
"""Send request with auto refresh token feature."""
if not withhold_token and auth is USE_CLIENT_DEFAULT:
if not self.token or self.token.is_expired():
self.refresh_token()
auth = self.token_auth
return super().request(method, url, auth=auth, **kwargs)
@@ -0,0 +1,140 @@
import typing
from authlib.common.encoding import to_unicode
from authlib.oauth1 import SIGNATURE_HMAC_SHA1
from authlib.oauth1 import SIGNATURE_TYPE_HEADER
from authlib.oauth1 import ClientAuth
from authlib.oauth1.client import OAuth1Client as _OAuth1Client
from ..base_client import OAuthError
from ._compat import httpx2
from .utils import build_request
from .utils import extract_client_kwargs
Auth = httpx2.Auth
Request = httpx2.Request
Response = httpx2.Response
class OAuth1Auth(Auth, ClientAuth):
"""Signs the httpx2 request using OAuth 1 (RFC5849)."""
requires_request_body = True
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
url, headers, body = self.prepare(
request.method, str(request.url), request.headers, request.content
)
headers["Content-Length"] = str(len(body))
yield build_request(
url=url, headers=headers, body=body, initial_request=request
)
class AsyncOAuth1Client(_OAuth1Client, httpx2.AsyncClient):
auth_class = OAuth1Auth
def __init__(
self,
client_id,
client_secret=None,
token=None,
token_secret=None,
redirect_uri=None,
rsa_key=None,
verifier=None,
signature_method=SIGNATURE_HMAC_SHA1,
signature_type=SIGNATURE_TYPE_HEADER,
force_include_body=False,
**kwargs,
):
_client_kwargs = extract_client_kwargs(kwargs)
httpx2.AsyncClient.__init__(self, **_client_kwargs)
_OAuth1Client.__init__(
self,
None,
client_id=client_id,
client_secret=client_secret,
token=token,
token_secret=token_secret,
redirect_uri=redirect_uri,
rsa_key=rsa_key,
verifier=verifier,
signature_method=signature_method,
signature_type=signature_type,
force_include_body=force_include_body,
**kwargs,
)
async def fetch_access_token(self, url, verifier=None, **kwargs):
"""Method for fetching an access token from the token endpoint.
This is the final step in the OAuth 1 workflow. An access token is
obtained using all previously obtained credentials, including the
verifier from the authorization step.
:param url: Access Token endpoint.
:param verifier: A verifier string to prove authorization was granted.
:param kwargs: Extra parameters to include for fetching access token.
:return: A token dict.
"""
if verifier:
self.auth.verifier = verifier
if not self.auth.verifier:
self.handle_error("missing_verifier", 'Missing "verifier" value')
token = await self._fetch_token(url, **kwargs)
self.auth.verifier = None
return token
async def _fetch_token(self, url, **kwargs):
resp = await self.post(url, **kwargs)
text = await resp.aread()
token = self.parse_response_token(resp.status_code, to_unicode(text))
self.token = token
return token
@staticmethod
def handle_error(error_type, error_description):
raise OAuthError(error_type, error_description)
class OAuth1Client(_OAuth1Client, httpx2.Client):
auth_class = OAuth1Auth
def __init__(
self,
client_id,
client_secret=None,
token=None,
token_secret=None,
redirect_uri=None,
rsa_key=None,
verifier=None,
signature_method=SIGNATURE_HMAC_SHA1,
signature_type=SIGNATURE_TYPE_HEADER,
force_include_body=False,
**kwargs,
):
_client_kwargs = extract_client_kwargs(kwargs)
httpx2.Client.__init__(self, **_client_kwargs)
_OAuth1Client.__init__(
self,
self,
client_id=client_id,
client_secret=client_secret,
token=token,
token_secret=token_secret,
redirect_uri=redirect_uri,
rsa_key=rsa_key,
verifier=verifier,
signature_method=signature_method,
signature_type=signature_type,
force_include_body=force_include_body,
**kwargs,
)
@staticmethod
def handle_error(error_type, error_description):
raise OAuthError(error_type, error_description)
@@ -0,0 +1,284 @@
# ruff: noqa: I001
# The import order below is intentional: httpx compatibility must be loaded
# before anyio so import errors refer to httpx.
import typing
from contextlib import asynccontextmanager
from ._compat import httpx2
from anyio import Lock # Import after httpx so import errors refer to httpx
from authlib.common.urls import url_decode
from authlib.oauth2.auth import ClientAuth
from authlib.oauth2.auth import TokenAuth
from authlib.oauth2.client import OAuth2Client as _OAuth2Client
from ..base_client import InvalidTokenError
from ..base_client import MissingTokenError
from ..base_client import OAuthError
from ..base_client import UnsupportedTokenTypeError
from .utils import HTTPX_CLIENT_KWARGS
from .utils import build_request
USE_CLIENT_DEFAULT = httpx2.USE_CLIENT_DEFAULT
Auth = httpx2.Auth
Request = httpx2.Request
Response = httpx2.Response
__all__ = [
"OAuth2Auth",
"OAuth2ClientAuth",
"AsyncOAuth2Client",
"OAuth2Client",
]
class OAuth2Auth(Auth, TokenAuth):
"""Sign requests for OAuth 2.0, currently only bearer token is supported."""
requires_request_body = True
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
try:
url, headers, body = self.prepare(
str(request.url), request.headers, request.content
)
headers["Content-Length"] = str(len(body))
yield build_request(
url=url, headers=headers, body=body, initial_request=request
)
except KeyError as error:
description = f"Unsupported token_type: {str(error)}"
raise UnsupportedTokenTypeError(description=description) from error
class OAuth2ClientAuth(Auth, ClientAuth):
requires_request_body = True
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
url, headers, body = self.prepare(
request.method, str(request.url), request.headers, request.content
)
headers["Content-Length"] = str(len(body))
yield build_request(
url=url, headers=headers, body=body, initial_request=request
)
class AsyncOAuth2Client(_OAuth2Client, httpx2.AsyncClient):
SESSION_REQUEST_PARAMS = HTTPX_CLIENT_KWARGS
client_auth_class = OAuth2ClientAuth
token_auth_class = OAuth2Auth
oauth_error_class = OAuthError
def __init__(
self,
client_id=None,
client_secret=None,
token_endpoint_auth_method=None,
revocation_endpoint_auth_method=None,
scope=None,
redirect_uri=None,
token=None,
token_placement="header",
update_token=None,
leeway=60,
**kwargs,
):
# extract httpx2.Client kwargs
client_kwargs = self._extract_session_request_params(kwargs)
httpx2.AsyncClient.__init__(self, **client_kwargs)
# We use a Lock to synchronize coroutines to prevent
# multiple concurrent attempts to refresh the same token
self._token_refresh_lock = Lock()
_OAuth2Client.__init__(
self,
session=None,
client_id=client_id,
client_secret=client_secret,
token_endpoint_auth_method=token_endpoint_auth_method,
revocation_endpoint_auth_method=revocation_endpoint_auth_method,
scope=scope,
redirect_uri=redirect_uri,
token=token,
token_placement=token_placement,
update_token=update_token,
leeway=leeway,
**kwargs,
)
async def request(
self, method, url, withhold_token=False, auth=USE_CLIENT_DEFAULT, **kwargs
):
if not withhold_token and auth is USE_CLIENT_DEFAULT:
if not self.token:
raise MissingTokenError()
await self.ensure_active_token(self.token)
auth = self.token_auth
return await super().request(method, url, auth=auth, **kwargs)
@asynccontextmanager
async def stream(
self, method, url, withhold_token=False, auth=USE_CLIENT_DEFAULT, **kwargs
):
if not withhold_token and auth is USE_CLIENT_DEFAULT:
if not self.token:
raise MissingTokenError()
await self.ensure_active_token(self.token)
auth = self.token_auth
async with super().stream(method, url, auth=auth, **kwargs) as resp:
yield resp
async def ensure_active_token(self, token):
async with self._token_refresh_lock:
if self.token.is_expired(leeway=self.leeway):
refresh_token = token.get("refresh_token")
url = self.metadata.get("token_endpoint")
if refresh_token and url:
await self.refresh_token(url, refresh_token=refresh_token)
elif self.metadata.get("grant_type") == "client_credentials":
access_token = token["access_token"]
new_token = await self.fetch_token(
url, grant_type="client_credentials"
)
if self.update_token:
await self.update_token(new_token, access_token=access_token)
else:
raise InvalidTokenError()
async def _fetch_token(
self,
url,
body="",
headers=None,
auth=USE_CLIENT_DEFAULT,
method="POST",
**kwargs,
):
if method.upper() == "POST":
resp = await self.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 = await self.get(url, headers=headers, auth=auth, **kwargs)
for hook in self.compliance_hook["access_token_response"]:
resp = hook(resp)
return self.parse_response_token(resp)
async def _refresh_token(
self,
url,
refresh_token=None,
body="",
headers=None,
auth=USE_CLIENT_DEFAULT,
**kwargs,
):
resp = await self.post(
url, data=dict(url_decode(body)), headers=headers, auth=auth, **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 self.update_token:
await self.update_token(self.token, refresh_token=refresh_token)
return self.token
def _http_post(
self, url, body=None, auth=USE_CLIENT_DEFAULT, headers=None, **kwargs
):
return self.post(
url, data=dict(url_decode(body)), headers=headers, auth=auth, **kwargs
)
class OAuth2Client(_OAuth2Client, httpx2.Client):
SESSION_REQUEST_PARAMS = HTTPX_CLIENT_KWARGS
client_auth_class = OAuth2ClientAuth
token_auth_class = OAuth2Auth
oauth_error_class = OAuthError
def __init__(
self,
client_id=None,
client_secret=None,
token_endpoint_auth_method=None,
revocation_endpoint_auth_method=None,
scope=None,
redirect_uri=None,
token=None,
token_placement="header",
update_token=None,
**kwargs,
):
# extract httpx2.Client kwargs
client_kwargs = self._extract_session_request_params(kwargs)
httpx2.Client.__init__(self, **client_kwargs)
_OAuth2Client.__init__(
self,
session=self,
client_id=client_id,
client_secret=client_secret,
token_endpoint_auth_method=token_endpoint_auth_method,
revocation_endpoint_auth_method=revocation_endpoint_auth_method,
scope=scope,
redirect_uri=redirect_uri,
token=token,
token_placement=token_placement,
update_token=update_token,
**kwargs,
)
@staticmethod
def handle_error(error_type, error_description):
raise OAuthError(error_type, error_description)
def request(
self, method, url, withhold_token=False, auth=USE_CLIENT_DEFAULT, **kwargs
):
if not withhold_token and auth is USE_CLIENT_DEFAULT:
if not self.token:
raise MissingTokenError()
if not self.ensure_active_token(self.token):
raise InvalidTokenError()
auth = self.token_auth
return super().request(method, url, auth=auth, **kwargs)
def stream(
self, method, url, withhold_token=False, auth=USE_CLIENT_DEFAULT, **kwargs
):
if not withhold_token and auth is USE_CLIENT_DEFAULT:
if not self.token:
raise MissingTokenError()
if not self.ensure_active_token(self.token):
raise InvalidTokenError()
auth = self.token_auth
return super().stream(method, url, auth=auth, **kwargs)
@@ -0,0 +1,46 @@
from ._compat import httpx2
Request = httpx2.Request
HTTPX_CLIENT_KWARGS = [
"headers",
"cookies",
"verify",
"cert",
"http1",
"http2",
"proxy",
"mounts",
"timeout",
"follow_redirects",
"limits",
"max_redirects",
"event_hooks",
"base_url",
"transport",
"trust_env",
"default_encoding",
]
def extract_client_kwargs(kwargs):
client_kwargs = {}
for k in HTTPX_CLIENT_KWARGS:
if k in kwargs:
client_kwargs[k] = kwargs.pop(k)
return client_kwargs
def build_request(url, headers, body, initial_request: Request) -> Request:
"""Make sure that all the data from initial request is passed to the updated object."""
updated_request = Request(
method=initial_request.method,
url=url,
headers=headers,
content=body,
)
if hasattr(initial_request, "extensions"):
updated_request.extensions = initial_request.extensions
return updated_request
@@ -0,0 +1,28 @@
from authlib.oauth1 import SIGNATURE_HMAC_SHA1
from authlib.oauth1 import SIGNATURE_PLAINTEXT
from authlib.oauth1 import SIGNATURE_RSA_SHA1
from authlib.oauth1 import SIGNATURE_TYPE_BODY
from authlib.oauth1 import SIGNATURE_TYPE_HEADER
from authlib.oauth1 import SIGNATURE_TYPE_QUERY
from ..base_client import OAuthError
from .assertion_session import AssertionSession
from .oauth1_session import OAuth1Auth
from .oauth1_session import OAuth1Session
from .oauth2_session import OAuth2Auth
from .oauth2_session import OAuth2Session
__all__ = [
"OAuthError",
"OAuth1Session",
"OAuth1Auth",
"SIGNATURE_HMAC_SHA1",
"SIGNATURE_RSA_SHA1",
"SIGNATURE_PLAINTEXT",
"SIGNATURE_TYPE_HEADER",
"SIGNATURE_TYPE_QUERY",
"SIGNATURE_TYPE_BODY",
"OAuth2Session",
"OAuth2Auth",
"AssertionSession",
]
@@ -0,0 +1,72 @@
from requests import Session
from authlib.oauth2.rfc7521 import AssertionClient
from authlib.oauth2.rfc7523 import JWTBearerGrant
from .oauth2_session import OAuth2Auth
from .utils import update_session_configure
class AssertionAuth(OAuth2Auth):
def ensure_active_token(self):
if self.client and (
not self.token or self.token.is_expired(self.client.leeway)
):
return self.client.refresh_token()
class AssertionSession(AssertionClient, Session):
"""Constructs a new Assertion Framework for OAuth 2.0 Authorization Grants
per RFC7521_.
.. _RFC7521: https://tools.ietf.org/html/rfc7521
"""
token_auth_class = AssertionAuth
JWT_BEARER_GRANT_TYPE = JWTBearerGrant.GRANT_TYPE
ASSERTION_METHODS = {
JWT_BEARER_GRANT_TYPE: JWTBearerGrant.sign,
}
DEFAULT_GRANT_TYPE = JWT_BEARER_GRANT_TYPE
def __init__(
self,
token_endpoint,
issuer,
subject,
audience=None,
grant_type=None,
claims=None,
token_placement="header",
scope=None,
client_id=None,
default_timeout=None,
leeway=60,
**kwargs,
):
Session.__init__(self)
self.default_timeout = default_timeout
update_session_configure(self, kwargs)
AssertionClient.__init__(
self,
session=self,
token_endpoint=token_endpoint,
issuer=issuer,
subject=subject,
audience=audience,
grant_type=grant_type,
claims=claims,
token_placement=token_placement,
scope=scope,
client_id=client_id,
leeway=leeway,
**kwargs,
)
def request(self, method, url, withhold_token=False, auth=None, **kwargs):
"""Send request with auto refresh token feature."""
if self.default_timeout:
kwargs.setdefault("timeout", self.default_timeout)
if not withhold_token and auth is None:
auth = self.token_auth
return super().request(method, url, auth=auth, **kwargs)
@@ -0,0 +1,74 @@
from requests import Session
from requests.auth import AuthBase
from authlib.common.encoding import to_native
from authlib.oauth1 import SIGNATURE_HMAC_SHA1
from authlib.oauth1 import SIGNATURE_TYPE_HEADER
from authlib.oauth1 import ClientAuth
from authlib.oauth1.client import OAuth1Client
from ..base_client import OAuthError
from .utils import update_session_configure
class OAuth1Auth(AuthBase, ClientAuth):
"""Signs the request using OAuth 1 (RFC5849)."""
def __call__(self, req):
url, headers, body = self.prepare(req.method, req.url, req.headers, req.body)
req.url = to_native(url)
req.prepare_headers(headers)
if body:
req.body = body
return req
class OAuth1Session(OAuth1Client, Session):
auth_class = OAuth1Auth
def __init__(
self,
client_id,
client_secret=None,
token=None,
token_secret=None,
redirect_uri=None,
rsa_key=None,
verifier=None,
signature_method=SIGNATURE_HMAC_SHA1,
signature_type=SIGNATURE_TYPE_HEADER,
force_include_body=False,
**kwargs,
):
Session.__init__(self)
update_session_configure(self, kwargs)
OAuth1Client.__init__(
self,
session=self,
client_id=client_id,
client_secret=client_secret,
token=token,
token_secret=token_secret,
redirect_uri=redirect_uri,
rsa_key=rsa_key,
verifier=verifier,
signature_method=signature_method,
signature_type=signature_type,
force_include_body=force_include_body,
**kwargs,
)
def rebuild_auth(self, prepared_request, response):
"""When being redirected we should always strip Authorization
header, since nonce may not be reused as per OAuth spec.
"""
if "Authorization" in prepared_request.headers:
# If we get redirected to a new host, we should strip out
# any authentication headers.
prepared_request.headers.pop("Authorization", True)
prepared_request.prepare_auth(self.auth)
@staticmethod
def handle_error(error_type, error_description):
raise OAuthError(error_type, error_description)
@@ -0,0 +1,140 @@
from requests import Session
from requests.auth import AuthBase
from authlib.oauth2.auth import ClientAuth
from authlib.oauth2.auth import TokenAuth
from authlib.oauth2.client import OAuth2Client
from ..base_client import InvalidTokenError
from ..base_client import MissingTokenError
from ..base_client import OAuthError
from ..base_client import UnsupportedTokenTypeError
from .utils import update_session_configure
__all__ = ["OAuth2Session", "OAuth2Auth"]
class OAuth2Auth(AuthBase, TokenAuth):
"""Sign requests for OAuth 2.0, currently only bearer token is supported."""
def ensure_active_token(self):
if self.client and not self.client.ensure_active_token(self.token):
raise InvalidTokenError()
def __call__(self, req):
self.ensure_active_token()
try:
req.url, req.headers, req.body = self.prepare(
req.url, req.headers, req.body
)
except KeyError as error:
description = f"Unsupported token_type: {str(error)}"
raise UnsupportedTokenTypeError(description=description) from error
return req
class OAuth2ClientAuth(AuthBase, ClientAuth):
"""Attaches OAuth Client Authentication to the given Request object."""
def __call__(self, req):
req.url, req.headers, req.body = self.prepare(
req.method, req.url, req.headers, req.body
)
return req
class OAuth2Session(OAuth2Client, Session):
"""Construct a new OAuth 2 client requests session.
:param client_id: Client ID, which you get from client registration.
:param client_secret: Client Secret, which you get from registration.
:param authorization_endpoint: URL of the authorization server's
authorization endpoint.
:param token_endpoint: URL of the authorization server's token endpoint.
:param token_endpoint_auth_method: client authentication method for
token endpoint.
:param revocation_endpoint: URL of the authorization server's OAuth 2.0
revocation 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 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.
:param default_timeout: If settled, every requests will have a default timeout.
"""
client_auth_class = OAuth2ClientAuth
token_auth_class = OAuth2Auth
oauth_error_class = OAuthError
SESSION_REQUEST_PARAMS = (
"allow_redirects",
"timeout",
"cookies",
"files",
"proxies",
"hooks",
"stream",
"verify",
"cert",
"json",
)
def __init__(
self,
client_id=None,
client_secret=None,
token_endpoint_auth_method=None,
revocation_endpoint_auth_method=None,
scope=None,
state=None,
redirect_uri=None,
token=None,
token_placement="header",
update_token=None,
leeway=60,
default_timeout=None,
**kwargs,
):
Session.__init__(self)
self.default_timeout = default_timeout
update_session_configure(self, kwargs)
OAuth2Client.__init__(
self,
session=self,
client_id=client_id,
client_secret=client_secret,
token_endpoint_auth_method=token_endpoint_auth_method,
revocation_endpoint_auth_method=revocation_endpoint_auth_method,
scope=scope,
state=state,
redirect_uri=redirect_uri,
token=token,
token_placement=token_placement,
update_token=update_token,
leeway=leeway,
**kwargs,
)
def fetch_access_token(self, url=None, **kwargs):
"""Alias for fetch_token."""
return self.fetch_token(url, **kwargs)
def request(self, method, url, withhold_token=False, auth=None, **kwargs):
"""Send request with auto refresh token feature (if available)."""
if self.default_timeout:
kwargs.setdefault("timeout", self.default_timeout)
if not withhold_token and auth is None:
if not self.token:
raise MissingTokenError()
auth = self.token_auth
return super().request(method, url, auth=auth, **kwargs)
@@ -0,0 +1,15 @@
REQUESTS_SESSION_KWARGS = [
"proxies",
"hooks",
"stream",
"verify",
"cert",
"max_redirects",
"trust_env",
]
def update_session_configure(session, kwargs):
for k in REQUESTS_SESSION_KWARGS:
if k in kwargs:
setattr(session, k, kwargs.pop(k))
@@ -0,0 +1,19 @@
from .client_mixin import OAuth2ClientMixin
from .functions import create_bearer_token_validator
from .functions import create_query_client_func
from .functions import create_query_token_func
from .functions import create_revocation_endpoint
from .functions import create_save_token_func
from .tokens_mixins import OAuth2AuthorizationCodeMixin
from .tokens_mixins import OAuth2TokenMixin
__all__ = [
"OAuth2ClientMixin",
"OAuth2AuthorizationCodeMixin",
"OAuth2TokenMixin",
"create_query_client_func",
"create_save_token_func",
"create_query_token_func",
"create_revocation_endpoint",
"create_bearer_token_validator",
]
@@ -0,0 +1,147 @@
import secrets
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
from authlib.common.encoding import json_dumps
from authlib.common.encoding import json_loads
from authlib.oauth2.rfc6749 import ClientMixin
from authlib.oauth2.rfc6749 import list_to_scope
from authlib.oauth2.rfc6749 import scope_to_list
class OAuth2ClientMixin(ClientMixin):
client_id = Column(String(48), index=True)
client_secret = Column(String(120))
client_id_issued_at = Column(Integer, nullable=False, default=0)
client_secret_expires_at = Column(Integer, nullable=False, default=0)
_client_metadata = Column("client_metadata", Text)
@property
def client_info(self):
"""Implementation for Client Info in OAuth 2.0 Dynamic Client
Registration Protocol via `Section 3.2.1`_.
.. _`Section 3.2.1`: https://tools.ietf.org/html/rfc7591#section-3.2.1
"""
return dict(
client_id=self.client_id,
client_secret=self.client_secret,
client_id_issued_at=self.client_id_issued_at,
client_secret_expires_at=self.client_secret_expires_at,
)
@property
def client_metadata(self):
if "client_metadata" in self.__dict__:
return self.__dict__["client_metadata"]
if self._client_metadata:
data = json_loads(self._client_metadata)
self.__dict__["client_metadata"] = data
return data
return {}
def set_client_metadata(self, value):
self._client_metadata = json_dumps(value)
if "client_metadata" in self.__dict__:
del self.__dict__["client_metadata"]
@property
def redirect_uris(self):
return self.client_metadata.get("redirect_uris", [])
@property
def token_endpoint_auth_method(self):
return self.client_metadata.get(
"token_endpoint_auth_method", "client_secret_basic"
)
@property
def grant_types(self):
return self.client_metadata.get("grant_types", [])
@property
def response_types(self):
return self.client_metadata.get("response_types", [])
@property
def client_name(self):
return self.client_metadata.get("client_name")
@property
def client_uri(self):
return self.client_metadata.get("client_uri")
@property
def logo_uri(self):
return self.client_metadata.get("logo_uri")
@property
def scope(self):
return self.client_metadata.get("scope", "")
@property
def contacts(self):
return self.client_metadata.get("contacts", [])
@property
def tos_uri(self):
return self.client_metadata.get("tos_uri")
@property
def policy_uri(self):
return self.client_metadata.get("policy_uri")
@property
def jwks_uri(self):
return self.client_metadata.get("jwks_uri")
@property
def jwks(self):
return self.client_metadata.get("jwks", [])
@property
def software_id(self):
return self.client_metadata.get("software_id")
@property
def software_version(self):
return self.client_metadata.get("software_version")
@property
def id_token_signed_response_alg(self):
return self.client_metadata.get("id_token_signed_response_alg")
def get_client_id(self):
return self.client_id
def get_default_redirect_uri(self):
if self.redirect_uris:
return self.redirect_uris[0]
def get_allowed_scope(self, scope):
if not scope:
return ""
allowed = set(self.scope.split())
scopes = scope_to_list(scope)
return list_to_scope([s for s in scopes if s in allowed])
def check_redirect_uri(self, redirect_uri):
return redirect_uri in self.redirect_uris
def check_client_secret(self, client_secret):
return secrets.compare_digest(self.client_secret, client_secret)
def check_endpoint_auth_method(self, method, endpoint):
if endpoint == "token":
return self.token_endpoint_auth_method == method
# TODO
return True
def check_response_type(self, response_type):
return response_type in self.response_types
def check_grant_type(self, grant_type):
return grant_type in self.grant_types
@@ -0,0 +1,104 @@
import time
def create_query_client_func(session, client_model):
"""Create an ``query_client`` function that can be used in authorization
server.
:param session: SQLAlchemy session
:param client_model: Client model class
"""
def query_client(client_id):
q = session.query(client_model)
return q.filter_by(client_id=client_id).first()
return query_client
def create_save_token_func(session, token_model):
"""Create an ``save_token`` function that can be used in authorization
server.
:param session: SQLAlchemy session
:param token_model: Token model class
"""
def save_token(token, request):
if request.user:
user_id = request.user.get_user_id()
else:
user_id = None
client = request.client
item = token_model(client_id=client.client_id, user_id=user_id, **token)
session.add(item)
session.commit()
return save_token
def create_query_token_func(session, token_model):
"""Create an ``query_token`` function for revocation, introspection
token endpoints.
:param session: SQLAlchemy session
:param token_model: Token model class
"""
def query_token(token, token_type_hint):
q = session.query(token_model)
if token_type_hint == "access_token":
return q.filter_by(access_token=token).first()
elif token_type_hint == "refresh_token":
return q.filter_by(refresh_token=token).first()
# without token_type_hint
item = q.filter_by(access_token=token).first()
if item:
return item
return q.filter_by(refresh_token=token).first()
return query_token
def create_revocation_endpoint(session, token_model):
"""Create a revocation endpoint class with SQLAlchemy session
and token model.
:param session: SQLAlchemy session
:param token_model: Token model class
"""
from authlib.oauth2.rfc7009 import RevocationEndpoint
query_token = create_query_token_func(session, token_model)
class _RevocationEndpoint(RevocationEndpoint):
def query_token(self, token, token_type_hint):
return query_token(token, token_type_hint)
def revoke_token(self, token, request):
now = int(time.time())
hint = request.form.get("token_type_hint")
token.access_token_revoked_at = now
if hint != "access_token":
token.refresh_token_revoked_at = now
session.add(token)
session.commit()
return _RevocationEndpoint
def create_bearer_token_validator(session, token_model):
"""Create an bearer token validator class with SQLAlchemy session
and token model.
:param session: SQLAlchemy session
:param token_model: Token model class
"""
from authlib.oauth2.rfc6750 import BearerTokenValidator
class _BearerTokenValidator(BearerTokenValidator):
def authenticate_token(self, token_string):
q = session.query(token_model)
return q.filter_by(access_token=token_string).first()
return _BearerTokenValidator
@@ -0,0 +1,76 @@
import time
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
from authlib.oauth2.rfc6749 import AuthorizationCodeMixin
from authlib.oauth2.rfc6749 import TokenMixin
class OAuth2AuthorizationCodeMixin(AuthorizationCodeMixin):
code = Column(String(120), unique=True, nullable=False)
client_id = Column(String(48))
redirect_uri = Column(Text, default="")
response_type = Column(Text, default="")
scope = Column(Text, default="")
nonce = Column(Text)
auth_time = Column(Integer, nullable=False, default=lambda: int(time.time()))
acr = Column(Text, nullable=True)
amr = Column(Text, nullable=True)
code_challenge = Column(Text)
code_challenge_method = Column(String(48))
def is_expired(self):
return self.auth_time + 300 < time.time()
def get_redirect_uri(self):
return self.redirect_uri
def get_scope(self):
return self.scope
def get_auth_time(self):
return self.auth_time
def get_acr(self):
return self.acr
def get_amr(self):
return self.amr.split() if self.amr else []
def get_nonce(self):
return self.nonce
class OAuth2TokenMixin(TokenMixin):
client_id = Column(String(48))
token_type = Column(String(40))
access_token = Column(String(255), unique=True, nullable=False)
refresh_token = Column(String(255), index=True)
scope = Column(Text, default="")
issued_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
access_token_revoked_at = Column(Integer, nullable=False, default=0)
refresh_token_revoked_at = Column(Integer, nullable=False, default=0)
expires_in = Column(Integer, nullable=False, default=0)
def check_client(self, client):
return self.client_id == client.get_client_id()
def get_scope(self):
return self.scope
def get_expires_in(self):
return self.expires_in
def is_revoked(self):
return self.access_token_revoked_at or self.refresh_token_revoked_at
def is_expired(self):
if not self.expires_in:
return False
expires_at = self.issued_at + self.expires_in
return expires_at < time.time()
@@ -0,0 +1,26 @@
from ..base_client import BaseOAuth
from ..base_client import OAuthError
from .apps import StarletteOAuth1App
from .apps import StarletteOAuth2App
from .integration import StarletteIntegration
class OAuth(BaseOAuth):
oauth1_client_cls = StarletteOAuth1App
oauth2_client_cls = StarletteOAuth2App
framework_integration_cls = StarletteIntegration
def __init__(self, config=None, cache=None, fetch_token=None, update_token=None):
super().__init__(
cache=cache, fetch_token=fetch_token, update_token=update_token
)
self.config = config
__all__ = [
"OAuth",
"OAuthError",
"StarletteIntegration",
"StarletteOAuth1App",
"StarletteOAuth2App",
]
@@ -0,0 +1,145 @@
from starlette.datastructures import URL
from starlette.responses import RedirectResponse
from ..base_client import BaseApp
from ..base_client import OAuthError
from ..base_client.async_app import AsyncOAuth1Mixin
from ..base_client.async_app import AsyncOAuth2Mixin
from ..base_client.async_openid import AsyncOpenIDMixin
from ..httpx_client import AsyncOAuth1Client
from ..httpx_client import AsyncOAuth2Client
class StarletteAppMixin:
async def save_authorize_data(self, request, **kwargs):
state = kwargs.pop("state", None)
if state:
await self.framework.set_state_data(request.session, state, kwargs)
else:
raise RuntimeError("Missing state value")
async def authorize_redirect(self, request, redirect_uri=None, **kwargs):
"""Create a HTTP Redirect for Authorization Endpoint.
:param request: HTTP request instance from Starlette view.
:param redirect_uri: Callback or redirect URI for authorization.
:param kwargs: Extra parameters to include.
:return: A HTTP redirect response.
"""
# Handle Starlette >= 0.26.0 where redirect_uri may now be a URL and not a string
if redirect_uri and isinstance(redirect_uri, URL):
redirect_uri = str(redirect_uri)
rv = await self.create_authorization_url(redirect_uri, **kwargs)
await self.save_authorize_data(request, redirect_uri=redirect_uri, **rv)
return RedirectResponse(rv["url"], status_code=302)
class StarletteOAuth1App(StarletteAppMixin, AsyncOAuth1Mixin, BaseApp):
client_cls = AsyncOAuth1Client
async def authorize_access_token(self, request, **kwargs):
params = dict(request.query_params)
state = params.get("oauth_token")
if not state:
raise OAuthError(description='Missing "oauth_token" parameter')
data = await self.framework.get_state_data(request.session, state)
if not data:
raise OAuthError(description='Missing "request_token" in temporary data')
params["request_token"] = data["request_token"]
params.update(kwargs)
await self.framework.clear_state_data(request.session, state)
return await self.fetch_access_token(**params)
class StarletteOAuth2App(
StarletteAppMixin, AsyncOAuth2Mixin, AsyncOpenIDMixin, BaseApp
):
client_cls = AsyncOAuth2Client
async def logout_redirect(
self, request, post_logout_redirect_uri=None, id_token_hint=None, **kwargs
):
"""Create a HTTP Redirect for End Session Endpoint (RP-Initiated Logout).
:param request: HTTP request instance from Starlette view.
:param post_logout_redirect_uri: URI to redirect after logout.
:param id_token_hint: ID Token previously issued to the RP.
:param kwargs: Extra parameters (state, client_id, logout_hint, ui_locales).
:return: A HTTP redirect response.
"""
if post_logout_redirect_uri and isinstance(post_logout_redirect_uri, URL):
post_logout_redirect_uri = str(post_logout_redirect_uri)
result = await self.create_logout_url(
post_logout_redirect_uri=post_logout_redirect_uri,
id_token_hint=id_token_hint,
**kwargs,
)
if result.get("state"):
await self.framework.set_state_data(
request.session,
result["state"],
{
"post_logout_redirect_uri": post_logout_redirect_uri,
},
)
return RedirectResponse(result["url"], status_code=302)
async def validate_logout_response(self, request):
"""Validate the state parameter from the logout callback.
:param request: HTTP request instance from Starlette view.
:return: The state data dict.
:raises OAuthError: If state is missing or invalid.
"""
state = request.query_params.get("state")
if not state:
raise OAuthError(description='Missing "state" parameter')
state_data = await self.framework.get_state_data(request.session, state)
if not state_data:
raise OAuthError(description='Invalid "state" parameter')
await self.framework.clear_state_data(request.session, state)
return state_data
async def authorize_access_token(self, request, **kwargs):
if request.scope.get("method", "GET") == "GET":
error = request.query_params.get("error")
if error:
description = request.query_params.get("error_description")
raise OAuthError(error=error, description=description)
params = {
"code": request.query_params.get("code"),
"state": request.query_params.get("state"),
}
else:
async with request.form() as form:
params = {
"code": form.get("code"),
"state": form.get("state"),
}
state_data = await self.framework.get_state_data(
request.session, params.get("state")
)
await self.framework.clear_state_data(request.session, params.get("state"))
params = self._format_state_params(state_data, params)
claims_options = kwargs.pop("claims_options", None)
claims_cls = kwargs.pop("claims_cls", None)
leeway = kwargs.pop("leeway", 120)
token = await self.fetch_access_token(**params, **kwargs)
if "id_token" in token and "nonce" in state_data:
userinfo = await self.parse_id_token(
token,
nonce=state_data["nonce"],
claims_options=claims_options,
claims_cls=claims_cls,
leeway=leeway,
)
token["userinfo"] = userinfo
return token
@@ -0,0 +1,81 @@
import json
import time
from collections.abc import Hashable
from typing import Any
from ..base_client import FrameworkIntegration
class StarletteIntegration(FrameworkIntegration):
async def _get_cache_data(self, key: Hashable):
value = await self.cache.get(key)
if not value:
return None
try:
return json.loads(value)
except (TypeError, ValueError):
return None
async def get_state_data(
self, session: dict[str, Any] | None, state: str
) -> dict[str, Any]:
key = f"_state_{self.name}_{state}"
if self.cache:
# require a session-bound marker to prove the callback originates
# from the user-agent that started the flow (RFC 6749 §10.12)
if session is None or session.get(key) is None:
return None
value = await self._get_cache_data(key)
elif session is not None:
value = session.get(key)
else:
value = None
if value:
return value.get("data")
return None
async def set_state_data(
self, session: dict[str, Any] | None, state: str, data: Any
):
key_prefix = f"_state_{self.name}_"
key = f"{key_prefix}{state}"
now = time.time()
if self.cache:
await self.cache.set(key, json.dumps({"data": data}), self.expires_in)
if session is not None:
# clear old state data to avoid session size growing
for old_key in list(session.keys()):
if old_key.startswith(key_prefix):
session.pop(old_key)
session[key] = {"exp": now + self.expires_in}
elif session is not None:
# clear old state data to avoid session size growing
for old_key in list(session.keys()):
if old_key.startswith(key_prefix):
session.pop(old_key)
session[key] = {"data": data, "exp": now + self.expires_in}
async def clear_state_data(self, session: dict[str, Any] | None, state: str):
key = f"_state_{self.name}_{state}"
if self.cache:
await self.cache.delete(key)
if session is not None:
session.pop(key, None)
self._clear_session_state(session)
def update_token(self, token, refresh_token=None, access_token=None):
pass
@staticmethod
def load_config(oauth, name, params):
if not oauth.config:
return {}
rv = {}
for k in params:
conf_key = f"{name}_{k}".upper()
v = oauth.config.get(conf_key, default=None)
if v is not None:
rv[k] = v
return rv
@@ -0,0 +1,96 @@
"""authlib.jose
~~~~~~~~~~~~
JOSE implementation in Authlib. Tracking the status of JOSE specs at
https://tools.ietf.org/wg/jose/
"""
from authlib.deprecate import deprecate
from .errors import JoseError
from .rfc7515 import JsonWebSignature
from .rfc7515 import JWSAlgorithm
from .rfc7515 import JWSHeader
from .rfc7515 import JWSObject
from .rfc7516 import JsonWebEncryption
from .rfc7516 import JWEAlgorithm
from .rfc7516 import JWEEncAlgorithm
from .rfc7516 import JWEZipAlgorithm
from .rfc7517 import JsonWebKey
from .rfc7517 import Key
from .rfc7517 import KeySet
from .rfc7518 import ECDHESAlgorithm
from .rfc7518 import ECKey
from .rfc7518 import OctKey
from .rfc7518 import RSAKey
from .rfc7518 import register_jwe_rfc7518
from .rfc7518 import register_jws_rfc7518
from .rfc7519 import BaseClaims
from .rfc7519 import JsonWebToken
from .rfc7519 import JWTClaims
from .rfc8037 import OKPKey
from .rfc8037 import register_jws_rfc8037
deprecate(
"authlib.jose module is deprecated, please use joserfc instead.", version="2.0.0"
)
# register algorithms
register_jws_rfc7518(JsonWebSignature)
register_jws_rfc8037(JsonWebSignature)
register_jwe_rfc7518(JsonWebEncryption)
# attach algorithms
ECDHESAlgorithm.ALLOWED_KEY_CLS = (ECKey, OKPKey)
# register supported keys
JsonWebKey.JWK_KEY_CLS = {
OctKey.kty: OctKey,
RSAKey.kty: RSAKey,
ECKey.kty: ECKey,
OKPKey.kty: OKPKey,
}
jwt = JsonWebToken(
[
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"ES256",
"ES256K",
"ES384",
"ES512",
"PS256",
"PS384",
"PS512",
"EdDSA",
]
)
__all__ = [
"JoseError",
"JsonWebSignature",
"JWSAlgorithm",
"JWSHeader",
"JWSObject",
"JsonWebEncryption",
"JWEAlgorithm",
"JWEEncAlgorithm",
"JWEZipAlgorithm",
"JsonWebKey",
"Key",
"KeySet",
"OctKey",
"RSAKey",
"ECKey",
"OKPKey",
"JsonWebToken",
"BaseClaims",
"JWTClaims",
"jwt",
]
@@ -0,0 +1,19 @@
from ._jwe_algorithms import JWE_DRAFT_ALG_ALGORITHMS
from ._jwe_enc_cryptography import C20PEncAlgorithm
try:
from ._jwe_enc_cryptodome import XC20PEncAlgorithm
except ImportError:
XC20PEncAlgorithm = None
def register_jwe_draft(cls):
for alg in JWE_DRAFT_ALG_ALGORITHMS:
cls.register_algorithm(alg)
cls.register_algorithm(C20PEncAlgorithm(256)) # C20P
if XC20PEncAlgorithm is not None:
cls.register_algorithm(XC20PEncAlgorithm(256)) # XC20P
__all__ = ["register_jwe_draft"]
@@ -0,0 +1,216 @@
import struct
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.concatkdf import ConcatKDFHash
from authlib.jose.errors import InvalidEncryptionAlgorithmForECDH1PUWithKeyWrappingError
from authlib.jose.rfc7516 import JWEAlgorithmWithTagAwareKeyAgreement
from authlib.jose.rfc7518 import AESAlgorithm
from authlib.jose.rfc7518 import CBCHS2EncAlgorithm
from authlib.jose.rfc7518 import ECKey
from authlib.jose.rfc7518 import u32be_len_input
from authlib.jose.rfc8037 import OKPKey
class ECDH1PUAlgorithm(JWEAlgorithmWithTagAwareKeyAgreement):
EXTRA_HEADERS = ["epk", "apu", "apv", "skid"]
ALLOWED_KEY_CLS = (ECKey, OKPKey)
# https://datatracker.ietf.org/doc/html/draft-madden-jose-ecdh-1pu-04
def __init__(self, key_size=None):
if key_size is None:
self.name = "ECDH-1PU"
self.description = "ECDH-1PU in the Direct Key Agreement mode"
else:
self.name = f"ECDH-1PU+A{key_size}KW"
self.description = (
f"ECDH-1PU using Concat KDF and CEK wrapped with A{key_size}KW"
)
self.key_size = key_size
self.aeskw = AESAlgorithm(key_size)
def prepare_key(self, raw_data):
if isinstance(raw_data, self.ALLOWED_KEY_CLS):
return raw_data
return ECKey.import_key(raw_data)
def generate_preset(self, enc_alg, key):
epk = self._generate_ephemeral_key(key)
h = self._prepare_headers(epk)
preset = {"epk": epk, "header": h}
if self.key_size is not None:
cek = enc_alg.generate_cek()
preset["cek"] = cek
return preset
def compute_shared_key(self, shared_key_e, shared_key_s):
return shared_key_e + shared_key_s
def compute_fixed_info(self, headers, bit_size, tag):
if tag is None:
cctag = b""
else:
cctag = u32be_len_input(tag)
# AlgorithmID
if self.key_size is None:
alg_id = u32be_len_input(headers["enc"])
else:
alg_id = u32be_len_input(headers["alg"])
# PartyUInfo
apu_info = u32be_len_input(headers.get("apu"), True)
# PartyVInfo
apv_info = u32be_len_input(headers.get("apv"), True)
# SuppPubInfo
pub_info = struct.pack(">I", bit_size) + cctag
return alg_id + apu_info + apv_info + pub_info
def compute_derived_key(self, shared_key, fixed_info, bit_size):
ckdf = ConcatKDFHash(
algorithm=hashes.SHA256(),
length=bit_size // 8,
otherinfo=fixed_info,
backend=default_backend(),
)
return ckdf.derive(shared_key)
def deliver_at_sender(
self,
sender_static_key,
sender_ephemeral_key,
recipient_pubkey,
headers,
bit_size,
tag,
):
shared_key_s = sender_static_key.exchange_shared_key(recipient_pubkey)
shared_key_e = sender_ephemeral_key.exchange_shared_key(recipient_pubkey)
shared_key = self.compute_shared_key(shared_key_e, shared_key_s)
fixed_info = self.compute_fixed_info(headers, bit_size, tag)
return self.compute_derived_key(shared_key, fixed_info, bit_size)
def deliver_at_recipient(
self,
recipient_key,
sender_static_pubkey,
sender_ephemeral_pubkey,
headers,
bit_size,
tag,
):
shared_key_s = recipient_key.exchange_shared_key(sender_static_pubkey)
shared_key_e = recipient_key.exchange_shared_key(sender_ephemeral_pubkey)
shared_key = self.compute_shared_key(shared_key_e, shared_key_s)
fixed_info = self.compute_fixed_info(headers, bit_size, tag)
return self.compute_derived_key(shared_key, fixed_info, bit_size)
def _generate_ephemeral_key(self, key):
return key.generate_key(key["crv"], is_private=True)
def _prepare_headers(self, epk):
# REQUIRED_JSON_FIELDS contains only public fields
pub_epk = {k: epk[k] for k in epk.REQUIRED_JSON_FIELDS}
pub_epk["kty"] = epk.kty
return {"epk": pub_epk}
def generate_keys_and_prepare_headers(self, enc_alg, key, sender_key, preset=None):
if not isinstance(enc_alg, CBCHS2EncAlgorithm):
raise InvalidEncryptionAlgorithmForECDH1PUWithKeyWrappingError()
if preset and "epk" in preset:
epk = preset["epk"]
h = {}
else:
epk = self._generate_ephemeral_key(key)
h = self._prepare_headers(epk)
if preset and "cek" in preset:
cek = preset["cek"]
else:
cek = enc_alg.generate_cek()
return {"epk": epk, "cek": cek, "header": h}
def _agree_upon_key_at_sender(
self, enc_alg, headers, key, sender_key, epk, tag=None
):
if self.key_size is None:
bit_size = enc_alg.CEK_SIZE
else:
bit_size = self.key_size
public_key = key.get_op_key("wrapKey")
return self.deliver_at_sender(
sender_key, epk, public_key, headers, bit_size, tag
)
def _wrap_cek(self, cek, dk):
kek = self.aeskw.prepare_key(dk)
return self.aeskw.wrap_cek(cek, kek)
def agree_upon_key_and_wrap_cek(
self, enc_alg, headers, key, sender_key, epk, cek, tag
):
dk = self._agree_upon_key_at_sender(enc_alg, headers, key, sender_key, epk, tag)
return self._wrap_cek(cek, dk)
def wrap(self, enc_alg, headers, key, sender_key, preset=None):
# In this class this method is used in direct key agreement mode only
if self.key_size is not None:
raise RuntimeError("Invalid algorithm state detected")
if preset and "epk" in preset:
epk = preset["epk"]
h = {}
else:
epk = self._generate_ephemeral_key(key)
h = self._prepare_headers(epk)
dk = self._agree_upon_key_at_sender(enc_alg, headers, key, sender_key, epk)
return {"ek": b"", "cek": dk, "header": h}
def unwrap(self, enc_alg, ek, headers, key, sender_key, tag=None):
if "epk" not in headers:
raise ValueError('Missing "epk" in headers')
if self.key_size is None:
bit_size = enc_alg.CEK_SIZE
else:
bit_size = self.key_size
sender_pubkey = sender_key.get_op_key("wrapKey")
epk = key.import_key(headers["epk"])
epk_pubkey = epk.get_op_key("wrapKey")
dk = self.deliver_at_recipient(
key, sender_pubkey, epk_pubkey, headers, bit_size, tag
)
if self.key_size is None:
return dk
kek = self.aeskw.prepare_key(dk)
return self.aeskw.unwrap(enc_alg, ek, headers, kek)
JWE_DRAFT_ALG_ALGORITHMS = [
ECDH1PUAlgorithm(None), # ECDH-1PU
ECDH1PUAlgorithm(128), # ECDH-1PU+A128KW
ECDH1PUAlgorithm(192), # ECDH-1PU+A192KW
ECDH1PUAlgorithm(256), # ECDH-1PU+A256KW
]
def register_jwe_alg_draft(cls):
for alg in JWE_DRAFT_ALG_ALGORITHMS:
cls.register_algorithm(alg)
@@ -0,0 +1,53 @@
"""authlib.jose.draft.
~~~~~~~~~~~~~~~~~~~~
Content Encryption per `Section 4`_.
.. _`Section 4`: https://datatracker.ietf.org/doc/html/draft-amringer-jose-chacha-02#section-4
"""
from Cryptodome.Cipher import ChaCha20_Poly1305 as Cryptodome_ChaCha20_Poly1305
from authlib.jose.rfc7516 import JWEEncAlgorithm
class XC20PEncAlgorithm(JWEEncAlgorithm):
# Use of an IV of size 192 bits is REQUIRED with this algorithm.
# https://datatracker.ietf.org/doc/html/draft-amringer-jose-chacha-02#section-4.1
IV_SIZE = 192
def __init__(self, key_size):
self.name = "XC20P"
self.description = "XChaCha20-Poly1305"
self.key_size = key_size
self.CEK_SIZE = key_size
def encrypt(self, msg, aad, iv, key):
"""Content Encryption with AEAD_XCHACHA20_POLY1305.
:param msg: text to be encrypt in bytes
:param aad: additional authenticated data in bytes
:param iv: initialization vector in bytes
:param key: encrypted key in bytes
:return: (ciphertext, tag)
"""
self.check_iv(iv)
chacha = Cryptodome_ChaCha20_Poly1305.new(key=key, nonce=iv)
chacha.update(aad)
ciphertext, tag = chacha.encrypt_and_digest(msg)
return ciphertext, tag
def decrypt(self, ciphertext, aad, iv, tag, key):
"""Content Decryption with AEAD_XCHACHA20_POLY1305.
:param ciphertext: ciphertext in bytes
:param aad: additional authenticated data in bytes
:param iv: initialization vector in bytes
:param tag: authentication tag in bytes
:param key: encrypted key in bytes
:return: message
"""
self.check_iv(iv)
chacha = Cryptodome_ChaCha20_Poly1305.new(key=key, nonce=iv)
chacha.update(aad)
return chacha.decrypt_and_verify(ciphertext, tag)
@@ -0,0 +1,51 @@
"""authlib.jose.draft.
~~~~~~~~~~~~~~~~~~~~
Content Encryption per `Section 4`_.
.. _`Section 4`: https://datatracker.ietf.org/doc/html/draft-amringer-jose-chacha-02#section-4
"""
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from authlib.jose.rfc7516 import JWEEncAlgorithm
class C20PEncAlgorithm(JWEEncAlgorithm):
# Use of an IV of size 96 bits is REQUIRED with this algorithm.
# https://datatracker.ietf.org/doc/html/draft-amringer-jose-chacha-02#section-4.1
IV_SIZE = 96
def __init__(self, key_size):
self.name = "C20P"
self.description = "ChaCha20-Poly1305"
self.key_size = key_size
self.CEK_SIZE = key_size
def encrypt(self, msg, aad, iv, key):
"""Content Encryption with AEAD_CHACHA20_POLY1305.
:param msg: text to be encrypt in bytes
:param aad: additional authenticated data in bytes
:param iv: initialization vector in bytes
:param key: encrypted key in bytes
:return: (ciphertext, tag)
"""
self.check_iv(iv)
chacha = ChaCha20Poly1305(key)
ciphertext = chacha.encrypt(iv, msg, aad)
return ciphertext[:-16], ciphertext[-16:]
def decrypt(self, ciphertext, aad, iv, tag, key):
"""Content Decryption with AEAD_CHACHA20_POLY1305.
:param ciphertext: ciphertext in bytes
:param aad: additional authenticated data in bytes
:param iv: initialization vector in bytes
:param tag: authentication tag in bytes
:param key: encrypted key in bytes
:return: message
"""
self.check_iv(iv)
chacha = ChaCha20Poly1305(key)
return chacha.decrypt(iv, ciphertext + tag, aad)
@@ -0,0 +1,120 @@
from authlib.common.errors import AuthlibBaseError
class JoseError(AuthlibBaseError):
pass
class DecodeError(JoseError):
error = "decode_error"
class MissingAlgorithmError(JoseError):
error = "missing_algorithm"
class UnsupportedAlgorithmError(JoseError):
error = "unsupported_algorithm"
class BadSignatureError(JoseError):
error = "bad_signature"
def __init__(self, result):
super().__init__()
self.result = result
class InvalidHeaderParameterNameError(JoseError):
error = "invalid_header_parameter_name"
def __init__(self, name):
description = f"Invalid Header Parameter Name: {name}"
super().__init__(description=description)
class InvalidCritHeaderParameterNameError(JoseError):
error = "invalid_crit_header_parameter_name"
def __init__(self, name):
description = f"Invalid Header Parameter Name: {name}"
super().__init__(description=description)
class InvalidEncryptionAlgorithmForECDH1PUWithKeyWrappingError(JoseError):
error = "invalid_encryption_algorithm_for_ECDH_1PU_with_key_wrapping"
def __init__(self):
description = (
"In key agreement with key wrapping mode ECDH-1PU algorithm "
"only supports AES_CBC_HMAC_SHA2 family encryption algorithms"
)
super().__init__(description=description)
class InvalidAlgorithmForMultipleRecipientsMode(JoseError):
error = "invalid_algorithm_for_multiple_recipients_mode"
def __init__(self, alg):
description = f"{alg} algorithm cannot be used in multiple recipients mode"
super().__init__(description=description)
class KeyMismatchError(JoseError):
error = "key_mismatch_error"
description = "Key does not match to any recipient"
class MissingEncryptionAlgorithmError(JoseError):
error = "missing_encryption_algorithm"
description = "Missing 'enc' in header"
class UnsupportedEncryptionAlgorithmError(JoseError):
error = "unsupported_encryption_algorithm"
description = "Unsupported 'enc' value in header"
class UnsupportedCompressionAlgorithmError(JoseError):
error = "unsupported_compression_algorithm"
description = "Unsupported 'zip' value in header"
class InvalidUseError(JoseError):
error = "invalid_use"
description = "Key 'use' is not valid for your usage"
class InvalidClaimError(JoseError):
error = "invalid_claim"
def __init__(self, claim):
self.claim_name = claim
description = f"Invalid claim '{claim}'"
super().__init__(description=description)
class MissingClaimError(JoseError):
error = "missing_claim"
def __init__(self, claim):
description = f"Missing '{claim}' claim"
super().__init__(description=description)
class InsecureClaimError(JoseError):
error = "insecure_claim"
def __init__(self, claim):
description = f"Insecure claim '{claim}'"
super().__init__(description=description)
class ExpiredTokenError(JoseError):
error = "expired_token"
description = "The token is expired"
class InvalidTokenError(JoseError):
error = "invalid_token"
description = "The token is not valid yet"
@@ -0,0 +1,20 @@
from authlib.deprecate import deprecate
from .rfc7517 import JsonWebKey
def loads(obj, kid=None):
deprecate("Please use ``JsonWebKey`` directly.")
key_set = JsonWebKey.import_key_set(obj)
if key_set:
return key_set.find_by_kid(kid)
return JsonWebKey.import_key(obj)
def dumps(key, kty=None, **params):
deprecate("Please use ``JsonWebKey`` directly.")
if kty:
params["kty"] = kty
key = JsonWebKey.import_key(key, params)
return dict(key)
@@ -0,0 +1,15 @@
"""authlib.jose.rfc7515.
~~~~~~~~~~~~~~~~~~~~~
This module represents a direct implementation of
JSON Web Signature (JWS).
https://tools.ietf.org/html/rfc7515
"""
from .jws import JsonWebSignature
from .models import JWSAlgorithm
from .models import JWSHeader
from .models import JWSObject
__all__ = ["JsonWebSignature", "JWSAlgorithm", "JWSHeader", "JWSObject"]
@@ -0,0 +1,352 @@
from authlib.common.encoding import json_b64encode
from authlib.common.encoding import to_bytes
from authlib.common.encoding import to_unicode
from authlib.common.encoding import urlsafe_b64encode
from authlib.jose.errors import BadSignatureError
from authlib.jose.errors import DecodeError
from authlib.jose.errors import InvalidCritHeaderParameterNameError
from authlib.jose.errors import InvalidHeaderParameterNameError
from authlib.jose.errors import MissingAlgorithmError
from authlib.jose.errors import UnsupportedAlgorithmError
from authlib.jose.util import ensure_dict
from authlib.jose.util import extract_header
from authlib.jose.util import extract_segment
from .models import JWSHeader
from .models import JWSObject
class JsonWebSignature:
#: Registered Header Parameter Names defined by Section 4.1
REGISTERED_HEADER_PARAMETER_NAMES = frozenset(
[
"alg",
"jku",
"jwk",
"kid",
"x5u",
"x5c",
"x5t",
"x5t#S256",
"typ",
"cty",
"crit",
]
)
MAX_CONTENT_LENGTH: int = 256000
#: Defined available JWS algorithms in the registry
ALGORITHMS_REGISTRY = {}
def __init__(self, algorithms=None, private_headers=None):
self._private_headers = private_headers
self._algorithms = algorithms
@classmethod
def register_algorithm(cls, algorithm):
if not algorithm or algorithm.algorithm_type != "JWS":
raise ValueError(f"Invalid algorithm for JWS, {algorithm!r}")
cls.ALGORITHMS_REGISTRY[algorithm.name] = algorithm
def serialize_compact(self, protected, payload, key):
"""Generate a JWS Compact Serialization. The JWS Compact Serialization
represents digitally signed or MACed content as a compact, URL-safe
string, per `Section 7.1`_.
.. code-block:: text
BASE64URL(UTF8(JWS Protected Header)) || '.' ||
BASE64URL(JWS Payload) || '.' ||
BASE64URL(JWS Signature)
:param protected: A dict of protected header
:param payload: A bytes/string of payload
:param key: Private key used to generate signature
:return: byte
"""
jws_header = JWSHeader(protected, None)
self._validate_private_headers(protected)
self._validate_crit_headers(protected)
algorithm, key = self._prepare_algorithm_key(protected, payload, key)
protected_segment = json_b64encode(jws_header.protected)
payload_segment = urlsafe_b64encode(to_bytes(payload))
# calculate signature
signing_input = b".".join([protected_segment, payload_segment])
signature = urlsafe_b64encode(algorithm.sign(signing_input, key))
return b".".join([protected_segment, payload_segment, signature])
def deserialize_compact(self, s, key, decode=None):
"""Exact JWS Compact Serialization, and validate with the given key.
If key is not provided, the returned dict will contain the signature,
and signing input values. Via `Section 7.1`_.
:param s: text of JWS Compact Serialization
:param key: key used to verify the signature
:param decode: a function to decode payload data
:return: JWSObject
:raise: BadSignatureError
.. _`Section 7.1`: https://tools.ietf.org/html/rfc7515#section-7.1
"""
if len(s) > self.MAX_CONTENT_LENGTH:
raise ValueError("Serialization is too long.")
try:
s = to_bytes(s)
signing_input, signature_segment = s.rsplit(b".", 1)
protected_segment, payload_segment = signing_input.split(b".", 1)
except ValueError as exc:
raise DecodeError("Not enough segments") from exc
protected = _extract_header(protected_segment)
self._validate_crit_headers(protected)
jws_header = JWSHeader(protected, None)
payload = _extract_payload(payload_segment)
if decode:
payload = decode(payload)
signature = _extract_signature(signature_segment)
rv = JWSObject(jws_header, payload, "compact")
algorithm, key = self._prepare_algorithm_key(jws_header, payload, key)
if algorithm.verify(signing_input, signature, key):
return rv
raise BadSignatureError(rv)
def serialize_json(self, header_obj, payload, key):
"""Generate a JWS JSON Serialization. The JWS JSON Serialization
represents digitally signed or MACed content as a JSON object,
per `Section 7.2`_.
:param header_obj: A dict/list of header
:param payload: A string/dict of payload
:param key: Private key used to generate signature
:return: JWSObject
Example ``header_obj`` of JWS JSON Serialization::
{
"protected: {"alg": "HS256"},
"header": {"kid": "jose"}
}
Pass a dict to generate flattened JSON Serialization, pass a list of
header dict to generate standard JSON Serialization.
"""
payload_segment = json_b64encode(payload)
def _sign(jws_header):
self._validate_private_headers(jws_header)
# RFC 7515 §4.1.11: 'crit' MUST be integrity-protected.
# Reject if present in unprotected header, and validate only
# against the protected header parameters.
self._reject_unprotected_crit(jws_header.header)
self._validate_crit_headers(jws_header.protected)
_alg, _key = self._prepare_algorithm_key(jws_header, payload, key)
protected_segment = json_b64encode(jws_header.protected)
signing_input = b".".join([protected_segment, payload_segment])
signature = urlsafe_b64encode(_alg.sign(signing_input, _key))
rv = {
"protected": to_unicode(protected_segment),
"signature": to_unicode(signature),
}
if jws_header.header is not None:
rv["header"] = jws_header.header
return rv
if isinstance(header_obj, dict):
data = _sign(JWSHeader.from_dict(header_obj))
data["payload"] = to_unicode(payload_segment)
return data
signatures = [_sign(JWSHeader.from_dict(h)) for h in header_obj]
return {"payload": to_unicode(payload_segment), "signatures": signatures}
def deserialize_json(self, obj, key, decode=None):
"""Exact JWS JSON Serialization, and validate with the given key.
If key is not provided, it will return a dict without signature
verification. Header will still be validated. Via `Section 7.2`_.
:param obj: text of JWS JSON Serialization
:param key: key used to verify the signature
:param decode: a function to decode payload data
:return: JWSObject
:raise: BadSignatureError
.. _`Section 7.2`: https://tools.ietf.org/html/rfc7515#section-7.2
"""
obj = ensure_dict(obj, "JWS")
payload_segment = obj.get("payload")
if payload_segment is None:
raise DecodeError('Missing "payload" value')
payload_segment = to_bytes(payload_segment)
payload = _extract_payload(payload_segment)
if decode:
payload = decode(payload)
if "signatures" not in obj:
# flattened JSON JWS
jws_header, valid = self._validate_json_jws(
payload_segment, payload, obj, key
)
rv = JWSObject(jws_header, payload, "flat")
if valid:
return rv
raise BadSignatureError(rv)
headers = []
is_valid = True
for header_obj in obj["signatures"]:
jws_header, valid = self._validate_json_jws(
payload_segment, payload, header_obj, key
)
headers.append(jws_header)
if not valid:
is_valid = False
rv = JWSObject(headers, payload, "json")
if is_valid:
return rv
raise BadSignatureError(rv)
def serialize(self, header, payload, key):
"""Generate a JWS Serialization. It will automatically generate a
Compact or JSON Serialization depending on the given header. If a
header is in a JSON header format, it will call
:meth:`serialize_json`, otherwise it will call
:meth:`serialize_compact`.
:param header: A dict/list of header
:param payload: A string/dict of payload
:param key: Private key used to generate signature
:return: byte/dict
"""
if isinstance(header, (list, tuple)):
return self.serialize_json(header, payload, key)
if "protected" in header:
return self.serialize_json(header, payload, key)
return self.serialize_compact(header, payload, key)
def deserialize(self, s, key, decode=None):
"""Deserialize JWS Serialization, both compact and JSON format.
It will automatically deserialize depending on the given JWS.
:param s: text of JWS Compact/JSON Serialization
:param key: key used to verify the signature
:param decode: a function to decode payload data
:return: dict
:raise: BadSignatureError
If key is not provided, it will still deserialize the serialization
without verification.
"""
if isinstance(s, dict):
return self.deserialize_json(s, key, decode)
s = to_bytes(s)
if s.startswith(b"{") and s.endswith(b"}"):
return self.deserialize_json(s, key, decode)
return self.deserialize_compact(s, key, decode)
def _prepare_algorithm_key(self, header, payload, key):
if "alg" not in header:
raise MissingAlgorithmError()
alg = header["alg"]
if alg not in self.ALGORITHMS_REGISTRY:
raise UnsupportedAlgorithmError()
algorithm = self.ALGORITHMS_REGISTRY[alg]
if self._algorithms is None:
if algorithm.deprecated:
raise UnsupportedAlgorithmError()
elif alg not in self._algorithms:
raise UnsupportedAlgorithmError()
if callable(key):
key = key(header, payload)
key = algorithm.prepare_key(key)
return algorithm, key
def _validate_private_headers(self, header):
# only validate private headers when developers set
# private headers explicitly
if self._private_headers is not None:
names = self.REGISTERED_HEADER_PARAMETER_NAMES.copy()
names = names.union(self._private_headers)
for k in header:
if k not in names:
raise InvalidHeaderParameterNameError(k)
def _reject_unprotected_crit(self, unprotected_header):
"""Reject 'crit' when found in the unprotected header (RFC 7515 §4.1.11)."""
if unprotected_header and "crit" in unprotected_header:
raise InvalidHeaderParameterNameError("crit")
def _validate_crit_headers(self, header):
if "crit" in header:
crit_headers = header["crit"]
# Type enforcement for robustness and predictable errors
if not isinstance(crit_headers, list) or not all(
isinstance(x, str) for x in crit_headers
):
raise InvalidHeaderParameterNameError("crit")
names = self.REGISTERED_HEADER_PARAMETER_NAMES.copy()
if self._private_headers:
names = names.union(self._private_headers)
for k in crit_headers:
if k not in names:
raise InvalidCritHeaderParameterNameError(k)
elif k not in header:
raise InvalidCritHeaderParameterNameError(k)
def _validate_json_jws(self, payload_segment, payload, header_obj, key):
protected_segment = header_obj.get("protected")
if not protected_segment:
raise DecodeError('Missing "protected" value')
signature_segment = header_obj.get("signature")
if not signature_segment:
raise DecodeError('Missing "signature" value')
protected_segment = to_bytes(protected_segment)
protected = _extract_header(protected_segment)
header = header_obj.get("header")
if header and not isinstance(header, dict):
raise DecodeError('Invalid "header" value')
# RFC 7515 §4.1.11: 'crit' MUST be integrity-protected. If present in
# the unprotected header object, reject the JWS.
self._reject_unprotected_crit(header)
# Enforce must-understand semantics for names listed in protected
# 'crit'. This will also ensure each listed name is present in the
# protected header.
self._validate_crit_headers(protected)
jws_header = JWSHeader(protected, header)
algorithm, key = self._prepare_algorithm_key(jws_header, payload, key)
signing_input = b".".join([protected_segment, payload_segment])
signature = _extract_signature(to_bytes(signature_segment))
if algorithm.verify(signing_input, signature, key):
return jws_header, True
return jws_header, False
def _extract_header(header_segment):
return extract_header(header_segment, DecodeError)
def _extract_signature(signature_segment):
return extract_segment(signature_segment, DecodeError, "signature")
def _extract_payload(payload_segment):
return extract_segment(payload_segment, DecodeError, "payload")
@@ -0,0 +1,85 @@
class JWSAlgorithm:
"""Interface for JWS algorithm. JWA specification (RFC7518) SHOULD
implement the algorithms for JWS with this base implementation.
"""
name = None
description = None
deprecated = False
algorithm_type = "JWS"
algorithm_location = "alg"
def prepare_key(self, raw_data):
"""Prepare key for signing and verifying signature."""
raise NotImplementedError()
def sign(self, msg, key):
"""Sign the text msg with a private/sign key.
:param msg: message bytes to be signed
:param key: private key to sign the message
:return: bytes
"""
raise NotImplementedError
def verify(self, msg, sig, key):
"""Verify the signature of text msg with a public/verify key.
:param msg: message bytes to be signed
:param sig: result signature to be compared
:param key: public key to verify the signature
:return: boolean
"""
raise NotImplementedError
class JWSHeader(dict):
"""Header object for JWS. It combine the protected header and unprotected
header together. JWSHeader itself is a dict of the combined dict. e.g.
>>> protected = {"alg": "HS256"}
>>> header = {"kid": "a"}
>>> jws_header = JWSHeader(protected, header)
>>> print(jws_header)
{'alg': 'HS256', 'kid': 'a'}
>>> jws_header.protected == protected
>>> jws_header.header == header
:param protected: dict of protected header
:param header: dict of unprotected header
"""
def __init__(self, protected, header):
obj = {}
if header:
obj.update(header)
if protected:
obj.update(protected)
super().__init__(obj)
self.protected = protected
self.header = header
@classmethod
def from_dict(cls, obj):
if isinstance(obj, cls):
return obj
return cls(obj.get("protected"), obj.get("header"))
class JWSObject(dict):
"""A dict instance to represent a JWS object."""
def __init__(self, header, payload, type="compact"):
super().__init__(
header=header,
payload=payload,
)
self.header = header
self.payload = payload
self.type = type
@property
def headers(self):
"""Alias of ``header`` for JSON typed JWS."""
if self.type == "json":
return self["header"]
@@ -0,0 +1,22 @@
"""authlib.jose.rfc7516.
~~~~~~~~~~~~~~~~~~~~~
This module represents a direct implementation of
JSON Web Encryption (JWE).
https://tools.ietf.org/html/rfc7516
"""
from .jwe import JsonWebEncryption
from .models import JWEAlgorithm
from .models import JWEAlgorithmWithTagAwareKeyAgreement
from .models import JWEEncAlgorithm
from .models import JWEZipAlgorithm
__all__ = [
"JsonWebEncryption",
"JWEAlgorithm",
"JWEAlgorithmWithTagAwareKeyAgreement",
"JWEEncAlgorithm",
"JWEZipAlgorithm",
]
@@ -0,0 +1,765 @@
from collections import OrderedDict
from copy import deepcopy
from authlib.common.encoding import json_b64encode
from authlib.common.encoding import to_bytes
from authlib.common.encoding import to_unicode
from authlib.common.encoding import urlsafe_b64encode
from authlib.jose.errors import DecodeError
from authlib.jose.errors import InvalidAlgorithmForMultipleRecipientsMode
from authlib.jose.errors import InvalidHeaderParameterNameError
from authlib.jose.errors import KeyMismatchError
from authlib.jose.errors import MissingAlgorithmError
from authlib.jose.errors import MissingEncryptionAlgorithmError
from authlib.jose.errors import UnsupportedAlgorithmError
from authlib.jose.errors import UnsupportedCompressionAlgorithmError
from authlib.jose.errors import UnsupportedEncryptionAlgorithmError
from authlib.jose.rfc7516.models import JWEAlgorithmWithTagAwareKeyAgreement
from authlib.jose.rfc7516.models import JWEHeader
from authlib.jose.rfc7516.models import JWESharedHeader
from authlib.jose.util import ensure_dict
from authlib.jose.util import extract_header
from authlib.jose.util import extract_segment
class JsonWebEncryption:
#: Registered Header Parameter Names defined by Section 4.1
REGISTERED_HEADER_PARAMETER_NAMES = frozenset(
[
"alg",
"enc",
"zip",
"jku",
"jwk",
"kid",
"x5u",
"x5c",
"x5t",
"x5t#S256",
"typ",
"cty",
"crit",
]
)
ALG_REGISTRY = {}
ENC_REGISTRY = {}
ZIP_REGISTRY = {}
def __init__(self, algorithms=None, private_headers=None):
self._algorithms = algorithms
self._private_headers = private_headers
@classmethod
def register_algorithm(cls, algorithm):
"""Register an algorithm for ``alg`` or ``enc`` or ``zip`` of JWE."""
if not algorithm or algorithm.algorithm_type != "JWE":
raise ValueError(f"Invalid algorithm for JWE, {algorithm!r}")
if algorithm.algorithm_location == "alg":
cls.ALG_REGISTRY[algorithm.name] = algorithm
elif algorithm.algorithm_location == "enc":
cls.ENC_REGISTRY[algorithm.name] = algorithm
elif algorithm.algorithm_location == "zip":
cls.ZIP_REGISTRY[algorithm.name] = algorithm
def serialize_compact(self, protected, payload, key, sender_key=None):
"""Generate a JWE Compact Serialization.
The JWE Compact Serialization represents encrypted content as a compact,
URL-safe string. This string is::
BASE64URL(UTF8(JWE Protected Header)) || '.' ||
BASE64URL(JWE Encrypted Key) || '.' ||
BASE64URL(JWE Initialization Vector) || '.' ||
BASE64URL(JWE Ciphertext) || '.' ||
BASE64URL(JWE Authentication Tag)
Only one recipient is supported by the JWE Compact Serialization and
it provides no syntax to represent JWE Shared Unprotected Header, JWE
Per-Recipient Unprotected Header, or JWE AAD values.
:param protected: A dict of protected header
:param payload: Payload (bytes or a value convertible to bytes)
:param key: Public key used to encrypt payload
:param sender_key: Sender's private key in case
JWEAlgorithmWithTagAwareKeyAgreement is used
:return: JWE compact serialization as bytes
"""
# step 1: Prepare algorithms & key
alg = self.get_header_alg(protected)
enc = self.get_header_enc(protected)
zip_alg = self.get_header_zip(protected)
self._validate_sender_key(sender_key, alg)
self._validate_private_headers(protected, alg)
key = prepare_key(alg, protected, key)
if sender_key is not None:
sender_key = alg.prepare_key(sender_key)
# self._post_validate_header(protected, algorithm)
# step 2: Generate a random Content Encryption Key (CEK)
# use enc_alg.generate_cek() in scope of upcoming .wrap
# or .generate_keys_and_prepare_headers call
# step 3: Encrypt the CEK with the recipient's public key
if (
isinstance(alg, JWEAlgorithmWithTagAwareKeyAgreement)
and alg.key_size is not None
):
# For a JWE algorithm with tag-aware key agreement in case key agreement
# with key wrapping mode is used:
# Defer key agreement with key wrapping until
# authentication tag is computed
prep = alg.generate_keys_and_prepare_headers(enc, key, sender_key)
epk = prep["epk"]
cek = prep["cek"]
protected.update(prep["header"])
else:
# In any other case:
# Keep the normal steps order defined by RFC 7516
if isinstance(alg, JWEAlgorithmWithTagAwareKeyAgreement):
wrapped = alg.wrap(enc, protected, key, sender_key)
else:
wrapped = alg.wrap(enc, protected, key)
cek = wrapped["cek"]
ek = wrapped["ek"]
if "header" in wrapped:
protected.update(wrapped["header"])
# step 4: Generate a random JWE Initialization Vector
iv = enc.generate_iv()
# step 5: Let the Additional Authenticated Data encryption parameter
# be ASCII(BASE64URL(UTF8(JWE Protected Header)))
protected_segment = json_b64encode(protected)
aad = to_bytes(protected_segment, "ascii")
# step 6: compress message if required
if zip_alg:
msg = zip_alg.compress(to_bytes(payload))
else:
msg = to_bytes(payload)
# step 7: perform encryption
ciphertext, tag = enc.encrypt(msg, aad, iv, cek)
if (
isinstance(alg, JWEAlgorithmWithTagAwareKeyAgreement)
and alg.key_size is not None
):
# For a JWE algorithm with tag-aware key agreement in case key agreement
# with key wrapping mode is used:
# Perform key agreement with key wrapping deferred at step 3
wrapped = alg.agree_upon_key_and_wrap_cek(
enc, protected, key, sender_key, epk, cek, tag
)
ek = wrapped["ek"]
# step 8: build resulting message
return b".".join(
[
protected_segment,
urlsafe_b64encode(ek),
urlsafe_b64encode(iv),
urlsafe_b64encode(ciphertext),
urlsafe_b64encode(tag),
]
)
def serialize_json(self, header_obj, payload, keys, sender_key=None): # noqa: C901
"""Generate a JWE JSON Serialization (in fully general syntax).
The JWE JSON Serialization represents encrypted content as a JSON
object. This representation is neither optimized for compactness nor
URL safe.
The following members are defined for use in top-level JSON objects
used for the fully general JWE JSON Serialization syntax:
protected
The "protected" member MUST be present and contain the value
BASE64URL(UTF8(JWE Protected Header)) when the JWE Protected
Header value is non-empty; otherwise, it MUST be absent. These
Header Parameter values are integrity protected.
unprotected
The "unprotected" member MUST be present and contain the value JWE
Shared Unprotected Header when the JWE Shared Unprotected Header
value is non-empty; otherwise, it MUST be absent. This value is
represented as an unencoded JSON object, rather than as a string.
These Header Parameter values are not integrity protected.
iv
The "iv" member MUST be present and contain the value
BASE64URL(JWE Initialization Vector) when the JWE Initialization
Vector value is non-empty; otherwise, it MUST be absent.
aad
The "aad" member MUST be present and contain the value
BASE64URL(JWE AAD)) when the JWE AAD value is non-empty;
otherwise, it MUST be absent. A JWE AAD value can be included to
supply a base64url-encoded value to be integrity protected but not
encrypted.
ciphertext
The "ciphertext" member MUST be present and contain the value
BASE64URL(JWE Ciphertext).
tag
The "tag" member MUST be present and contain the value
BASE64URL(JWE Authentication Tag) when the JWE Authentication Tag
value is non-empty; otherwise, it MUST be absent.
recipients
The "recipients" member value MUST be an array of JSON objects.
Each object contains information specific to a single recipient.
This member MUST be present with exactly one array element per
recipient, even if some or all of the array element values are the
empty JSON object "{}" (which can happen when all Header Parameter
values are shared between all recipients and when no encrypted key
is used, such as when doing Direct Encryption).
The following members are defined for use in the JSON objects that
are elements of the "recipients" array:
header
The "header" member MUST be present and contain the value JWE Per-
Recipient Unprotected Header when the JWE Per-Recipient
Unprotected Header value is non-empty; otherwise, it MUST be
absent. This value is represented as an unencoded JSON object,
rather than as a string. These Header Parameter values are not
integrity protected.
encrypted_key
The "encrypted_key" member MUST be present and contain the value
BASE64URL(JWE Encrypted Key) when the JWE Encrypted Key value is
non-empty; otherwise, it MUST be absent.
This implementation assumes that "alg" and "enc" header fields are
contained in the protected or shared unprotected header.
:param header_obj: A dict of headers (in addition optionally contains JWE AAD)
:param payload: Payload (bytes or a value convertible to bytes)
:param keys: Public keys (or a single public key) used to encrypt payload
:param sender_key: Sender's private key in case
JWEAlgorithmWithTagAwareKeyAgreement is used
:return: JWE JSON serialization (in fully general syntax) as dict
Example of `header_obj`::
{
"protected": {
"alg": "ECDH-1PU+A128KW",
"enc": "A256CBC-HS512",
"apu": "QWxpY2U",
"apv": "Qm9iIGFuZCBDaGFybGll",
},
"unprotected": {"jku": "https://alice.example.com/keys.jwks"},
"recipients": [
{"header": {"kid": "bob-key-2"}},
{"header": {"kid": "2021-05-06"}},
],
"aad": b"Authenticate me too.",
}
"""
if not isinstance(keys, list): # single key
keys = [keys]
if not keys:
raise ValueError("No keys have been provided")
header_obj = deepcopy(header_obj)
shared_header = JWESharedHeader.from_dict(header_obj)
recipients = header_obj.get("recipients")
if recipients is None:
recipients = [{} for _ in keys]
for i in range(len(recipients)):
if recipients[i] is None:
recipients[i] = {}
if "header" not in recipients[i]:
recipients[i]["header"] = {}
jwe_aad = header_obj.get("aad")
if len(keys) != len(recipients):
raise ValueError(
f"Count of recipient keys {len(keys)} does not equal to count of recipients {len(recipients)}"
)
# step 1: Prepare algorithms & key
alg = self.get_header_alg(shared_header)
enc = self.get_header_enc(shared_header)
zip_alg = self.get_header_zip(shared_header)
self._validate_sender_key(sender_key, alg)
self._validate_private_headers(shared_header, alg)
for recipient in recipients:
self._validate_private_headers(recipient["header"], alg)
for i in range(len(keys)):
keys[i] = prepare_key(alg, recipients[i]["header"], keys[i])
if sender_key is not None:
sender_key = alg.prepare_key(sender_key)
# self._post_validate_header(protected, algorithm)
# step 2: Generate a random Content Encryption Key (CEK)
# use enc_alg.generate_cek() in scope of upcoming .wrap
# or .generate_keys_and_prepare_headers call
# step 3: Encrypt the CEK with the recipient's public key
preset = alg.generate_preset(enc, keys[0])
if "cek" in preset:
cek = preset["cek"]
else:
cek = None
if len(keys) > 1 and cek is None:
raise InvalidAlgorithmForMultipleRecipientsMode(alg.name)
if "header" in preset:
shared_header.update_protected(preset["header"])
if (
isinstance(alg, JWEAlgorithmWithTagAwareKeyAgreement)
and alg.key_size is not None
):
# For a JWE algorithm with tag-aware key agreement in case key agreement
# with key wrapping mode is used:
# Defer key agreement with key wrapping until authentication tag is computed
epks = []
for i in range(len(keys)):
prep = alg.generate_keys_and_prepare_headers(
enc, keys[i], sender_key, preset
)
if cek is None:
cek = prep["cek"]
epks.append(prep["epk"])
recipients[i]["header"].update(prep["header"])
else:
# In any other case:
# Keep the normal steps order defined by RFC 7516
for i in range(len(keys)):
if isinstance(alg, JWEAlgorithmWithTagAwareKeyAgreement):
wrapped = alg.wrap(enc, shared_header, keys[i], sender_key, preset)
else:
wrapped = alg.wrap(enc, shared_header, keys[i], preset)
if cek is None:
cek = wrapped["cek"]
recipients[i]["encrypted_key"] = wrapped["ek"]
if "header" in wrapped:
recipients[i]["header"].update(wrapped["header"])
# step 4: Generate a random JWE Initialization Vector
iv = enc.generate_iv()
# step 5: Compute the Encoded Protected Header value
# BASE64URL(UTF8(JWE Protected Header)). If the JWE Protected Header
# is not present, let this value be the empty string.
# Let the Additional Authenticated Data encryption parameter be
# ASCII(Encoded Protected Header). However, if a JWE AAD value is
# present, instead let the Additional Authenticated Data encryption
# parameter be ASCII(Encoded Protected Header || '.' || BASE64URL(JWE AAD)).
aad = (
json_b64encode(shared_header.protected) if shared_header.protected else b""
)
if jwe_aad is not None:
aad += b"." + urlsafe_b64encode(jwe_aad)
aad = to_bytes(aad, "ascii")
# step 6: compress message if required
if zip_alg:
msg = zip_alg.compress(to_bytes(payload))
else:
msg = to_bytes(payload)
# step 7: perform encryption
ciphertext, tag = enc.encrypt(msg, aad, iv, cek)
if (
isinstance(alg, JWEAlgorithmWithTagAwareKeyAgreement)
and alg.key_size is not None
):
# For a JWE algorithm with tag-aware key agreement in case key agreement
# with key wrapping mode is used:
# Perform key agreement with key wrapping deferred at step 3
for i in range(len(keys)):
wrapped = alg.agree_upon_key_and_wrap_cek(
enc, shared_header, keys[i], sender_key, epks[i], cek, tag
)
recipients[i]["encrypted_key"] = wrapped["ek"]
# step 8: build resulting message
obj = OrderedDict()
if shared_header.protected:
obj["protected"] = to_unicode(json_b64encode(shared_header.protected))
if shared_header.unprotected:
obj["unprotected"] = shared_header.unprotected
for recipient in recipients:
if not recipient["header"]:
del recipient["header"]
recipient["encrypted_key"] = to_unicode(
urlsafe_b64encode(recipient["encrypted_key"])
)
for member in set(recipient.keys()):
if member not in {"header", "encrypted_key"}:
del recipient[member]
obj["recipients"] = recipients
if jwe_aad is not None:
obj["aad"] = to_unicode(urlsafe_b64encode(jwe_aad))
obj["iv"] = to_unicode(urlsafe_b64encode(iv))
obj["ciphertext"] = to_unicode(urlsafe_b64encode(ciphertext))
obj["tag"] = to_unicode(urlsafe_b64encode(tag))
return obj
def serialize(self, header, payload, key, sender_key=None):
"""Generate a JWE Serialization.
It will automatically generate a compact or JSON serialization depending
on `header` argument. If `header` is a dict with "protected",
"unprotected" and/or "recipients" keys, it will call `serialize_json`,
otherwise it will call `serialize_compact`.
:param header: A dict of header(s)
:param payload: Payload (bytes or a value convertible to bytes)
:param key: Public key(s) used to encrypt payload
:param sender_key: Sender's private key in case
JWEAlgorithmWithTagAwareKeyAgreement is used
:return: JWE compact serialization as bytes or
JWE JSON serialization as dict
"""
if "protected" in header or "unprotected" in header or "recipients" in header:
return self.serialize_json(header, payload, key, sender_key)
return self.serialize_compact(header, payload, key, sender_key)
def deserialize_compact(self, s, key, decode=None, sender_key=None):
"""Extract JWE Compact Serialization.
:param s: JWE Compact Serialization as bytes
:param key: Private key used to decrypt payload
(optionally can be a tuple of kid and essentially key)
:param decode: Function to decode payload data
:param sender_key: Sender's public key in case
JWEAlgorithmWithTagAwareKeyAgreement is used
:return: dict with `header` and `payload` keys where `header` value is
a dict containing protected header fields
"""
try:
s = to_bytes(s)
protected_s, ek_s, iv_s, ciphertext_s, tag_s = s.rsplit(b".")
except ValueError as exc:
raise DecodeError("Not enough segments") from exc
protected = extract_header(protected_s, DecodeError)
ek = extract_segment(ek_s, DecodeError, "encryption key")
iv = extract_segment(iv_s, DecodeError, "initialization vector")
ciphertext = extract_segment(ciphertext_s, DecodeError, "ciphertext")
tag = extract_segment(tag_s, DecodeError, "authentication tag")
alg = self.get_header_alg(protected)
enc = self.get_header_enc(protected)
zip_alg = self.get_header_zip(protected)
self._validate_sender_key(sender_key, alg)
self._validate_private_headers(protected, alg)
if isinstance(key, tuple) and len(key) == 2:
# Ignore separately provided kid, extract essentially key only
key = key[1]
key = prepare_key(alg, protected, key)
if sender_key is not None:
sender_key = alg.prepare_key(sender_key)
if isinstance(alg, JWEAlgorithmWithTagAwareKeyAgreement):
# For a JWE algorithm with tag-aware key agreement:
if alg.key_size is not None:
# In case key agreement with key wrapping mode is used:
# Provide authentication tag to .unwrap method
cek = alg.unwrap(enc, ek, protected, key, sender_key, tag)
else:
# Otherwise, don't provide authentication tag to .unwrap method
cek = alg.unwrap(enc, ek, protected, key, sender_key)
else:
# For any other JWE algorithm:
# Don't provide authentication tag to .unwrap method
cek = alg.unwrap(enc, ek, protected, key)
aad = to_bytes(protected_s, "ascii")
msg = enc.decrypt(ciphertext, aad, iv, tag, cek)
if zip_alg:
payload = zip_alg.decompress(to_bytes(msg))
else:
payload = msg
if decode:
payload = decode(payload)
return {"header": protected, "payload": payload}
def deserialize_json(self, obj, key, decode=None, sender_key=None): # noqa: C901
"""Extract JWE JSON Serialization.
:param obj: JWE JSON Serialization as dict or str
:param key: Private key used to decrypt payload
(optionally can be a tuple of kid and essentially key)
:param decode: Function to decode payload data
:param sender_key: Sender's public key in case
JWEAlgorithmWithTagAwareKeyAgreement is used
:return: dict with `header` and `payload` keys where `header` value is
a dict containing `protected`, `unprotected`, `recipients` and/or
`aad` keys
"""
obj = ensure_dict(obj, "JWE")
obj = deepcopy(obj)
if "protected" in obj:
protected = extract_header(to_bytes(obj["protected"]), DecodeError)
else:
protected = None
unprotected = obj.get("unprotected")
recipients = obj["recipients"]
for recipient in recipients:
if "header" not in recipient:
recipient["header"] = {}
recipient["encrypted_key"] = extract_segment(
to_bytes(recipient["encrypted_key"]), DecodeError, "encrypted key"
)
if "aad" in obj:
jwe_aad = extract_segment(to_bytes(obj["aad"]), DecodeError, "JWE AAD")
else:
jwe_aad = None
iv = extract_segment(to_bytes(obj["iv"]), DecodeError, "initialization vector")
ciphertext = extract_segment(
to_bytes(obj["ciphertext"]), DecodeError, "ciphertext"
)
tag = extract_segment(to_bytes(obj["tag"]), DecodeError, "authentication tag")
shared_header = JWESharedHeader(protected, unprotected)
alg = self.get_header_alg(shared_header)
enc = self.get_header_enc(shared_header)
zip_alg = self.get_header_zip(shared_header)
self._validate_sender_key(sender_key, alg)
self._validate_private_headers(shared_header, alg)
for recipient in recipients:
self._validate_private_headers(recipient["header"], alg)
kid = None
if isinstance(key, tuple) and len(key) == 2:
# Extract separately provided kid and essentially key
kid = key[0]
key = key[1]
key = alg.prepare_key(key)
if kid is None:
# If kid has not been provided separately, try to get it from key itself
kid = key.kid
if sender_key is not None:
sender_key = alg.prepare_key(sender_key)
def _unwrap_with_sender_key_and_tag(ek, header):
return alg.unwrap(enc, ek, header, key, sender_key, tag)
def _unwrap_with_sender_key_and_without_tag(ek, header):
return alg.unwrap(enc, ek, header, key, sender_key)
def _unwrap_without_sender_key_and_tag(ek, header):
return alg.unwrap(enc, ek, header, key)
def _unwrap_for_matching_recipient(unwrap_func):
if kid is not None:
for recipient in recipients:
if recipient["header"].get("kid") == kid:
header = JWEHeader(protected, unprotected, recipient["header"])
return unwrap_func(recipient["encrypted_key"], header)
# Since no explicit match has been found, iterate over all the recipients
error = None
for recipient in recipients:
header = JWEHeader(protected, unprotected, recipient["header"])
try:
return unwrap_func(recipient["encrypted_key"], header)
except Exception as e:
error = e
else:
if error is None:
raise KeyMismatchError()
else:
raise error
if isinstance(alg, JWEAlgorithmWithTagAwareKeyAgreement):
# For a JWE algorithm with tag-aware key agreement:
if alg.key_size is not None:
# In case key agreement with key wrapping mode is used:
# Provide authentication tag to .unwrap method
cek = _unwrap_for_matching_recipient(_unwrap_with_sender_key_and_tag)
else:
# Otherwise, don't provide authentication tag to .unwrap method
cek = _unwrap_for_matching_recipient(
_unwrap_with_sender_key_and_without_tag
)
else:
# For any other JWE algorithm:
# Don't provide authentication tag to .unwrap method
cek = _unwrap_for_matching_recipient(_unwrap_without_sender_key_and_tag)
aad = to_bytes(obj.get("protected", ""))
if "aad" in obj:
aad += b"." + to_bytes(obj["aad"])
aad = to_bytes(aad, "ascii")
msg = enc.decrypt(ciphertext, aad, iv, tag, cek)
if zip_alg:
payload = zip_alg.decompress(to_bytes(msg))
else:
payload = msg
if decode:
payload = decode(payload)
for recipient in recipients:
if not recipient["header"]:
del recipient["header"]
for member in set(recipient.keys()):
if member != "header":
del recipient[member]
header = {}
if protected:
header["protected"] = protected
if unprotected:
header["unprotected"] = unprotected
header["recipients"] = recipients
if jwe_aad is not None:
header["aad"] = jwe_aad
return {"header": header, "payload": payload}
def deserialize(self, obj, key, decode=None, sender_key=None):
"""Extract a JWE Serialization.
It supports both compact and JSON serialization.
:param obj: JWE compact serialization as bytes or
JWE JSON serialization as dict or str
:param key: Private key used to decrypt payload
(optionally can be a tuple of kid and essentially key)
:param decode: Function to decode payload data
:param sender_key: Sender's public key in case
JWEAlgorithmWithTagAwareKeyAgreement is used
:return: dict with `header` and `payload` keys
"""
if isinstance(obj, dict):
return self.deserialize_json(obj, key, decode, sender_key)
obj = to_bytes(obj)
if obj.startswith(b"{") and obj.endswith(b"}"):
return self.deserialize_json(obj, key, decode, sender_key)
return self.deserialize_compact(obj, key, decode, sender_key)
@staticmethod
def parse_json(obj):
"""Parse JWE JSON Serialization.
:param obj: JWE JSON Serialization as str or dict
:return: Parsed JWE JSON Serialization as dict if `obj` is an str,
or `obj` as is if `obj` is already a dict
"""
return ensure_dict(obj, "JWE")
def get_header_alg(self, header):
if "alg" not in header:
raise MissingAlgorithmError()
alg = header["alg"]
if alg not in self.ALG_REGISTRY:
raise UnsupportedAlgorithmError()
instance = self.ALG_REGISTRY[alg]
# use all ALG_REGISTRY algorithms
if self._algorithms is None:
# do not use deprecated algorithms
if instance.deprecated:
raise UnsupportedAlgorithmError()
elif alg not in self._algorithms:
raise UnsupportedAlgorithmError()
return instance
def get_header_enc(self, header):
if "enc" not in header:
raise MissingEncryptionAlgorithmError()
enc = header["enc"]
if self._algorithms is not None and enc not in self._algorithms:
raise UnsupportedEncryptionAlgorithmError()
if enc not in self.ENC_REGISTRY:
raise UnsupportedEncryptionAlgorithmError()
return self.ENC_REGISTRY[enc]
def get_header_zip(self, header):
if "zip" in header:
z = header["zip"]
if self._algorithms is not None and z not in self._algorithms:
raise UnsupportedCompressionAlgorithmError()
if z not in self.ZIP_REGISTRY:
raise UnsupportedCompressionAlgorithmError()
return self.ZIP_REGISTRY[z]
def _validate_sender_key(self, sender_key, alg):
if isinstance(alg, JWEAlgorithmWithTagAwareKeyAgreement):
if sender_key is None:
raise ValueError(
f"{alg.name} algorithm requires sender_key but passed sender_key value is None"
)
else:
if sender_key is not None:
raise ValueError(
f"{alg.name} algorithm does not use sender_key but passed sender_key value is not None"
)
def _validate_private_headers(self, header, alg):
# only validate private headers when developers set
# private headers explicitly
if self._private_headers is None:
return
names = self.REGISTERED_HEADER_PARAMETER_NAMES.copy()
names = names.union(self._private_headers)
if alg.EXTRA_HEADERS:
names = names.union(alg.EXTRA_HEADERS)
for k in header:
if k not in names:
raise InvalidHeaderParameterNameError(k)
def prepare_key(alg, header, key):
if callable(key):
key = key(header, None)
return alg.prepare_key(key)
@@ -0,0 +1,158 @@
import os
from abc import ABCMeta
class JWEAlgorithmBase(metaclass=ABCMeta): # noqa: B024
"""Base interface for all JWE algorithms."""
EXTRA_HEADERS = None
name = None
description = None
deprecated = False
algorithm_type = "JWE"
algorithm_location = "alg"
def prepare_key(self, raw_data):
raise NotImplementedError
def generate_preset(self, enc_alg, key):
raise NotImplementedError
class JWEAlgorithm(JWEAlgorithmBase, metaclass=ABCMeta):
"""Interface for JWE algorithm conforming to RFC7518.
JWA specification (RFC7518) SHOULD implement the algorithms for JWE
with this base implementation.
"""
def wrap(self, enc_alg, headers, key, preset=None):
raise NotImplementedError
def unwrap(self, enc_alg, ek, headers, key):
raise NotImplementedError
class JWEAlgorithmWithTagAwareKeyAgreement(JWEAlgorithmBase, metaclass=ABCMeta):
"""Interface for JWE algorithm with tag-aware key agreement (in key agreement
with key wrapping mode).
ECDH-1PU is an example of such an algorithm.
"""
def generate_keys_and_prepare_headers(self, enc_alg, key, sender_key, preset=None):
raise NotImplementedError
def agree_upon_key_and_wrap_cek(
self, enc_alg, headers, key, sender_key, epk, cek, tag
):
raise NotImplementedError
def wrap(self, enc_alg, headers, key, sender_key, preset=None):
raise NotImplementedError
def unwrap(self, enc_alg, ek, headers, key, sender_key, tag=None):
raise NotImplementedError
class JWEEncAlgorithm:
name = None
description = None
algorithm_type = "JWE"
algorithm_location = "enc"
IV_SIZE = None
CEK_SIZE = None
def generate_cek(self):
return os.urandom(self.CEK_SIZE // 8)
def generate_iv(self):
return os.urandom(self.IV_SIZE // 8)
def check_iv(self, iv):
if len(iv) * 8 != self.IV_SIZE:
raise ValueError('Invalid "iv" size')
def encrypt(self, msg, aad, iv, key):
"""Encrypt the given "msg" text.
:param msg: text to be encrypt in bytes
:param aad: additional authenticated data in bytes
:param iv: initialization vector in bytes
:param key: encrypted key in bytes
:return: (ciphertext, tag)
"""
raise NotImplementedError
def decrypt(self, ciphertext, aad, iv, tag, key):
"""Decrypt the given cipher text.
:param ciphertext: ciphertext in bytes
:param aad: additional authenticated data in bytes
:param iv: initialization vector in bytes
:param tag: authentication tag in bytes
:param key: encrypted key in bytes
:return: message
"""
raise NotImplementedError
class JWEZipAlgorithm:
name = None
description = None
algorithm_type = "JWE"
algorithm_location = "zip"
def compress(self, s):
raise NotImplementedError
def decompress(self, s):
raise NotImplementedError
class JWESharedHeader(dict):
"""Shared header object for JWE.
Combines protected header and shared unprotected header together.
"""
def __init__(self, protected, unprotected):
obj = {}
if unprotected:
obj.update(unprotected)
if protected:
obj.update(protected)
super().__init__(obj)
self.protected = protected if protected else {}
self.unprotected = unprotected if unprotected else {}
def update_protected(self, addition):
self.update(addition)
self.protected.update(addition)
@classmethod
def from_dict(cls, obj):
if isinstance(obj, cls):
return obj
return cls(obj.get("protected"), obj.get("unprotected"))
class JWEHeader(dict):
"""Header object for JWE.
Combines protected header, shared unprotected header
and specific recipient's unprotected header together.
"""
def __init__(self, protected, unprotected, header):
obj = {}
if unprotected:
obj.update(unprotected)
if header:
obj.update(header)
if protected:
obj.update(protected)
super().__init__(obj)
self.protected = protected if protected else {}
self.unprotected = unprotected if unprotected else {}
self.header = header if header else {}
@@ -0,0 +1,16 @@
"""authlib.jose.rfc7517.
~~~~~~~~~~~~~~~~~~~~~
This module represents a direct implementation of
JSON Web Key (JWK).
https://tools.ietf.org/html/rfc7517
"""
from ._cryptography_key import load_pem_key
from .asymmetric_key import AsymmetricKey
from .base_key import Key
from .jwk import JsonWebKey
from .key_set import KeySet
__all__ = ["Key", "AsymmetricKey", "KeySet", "JsonWebKey", "load_pem_key"]
@@ -0,0 +1,35 @@
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.hazmat.primitives.serialization import load_ssh_public_key
from cryptography.x509 import load_pem_x509_certificate
from authlib.common.encoding import to_bytes
def load_pem_key(raw, ssh_type=None, key_type=None, password=None):
raw = to_bytes(raw)
if ssh_type and raw.startswith(ssh_type):
return load_ssh_public_key(raw, backend=default_backend())
if key_type == "public":
return load_pem_public_key(raw, backend=default_backend())
if key_type == "private" or password is not None:
return load_pem_private_key(raw, password=password, backend=default_backend())
if b"PUBLIC" in raw:
return load_pem_public_key(raw, backend=default_backend())
if b"PRIVATE" in raw:
return load_pem_private_key(raw, password=password, backend=default_backend())
if b"CERTIFICATE" in raw:
cert = load_pem_x509_certificate(raw, default_backend())
return cert.public_key()
try:
return load_pem_private_key(raw, password=password, backend=default_backend())
except ValueError:
return load_pem_public_key(raw, backend=default_backend())
@@ -0,0 +1,196 @@
from cryptography.hazmat.primitives.serialization import BestAvailableEncryption
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.hazmat.primitives.serialization import NoEncryption
from cryptography.hazmat.primitives.serialization import PrivateFormat
from cryptography.hazmat.primitives.serialization import PublicFormat
from authlib.common.encoding import to_bytes
from ._cryptography_key import load_pem_key
from .base_key import Key
class AsymmetricKey(Key):
"""This is the base class for a JSON Web Key."""
PUBLIC_KEY_FIELDS = []
PRIVATE_KEY_FIELDS = []
PRIVATE_KEY_CLS = bytes
PUBLIC_KEY_CLS = bytes
SSH_PUBLIC_PREFIX = b""
def __init__(self, private_key=None, public_key=None, options=None):
super().__init__(options)
self.private_key = private_key
self.public_key = public_key
@property
def public_only(self):
if self.private_key:
return False
if "d" in self.tokens:
return False
return True
def get_op_key(self, operation):
"""Get the raw key for the given key_op. This method will also
check if the given key_op is supported by this key.
:param operation: key operation value, such as "sign", "encrypt".
:return: raw key
"""
self.check_key_op(operation)
if operation in self.PUBLIC_KEY_OPS:
return self.get_public_key()
return self.get_private_key()
def get_public_key(self):
if self.public_key:
return self.public_key
private_key = self.get_private_key()
if private_key:
return private_key.public_key()
return self.public_key
def get_private_key(self):
if self.private_key:
return self.private_key
if self.tokens:
self.load_raw_key()
return self.private_key
def load_raw_key(self):
if "d" in self.tokens:
self.private_key = self.load_private_key()
else:
self.public_key = self.load_public_key()
def load_dict_key(self):
if self.private_key:
self._dict_data.update(self.dumps_private_key())
else:
self._dict_data.update(self.dumps_public_key())
def dumps_private_key(self):
raise NotImplementedError()
def dumps_public_key(self):
raise NotImplementedError()
def load_private_key(self):
raise NotImplementedError()
def load_public_key(self):
raise NotImplementedError()
def as_dict(self, is_private=False, **params):
"""Represent this key as a dict of the JSON Web Key."""
tokens = self.tokens
if is_private and "d" not in tokens:
raise ValueError("This is a public key")
kid = tokens.get("kid")
if "d" in tokens and not is_private:
# filter out private fields
tokens = {k: tokens[k] for k in tokens if k in self.PUBLIC_KEY_FIELDS}
tokens["kty"] = self.kty
if kid:
tokens["kid"] = kid
if not kid:
tokens["kid"] = self.thumbprint()
tokens.update(params)
return tokens
def as_key(self, is_private=False):
"""Represent this key as raw key."""
if is_private:
return self.get_private_key()
return self.get_public_key()
def as_bytes(self, encoding=None, is_private=False, password=None):
"""Export key into PEM/DER format bytes.
:param encoding: "PEM" or "DER"
:param is_private: export private key or public key
:param password: encrypt private key with password
:return: bytes
"""
if encoding is None or encoding == "PEM":
encoding = Encoding.PEM
elif encoding == "DER":
encoding = Encoding.DER
else:
raise ValueError(f"Invalid encoding: {encoding!r}")
raw_key = self.as_key(is_private)
if is_private:
if not raw_key:
raise ValueError("This is a public key")
if password is None:
encryption_algorithm = NoEncryption()
else:
encryption_algorithm = BestAvailableEncryption(to_bytes(password))
return raw_key.private_bytes(
encoding=encoding,
format=PrivateFormat.PKCS8,
encryption_algorithm=encryption_algorithm,
)
return raw_key.public_bytes(
encoding=encoding,
format=PublicFormat.SubjectPublicKeyInfo,
)
def as_pem(self, is_private=False, password=None):
return self.as_bytes(is_private=is_private, password=password)
def as_der(self, is_private=False, password=None):
return self.as_bytes(encoding="DER", is_private=is_private, password=password)
@classmethod
def import_dict_key(cls, raw, options=None):
cls.check_required_fields(raw)
key = cls(options=options)
key._dict_data = raw
return key
@classmethod
def import_key(cls, raw, options=None):
if isinstance(raw, cls):
if options is not None:
raw.options.update(options)
return raw
if isinstance(raw, cls.PUBLIC_KEY_CLS):
key = cls(public_key=raw, options=options)
elif isinstance(raw, cls.PRIVATE_KEY_CLS):
key = cls(private_key=raw, options=options)
elif isinstance(raw, dict):
key = cls.import_dict_key(raw, options)
else:
if options is not None:
password = options.pop("password", None)
else:
password = None
raw_key = load_pem_key(raw, cls.SSH_PUBLIC_PREFIX, password=password)
if isinstance(raw_key, cls.PUBLIC_KEY_CLS):
key = cls(public_key=raw_key, options=options)
elif isinstance(raw_key, cls.PRIVATE_KEY_CLS):
key = cls(private_key=raw_key, options=options)
else:
raise ValueError("Invalid data for importing key")
return key
@classmethod
def validate_raw_key(cls, key):
return isinstance(key, cls.PUBLIC_KEY_CLS) or isinstance(
key, cls.PRIVATE_KEY_CLS
)
@classmethod
def generate_key(cls, crv_or_size, options=None, is_private=False):
raise NotImplementedError()
@@ -0,0 +1,120 @@
import hashlib
from collections import OrderedDict
from authlib.common.encoding import json_dumps
from authlib.common.encoding import to_bytes
from authlib.common.encoding import to_unicode
from authlib.common.encoding import urlsafe_b64encode
from ..errors import InvalidUseError
class Key:
"""This is the base class for a JSON Web Key."""
kty = "_"
ALLOWED_PARAMS = ["use", "key_ops", "alg", "kid", "x5u", "x5c", "x5t", "x5t#S256"]
PRIVATE_KEY_OPS = [
"sign",
"decrypt",
"unwrapKey",
]
PUBLIC_KEY_OPS = [
"verify",
"encrypt",
"wrapKey",
]
REQUIRED_JSON_FIELDS = []
def __init__(self, options=None):
self.options = options or {}
self._dict_data = {}
@property
def tokens(self):
if not self._dict_data:
self.load_dict_key()
rv = dict(self._dict_data)
rv["kty"] = self.kty
for k in self.ALLOWED_PARAMS:
if k not in rv and k in self.options:
rv[k] = self.options[k]
return rv
@property
def kid(self):
return self.tokens.get("kid")
def keys(self):
return self.tokens.keys()
def __getitem__(self, item):
return self.tokens[item]
@property
def public_only(self):
raise NotImplementedError()
def load_raw_key(self):
raise NotImplementedError()
def load_dict_key(self):
raise NotImplementedError()
def check_key_op(self, operation):
"""Check if the given key_op is supported by this key.
:param operation: key operation value, such as "sign", "encrypt".
:raise: ValueError
"""
key_ops = self.tokens.get("key_ops")
if key_ops is not None and operation not in key_ops:
raise ValueError(f'Unsupported key_op "{operation}"')
if operation in self.PRIVATE_KEY_OPS and self.public_only:
raise ValueError(f'Invalid key_op "{operation}" for public key')
use = self.tokens.get("use")
if use:
if operation in ["sign", "verify"]:
if use != "sig":
raise InvalidUseError()
elif operation in ["decrypt", "encrypt", "wrapKey", "unwrapKey"]:
if use != "enc":
raise InvalidUseError()
def as_dict(self, is_private=False, **params):
raise NotImplementedError()
def as_json(self, is_private=False, **params):
"""Represent this key as a JSON string."""
obj = self.as_dict(is_private, **params)
return json_dumps(obj)
def thumbprint(self):
"""Implementation of RFC7638 JSON Web Key (JWK) Thumbprint."""
fields = list(self.REQUIRED_JSON_FIELDS)
fields.append("kty")
fields.sort()
data = OrderedDict()
for k in fields:
data[k] = self.tokens[k]
json_data = json_dumps(data)
digest_data = hashlib.sha256(to_bytes(json_data)).digest()
return to_unicode(urlsafe_b64encode(digest_data))
@classmethod
def check_required_fields(cls, data):
for k in cls.REQUIRED_JSON_FIELDS:
if k not in data:
raise ValueError(f'Missing required field: "{k}"')
@classmethod
def validate_raw_key(cls, key):
raise NotImplementedError()
@@ -0,0 +1,64 @@
from authlib.common.encoding import json_loads
from ._cryptography_key import load_pem_key
from .key_set import KeySet
class JsonWebKey:
JWK_KEY_CLS = {}
@classmethod
def generate_key(cls, kty, crv_or_size, options=None, is_private=False):
"""Generate a Key with the given key type, curve name or bit size.
:param kty: string of ``oct``, ``RSA``, ``EC``, ``OKP``
:param crv_or_size: curve name or bit size
:param options: a dict of other options for Key
:param is_private: create a private key or public key
:return: Key instance
"""
key_cls = cls.JWK_KEY_CLS[kty]
return key_cls.generate_key(crv_or_size, options, is_private)
@classmethod
def import_key(cls, raw, options=None):
"""Import a Key from bytes, string, PEM or dict.
:return: Key instance
"""
kty = None
if options is not None:
kty = options.get("kty")
if kty is None and isinstance(raw, dict):
kty = raw.get("kty")
if kty is None:
raw_key = load_pem_key(raw)
for _kty in cls.JWK_KEY_CLS:
key_cls = cls.JWK_KEY_CLS[_kty]
if key_cls.validate_raw_key(raw_key):
return key_cls.import_key(raw_key, options)
key_cls = cls.JWK_KEY_CLS[kty]
return key_cls.import_key(raw, options)
@classmethod
def import_key_set(cls, raw):
"""Import KeySet from string, dict or a list of keys.
:return: KeySet instance
"""
raw = _transform_raw_key(raw)
if isinstance(raw, dict) and "keys" in raw:
keys = raw.get("keys")
return KeySet([cls.import_key(k) for k in keys])
raise ValueError("Invalid key set format")
def _transform_raw_key(raw):
if isinstance(raw, str) and raw.startswith("{") and raw.endswith("}"):
return json_loads(raw)
elif isinstance(raw, (tuple, list)):
return {"keys": raw}
return raw
@@ -0,0 +1,53 @@
from authlib.common.encoding import json_dumps
class KeySet:
"""This class represents a JSON Web Key Set."""
def __init__(self, keys):
self.keys = keys
def as_dict(self, is_private=False, **params):
"""Represent this key as a dict of the JSON Web Key Set."""
return {"keys": [k.as_dict(is_private, **params) for k in self.keys]}
def as_json(self, is_private=False, **params):
"""Represent this key set as a JSON string."""
obj = self.as_dict(is_private, **params)
return json_dumps(obj)
def find_by_kid(self, kid, **params):
"""Find the key matches the given kid value.
:param kid: A string of kid
:return: Key instance
:raise: ValueError
"""
# Proposed fix, feel free to do something else but the idea is that we take the only key
# of the set if no kid is specified
if kid is None and len(self.keys) == 1:
return self.keys[0]
keys = [key for key in self.keys if key.kid == kid]
if params:
keys = list(_filter_keys_by_params(keys, **params))
if keys:
return keys[0]
raise ValueError("Key not found")
def _filter_keys_by_params(keys, **params):
_use = params.get("use")
_alg = params.get("alg")
for key in keys:
designed_use = key.tokens.get("use")
if designed_use and _use and designed_use != _use:
continue
designed_alg = key.tokens.get("alg")
if designed_alg and _alg and designed_alg != _alg:
continue
yield key
@@ -0,0 +1,39 @@
from .ec_key import ECKey
from .jwe_algs import JWE_ALG_ALGORITHMS
from .jwe_algs import AESAlgorithm
from .jwe_algs import ECDHESAlgorithm
from .jwe_algs import u32be_len_input
from .jwe_encs import JWE_ENC_ALGORITHMS
from .jwe_encs import CBCHS2EncAlgorithm
from .jwe_zips import DeflateZipAlgorithm
from .jws_algs import JWS_ALGORITHMS
from .oct_key import OctKey
from .rsa_key import RSAKey
def register_jws_rfc7518(cls):
for algorithm in JWS_ALGORITHMS:
cls.register_algorithm(algorithm)
def register_jwe_rfc7518(cls):
for algorithm in JWE_ALG_ALGORITHMS:
cls.register_algorithm(algorithm)
for algorithm in JWE_ENC_ALGORITHMS:
cls.register_algorithm(algorithm)
cls.register_algorithm(DeflateZipAlgorithm())
__all__ = [
"register_jws_rfc7518",
"register_jwe_rfc7518",
"OctKey",
"RSAKey",
"ECKey",
"u32be_len_input",
"AESAlgorithm",
"ECDHESAlgorithm",
"CBCHS2EncAlgorithm",
]
@@ -0,0 +1,108 @@
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.ec import SECP256K1
from cryptography.hazmat.primitives.asymmetric.ec import SECP256R1
from cryptography.hazmat.primitives.asymmetric.ec import SECP384R1
from cryptography.hazmat.primitives.asymmetric.ec import SECP521R1
from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePrivateKeyWithSerialization,
)
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateNumbers
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers
from authlib.common.encoding import base64_to_int
from authlib.common.encoding import int_to_base64
from ..rfc7517 import AsymmetricKey
class ECKey(AsymmetricKey):
"""Key class of the ``EC`` key type."""
kty = "EC"
DSS_CURVES = {
"P-256": SECP256R1,
"P-384": SECP384R1,
"P-521": SECP521R1,
# https://tools.ietf.org/html/rfc8812#section-3.1
"secp256k1": SECP256K1,
}
CURVES_DSS = {
SECP256R1.name: "P-256",
SECP384R1.name: "P-384",
SECP521R1.name: "P-521",
SECP256K1.name: "secp256k1",
}
REQUIRED_JSON_FIELDS = ["crv", "x", "y"]
PUBLIC_KEY_FIELDS = REQUIRED_JSON_FIELDS
PRIVATE_KEY_FIELDS = ["crv", "d", "x", "y"]
PUBLIC_KEY_CLS = EllipticCurvePublicKey
PRIVATE_KEY_CLS = EllipticCurvePrivateKeyWithSerialization
SSH_PUBLIC_PREFIX = b"ecdsa-sha2-"
def exchange_shared_key(self, pubkey):
# # used in ECDHESAlgorithm
private_key = self.get_private_key()
if private_key:
return private_key.exchange(ec.ECDH(), pubkey)
raise ValueError("Invalid key for exchanging shared key")
@property
def curve_key_size(self):
raw_key = self.get_private_key()
if not raw_key:
raw_key = self.public_key
return raw_key.curve.key_size
def load_private_key(self):
curve = self.DSS_CURVES[self._dict_data["crv"]]()
public_numbers = EllipticCurvePublicNumbers(
base64_to_int(self._dict_data["x"]),
base64_to_int(self._dict_data["y"]),
curve,
)
private_numbers = EllipticCurvePrivateNumbers(
base64_to_int(self.tokens["d"]), public_numbers
)
return private_numbers.private_key(default_backend())
def load_public_key(self):
curve = self.DSS_CURVES[self._dict_data["crv"]]()
public_numbers = EllipticCurvePublicNumbers(
base64_to_int(self._dict_data["x"]),
base64_to_int(self._dict_data["y"]),
curve,
)
return public_numbers.public_key(default_backend())
def dumps_private_key(self):
numbers = self.private_key.private_numbers()
return {
"crv": self.CURVES_DSS[self.private_key.curve.name],
"x": int_to_base64(numbers.public_numbers.x),
"y": int_to_base64(numbers.public_numbers.y),
"d": int_to_base64(numbers.private_value),
}
def dumps_public_key(self):
numbers = self.public_key.public_numbers()
return {
"crv": self.CURVES_DSS[numbers.curve.name],
"x": int_to_base64(numbers.x),
"y": int_to_base64(numbers.y),
}
@classmethod
def generate_key(cls, crv="P-256", options=None, is_private=False) -> "ECKey":
if crv not in cls.DSS_CURVES:
raise ValueError(f'Invalid crv value: "{crv}"')
raw_key = ec.generate_private_key(
curve=cls.DSS_CURVES[crv](),
backend=default_backend(),
)
if not is_private:
raw_key = raw_key.public_key()
return cls.import_key(raw_key, options=options)
@@ -0,0 +1,350 @@
import secrets
import struct
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import AES
from cryptography.hazmat.primitives.ciphers.modes import GCM
from cryptography.hazmat.primitives.kdf.concatkdf import ConcatKDFHash
from cryptography.hazmat.primitives.keywrap import aes_key_unwrap
from cryptography.hazmat.primitives.keywrap import aes_key_wrap
from authlib.common.encoding import to_bytes
from authlib.common.encoding import to_native
from authlib.common.encoding import urlsafe_b64decode
from authlib.common.encoding import urlsafe_b64encode
from authlib.jose.rfc7516 import JWEAlgorithm
from .ec_key import ECKey
from .oct_key import OctKey
from .rsa_key import RSAKey
class DirectAlgorithm(JWEAlgorithm):
name = "dir"
description = "Direct use of a shared symmetric key"
def prepare_key(self, raw_data):
return OctKey.import_key(raw_data)
def generate_preset(self, enc_alg, key):
return {}
def wrap(self, enc_alg, headers, key, preset=None):
cek = key.get_op_key("encrypt")
if len(cek) * 8 != enc_alg.CEK_SIZE:
raise ValueError('Invalid "cek" length')
return {"ek": b"", "cek": cek}
def unwrap(self, enc_alg, ek, headers, key):
cek = key.get_op_key("decrypt")
if len(cek) * 8 != enc_alg.CEK_SIZE:
cek = secrets.token_bytes(enc_alg.CEK_SIZE // 8)
return cek
class RSAAlgorithm(JWEAlgorithm):
#: A key of size 2048 bits or larger MUST be used with these algorithms
#: RSA1_5, RSA-OAEP, RSA-OAEP-256
key_size = 2048
def __init__(self, name, description, pad_fn):
self.name = name
self.deprecated = name == "RSA1_5"
self.description = description
self.padding = pad_fn
def prepare_key(self, raw_data):
return RSAKey.import_key(raw_data)
def generate_preset(self, enc_alg, key):
cek = enc_alg.generate_cek()
return {"cek": cek}
def wrap(self, enc_alg, headers, key, preset=None):
if preset and "cek" in preset:
cek = preset["cek"]
else:
cek = enc_alg.generate_cek()
op_key = key.get_op_key("wrapKey")
if op_key.key_size < self.key_size:
raise ValueError("A key of size 2048 bits or larger MUST be used")
ek = op_key.encrypt(cek, self.padding)
return {"ek": ek, "cek": cek}
def unwrap(self, enc_alg, ek, headers, key):
op_key = key.get_op_key("unwrapKey")
cek = op_key.decrypt(ek, self.padding)
if len(cek) * 8 != enc_alg.CEK_SIZE:
cek = secrets.token_bytes(enc_alg.CEK_SIZE // 8)
return cek
class AESAlgorithm(JWEAlgorithm):
def __init__(self, key_size):
self.name = f"A{key_size}KW"
self.description = f"AES Key Wrap using {key_size}-bit key"
self.key_size = key_size
def prepare_key(self, raw_data):
return OctKey.import_key(raw_data)
def generate_preset(self, enc_alg, key):
cek = enc_alg.generate_cek()
return {"cek": cek}
def _check_key(self, key):
if len(key) * 8 != self.key_size:
raise ValueError(f"A key of size {self.key_size} bits is required.")
def wrap_cek(self, cek, key):
op_key = key.get_op_key("wrapKey")
self._check_key(op_key)
ek = aes_key_wrap(op_key, cek, default_backend())
return {"ek": ek, "cek": cek}
def wrap(self, enc_alg, headers, key, preset=None):
if preset and "cek" in preset:
cek = preset["cek"]
else:
cek = enc_alg.generate_cek()
return self.wrap_cek(cek, key)
def unwrap(self, enc_alg, ek, headers, key):
op_key = key.get_op_key("unwrapKey")
self._check_key(op_key)
cek = aes_key_unwrap(op_key, ek, default_backend())
if len(cek) * 8 != enc_alg.CEK_SIZE:
cek = secrets.token_bytes(enc_alg.CEK_SIZE // 8)
return cek
class AESGCMAlgorithm(JWEAlgorithm):
EXTRA_HEADERS = frozenset(["iv", "tag"])
def __init__(self, key_size):
self.name = f"A{key_size}GCMKW"
self.description = f"Key wrapping with AES GCM using {key_size}-bit key"
self.key_size = key_size
def prepare_key(self, raw_data):
return OctKey.import_key(raw_data)
def generate_preset(self, enc_alg, key):
cek = enc_alg.generate_cek()
return {"cek": cek}
def _check_key(self, key):
if len(key) * 8 != self.key_size:
raise ValueError(f"A key of size {self.key_size} bits is required.")
def wrap(self, enc_alg, headers, key, preset=None):
if preset and "cek" in preset:
cek = preset["cek"]
else:
cek = enc_alg.generate_cek()
op_key = key.get_op_key("wrapKey")
self._check_key(op_key)
#: https://tools.ietf.org/html/rfc7518#section-4.7.1.1
#: The "iv" (initialization vector) Header Parameter value is the
#: base64url-encoded representation of the 96-bit IV value
iv_size = 96
iv = secrets.token_bytes(iv_size // 8)
cipher = Cipher(AES(op_key), GCM(iv), backend=default_backend())
enc = cipher.encryptor()
ek = enc.update(cek) + enc.finalize()
h = {
"iv": to_native(urlsafe_b64encode(iv)),
"tag": to_native(urlsafe_b64encode(enc.tag)),
}
return {"ek": ek, "cek": cek, "header": h}
def unwrap(self, enc_alg, ek, headers, key):
op_key = key.get_op_key("unwrapKey")
self._check_key(op_key)
iv = headers.get("iv")
if not iv:
raise ValueError('Missing "iv" in headers')
tag = headers.get("tag")
if not tag:
raise ValueError('Missing "tag" in headers')
iv = urlsafe_b64decode(to_bytes(iv))
tag = urlsafe_b64decode(to_bytes(tag))
cipher = Cipher(AES(op_key), GCM(iv, tag), backend=default_backend())
d = cipher.decryptor()
cek = d.update(ek) + d.finalize()
if len(cek) * 8 != enc_alg.CEK_SIZE:
cek = secrets.token_bytes(enc_alg.CEK_SIZE // 8)
return cek
class ECDHESAlgorithm(JWEAlgorithm):
EXTRA_HEADERS = ["epk", "apu", "apv"]
ALLOWED_KEY_CLS = ECKey
# https://tools.ietf.org/html/rfc7518#section-4.6
def __init__(self, key_size=None):
if key_size is None:
self.name = "ECDH-ES"
self.description = "ECDH-ES in the Direct Key Agreement mode"
else:
self.name = f"ECDH-ES+A{key_size}KW"
self.description = (
f"ECDH-ES using Concat KDF and CEK wrapped with A{key_size}KW"
)
self.key_size = key_size
self.aeskw = AESAlgorithm(key_size)
def prepare_key(self, raw_data):
if isinstance(raw_data, self.ALLOWED_KEY_CLS):
return raw_data
return ECKey.import_key(raw_data)
def generate_preset(self, enc_alg, key):
epk = self._generate_ephemeral_key(key)
h = self._prepare_headers(epk)
preset = {"epk": epk, "header": h}
if self.key_size is not None:
cek = enc_alg.generate_cek()
preset["cek"] = cek
return preset
def compute_fixed_info(self, headers, bit_size):
# AlgorithmID
if self.key_size is None:
alg_id = u32be_len_input(headers["enc"])
else:
alg_id = u32be_len_input(headers["alg"])
# PartyUInfo
apu_info = u32be_len_input(headers.get("apu"), True)
# PartyVInfo
apv_info = u32be_len_input(headers.get("apv"), True)
# SuppPubInfo
pub_info = struct.pack(">I", bit_size)
return alg_id + apu_info + apv_info + pub_info
def compute_derived_key(self, shared_key, fixed_info, bit_size):
ckdf = ConcatKDFHash(
algorithm=hashes.SHA256(),
length=bit_size // 8,
otherinfo=fixed_info,
backend=default_backend(),
)
return ckdf.derive(shared_key)
def deliver(self, key, pubkey, headers, bit_size):
shared_key = key.exchange_shared_key(pubkey)
fixed_info = self.compute_fixed_info(headers, bit_size)
return self.compute_derived_key(shared_key, fixed_info, bit_size)
def _generate_ephemeral_key(self, key):
return key.generate_key(key["crv"], is_private=True)
def _prepare_headers(self, epk):
# REQUIRED_JSON_FIELDS contains only public fields
pub_epk = {k: epk[k] for k in epk.REQUIRED_JSON_FIELDS}
pub_epk["kty"] = epk.kty
return {"epk": pub_epk}
def wrap(self, enc_alg, headers, key, preset=None):
if self.key_size is None:
bit_size = enc_alg.CEK_SIZE
else:
bit_size = self.key_size
if preset and "epk" in preset:
epk = preset["epk"]
h = {}
else:
epk = self._generate_ephemeral_key(key)
h = self._prepare_headers(epk)
public_key = key.get_op_key("wrapKey")
dk = self.deliver(epk, public_key, headers, bit_size)
if self.key_size is None:
return {"ek": b"", "cek": dk, "header": h}
if preset and "cek" in preset:
preset_for_kw = {"cek": preset["cek"]}
else:
preset_for_kw = None
kek = self.aeskw.prepare_key(dk)
rv = self.aeskw.wrap(enc_alg, headers, kek, preset_for_kw)
rv["header"] = h
return rv
def unwrap(self, enc_alg, ek, headers, key):
if "epk" not in headers:
raise ValueError('Missing "epk" in headers')
if self.key_size is None:
bit_size = enc_alg.CEK_SIZE
else:
bit_size = self.key_size
epk = key.import_key(headers["epk"])
public_key = epk.get_op_key("wrapKey")
dk = self.deliver(key, public_key, headers, bit_size)
if self.key_size is None:
return dk
kek = self.aeskw.prepare_key(dk)
return self.aeskw.unwrap(enc_alg, ek, headers, kek)
def u32be_len_input(s, base64=False):
if not s:
return b"\x00\x00\x00\x00"
if base64:
s = urlsafe_b64decode(to_bytes(s))
else:
s = to_bytes(s)
return struct.pack(">I", len(s)) + s
JWE_ALG_ALGORITHMS = [
DirectAlgorithm(), # dir
RSAAlgorithm("RSA1_5", "RSAES-PKCS1-v1_5", padding.PKCS1v15()),
RSAAlgorithm(
"RSA-OAEP",
"RSAES OAEP using default parameters",
padding.OAEP(padding.MGF1(hashes.SHA1()), hashes.SHA1(), None),
),
RSAAlgorithm(
"RSA-OAEP-256",
"RSAES OAEP using SHA-256 and MGF1 with SHA-256",
padding.OAEP(padding.MGF1(hashes.SHA256()), hashes.SHA256(), None),
),
AESAlgorithm(128), # A128KW
AESAlgorithm(192), # A192KW
AESAlgorithm(256), # A256KW
AESGCMAlgorithm(128), # A128GCMKW
AESGCMAlgorithm(192), # A192GCMKW
AESGCMAlgorithm(256), # A256GCMKW
ECDHESAlgorithm(None), # ECDH-ES
ECDHESAlgorithm(128), # ECDH-ES+A128KW
ECDHESAlgorithm(192), # ECDH-ES+A192KW
ECDHESAlgorithm(256), # ECDH-ES+A256KW
]
# 'PBES2-HS256+A128KW': '',
# 'PBES2-HS384+A192KW': '',
# 'PBES2-HS512+A256KW': '',
@@ -0,0 +1,147 @@
"""authlib.jose.rfc7518.
~~~~~~~~~~~~~~~~~~~~
Cryptographic Algorithms for Cryptographic Algorithms for Content
Encryption per `Section 5`_.
.. _`Section 5`: https://tools.ietf.org/html/rfc7518#section-5
"""
import hashlib
import hmac
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import AES
from cryptography.hazmat.primitives.ciphers.modes import CBC
from cryptography.hazmat.primitives.ciphers.modes import GCM
from cryptography.hazmat.primitives.padding import PKCS7
from ..rfc7516 import JWEEncAlgorithm
from .util import encode_int
class CBCHS2EncAlgorithm(JWEEncAlgorithm):
# The IV used is a 128-bit value generated randomly or
# pseudo-randomly for use in the cipher.
IV_SIZE = 128
def __init__(self, key_size, hash_type):
self.name = f"A{key_size}CBC-HS{hash_type}"
tpl = "AES_{}_CBC_HMAC_SHA_{} authenticated encryption algorithm"
self.description = tpl.format(key_size, hash_type)
# bit length
self.key_size = key_size
# byte length
self.key_len = key_size // 8
self.CEK_SIZE = key_size * 2
self.hash_alg = getattr(hashlib, f"sha{hash_type}")
def _hmac(self, ciphertext, aad, iv, key):
al = encode_int(len(aad) * 8, 64)
msg = aad + iv + ciphertext + al
d = hmac.new(key, msg, self.hash_alg).digest()
return d[: self.key_len]
def encrypt(self, msg, aad, iv, key):
"""Key Encryption with AES_CBC_HMAC_SHA2.
:param msg: text to be encrypt in bytes
:param aad: additional authenticated data in bytes
:param iv: initialization vector in bytes
:param key: encrypted key in bytes
:return: (ciphertext, iv, tag)
"""
self.check_iv(iv)
hkey = key[: self.key_len]
ekey = key[self.key_len :]
pad = PKCS7(AES.block_size).padder()
padded_data = pad.update(msg) + pad.finalize()
cipher = Cipher(AES(ekey), CBC(iv), backend=default_backend())
enc = cipher.encryptor()
ciphertext = enc.update(padded_data) + enc.finalize()
tag = self._hmac(ciphertext, aad, iv, hkey)
return ciphertext, tag
def decrypt(self, ciphertext, aad, iv, tag, key):
"""Key Decryption with AES AES_CBC_HMAC_SHA2.
:param ciphertext: ciphertext in bytes
:param aad: additional authenticated data in bytes
:param iv: initialization vector in bytes
:param tag: authentication tag in bytes
:param key: encrypted key in bytes
:return: message
"""
self.check_iv(iv)
hkey = key[: self.key_len]
dkey = key[self.key_len :]
_tag = self._hmac(ciphertext, aad, iv, hkey)
if not hmac.compare_digest(_tag, tag):
raise InvalidTag()
cipher = Cipher(AES(dkey), CBC(iv), backend=default_backend())
d = cipher.decryptor()
data = d.update(ciphertext) + d.finalize()
unpad = PKCS7(AES.block_size).unpadder()
return unpad.update(data) + unpad.finalize()
class GCMEncAlgorithm(JWEEncAlgorithm):
# Use of an IV of size 96 bits is REQUIRED with this algorithm.
# https://tools.ietf.org/html/rfc7518#section-5.3
IV_SIZE = 96
def __init__(self, key_size):
self.name = f"A{key_size}GCM"
self.description = f"AES GCM using {key_size}-bit key"
self.key_size = key_size
self.CEK_SIZE = key_size
def encrypt(self, msg, aad, iv, key):
"""Key Encryption with AES GCM.
:param msg: text to be encrypt in bytes
:param aad: additional authenticated data in bytes
:param iv: initialization vector in bytes
:param key: encrypted key in bytes
:return: (ciphertext, iv, tag)
"""
self.check_iv(iv)
cipher = Cipher(AES(key), GCM(iv), backend=default_backend())
enc = cipher.encryptor()
enc.authenticate_additional_data(aad)
ciphertext = enc.update(msg) + enc.finalize()
return ciphertext, enc.tag
def decrypt(self, ciphertext, aad, iv, tag, key):
"""Key Decryption with AES GCM.
:param ciphertext: ciphertext in bytes
:param aad: additional authenticated data in bytes
:param iv: initialization vector in bytes
:param tag: authentication tag in bytes
:param key: encrypted key in bytes
:return: message
"""
self.check_iv(iv)
cipher = Cipher(AES(key), GCM(iv, tag), backend=default_backend())
d = cipher.decryptor()
d.authenticate_additional_data(aad)
return d.update(ciphertext) + d.finalize()
JWE_ENC_ALGORITHMS = [
CBCHS2EncAlgorithm(128, 256), # A128CBC-HS256
CBCHS2EncAlgorithm(192, 384), # A192CBC-HS384
CBCHS2EncAlgorithm(256, 512), # A256CBC-HS512
GCMEncAlgorithm(128), # A128GCM
GCMEncAlgorithm(192), # A192GCM
GCMEncAlgorithm(256), # A256GCM
]
@@ -0,0 +1,34 @@
import zlib
from ..rfc7516 import JsonWebEncryption
from ..rfc7516 import JWEZipAlgorithm
GZIP_HEAD = bytes([120, 156])
MAX_SIZE = 250 * 1024
class DeflateZipAlgorithm(JWEZipAlgorithm):
name = "DEF"
description = "DEFLATE"
def compress(self, s: bytes) -> bytes:
"""Compress bytes data with DEFLATE algorithm."""
data = zlib.compress(s)
# https://datatracker.ietf.org/doc/html/rfc1951
# since DEF is always gzip, we can drop gzip headers and tail
return data[2:-4]
def decompress(self, s: bytes) -> bytes:
"""Decompress DEFLATE bytes data."""
if s.startswith(GZIP_HEAD):
decompressor = zlib.decompressobj()
else:
decompressor = zlib.decompressobj(-zlib.MAX_WBITS)
value = decompressor.decompress(s, MAX_SIZE)
if decompressor.unconsumed_tail:
raise ValueError(f"Decompressed string exceeds {MAX_SIZE} bytes")
return value
def register_jwe_rfc7518():
JsonWebEncryption.register_algorithm(DeflateZipAlgorithm())
@@ -0,0 +1,222 @@
"""authlib.jose.rfc7518.
~~~~~~~~~~~~~~~~~~~~
"alg" (Algorithm) Header Parameter Values for JWS per `Section 3`_.
.. _`Section 3`: https://tools.ietf.org/html/rfc7518#section-3
"""
import hashlib
import hmac
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from ..rfc7515 import JWSAlgorithm
from .ec_key import ECKey
from .oct_key import OctKey
from .rsa_key import RSAKey
from .util import decode_int
from .util import encode_int
class NoneAlgorithm(JWSAlgorithm):
name = "none"
description = "No digital signature or MAC performed"
deprecated = True
def prepare_key(self, raw_data):
return None
def sign(self, msg, key):
return b""
def verify(self, msg, sig, key):
return sig == b""
class HMACAlgorithm(JWSAlgorithm):
"""HMAC using SHA algorithms for JWS. Available algorithms:
- HS256: HMAC using SHA-256
- HS384: HMAC using SHA-384
- HS512: HMAC using SHA-512
"""
SHA256 = hashlib.sha256
SHA384 = hashlib.sha384
SHA512 = hashlib.sha512
def __init__(self, sha_type):
self.name = f"HS{sha_type}"
self.description = f"HMAC using SHA-{sha_type}"
self.hash_alg = getattr(self, f"SHA{sha_type}")
def prepare_key(self, raw_data):
return OctKey.import_key(raw_data)
def sign(self, msg, key):
# it is faster than the one in cryptography
op_key = key.get_op_key("sign")
return hmac.new(op_key, msg, self.hash_alg).digest()
def verify(self, msg, sig, key):
op_key = key.get_op_key("verify")
v_sig = hmac.new(op_key, msg, self.hash_alg).digest()
return hmac.compare_digest(sig, v_sig)
class RSAAlgorithm(JWSAlgorithm):
"""RSA using SHA algorithms for JWS. Available algorithms:
- RS256: RSASSA-PKCS1-v1_5 using SHA-256
- RS384: RSASSA-PKCS1-v1_5 using SHA-384
- RS512: RSASSA-PKCS1-v1_5 using SHA-512
"""
SHA256 = hashes.SHA256
SHA384 = hashes.SHA384
SHA512 = hashes.SHA512
def __init__(self, sha_type):
self.name = f"RS{sha_type}"
self.description = f"RSASSA-PKCS1-v1_5 using SHA-{sha_type}"
self.hash_alg = getattr(self, f"SHA{sha_type}")
self.padding = padding.PKCS1v15()
def prepare_key(self, raw_data):
return RSAKey.import_key(raw_data)
def sign(self, msg, key):
op_key = key.get_op_key("sign")
return op_key.sign(msg, self.padding, self.hash_alg())
def verify(self, msg, sig, key):
op_key = key.get_op_key("verify")
try:
op_key.verify(sig, msg, self.padding, self.hash_alg())
return True
except InvalidSignature:
return False
class ECAlgorithm(JWSAlgorithm):
"""ECDSA using SHA algorithms for JWS. Available algorithms:
- ES256: ECDSA using P-256 and SHA-256
- ES384: ECDSA using P-384 and SHA-384
- ES512: ECDSA using P-521 and SHA-512
"""
SHA256 = hashes.SHA256
SHA384 = hashes.SHA384
SHA512 = hashes.SHA512
def __init__(self, name, curve, sha_type):
self.name = name
self.curve = curve
self.description = f"ECDSA using {self.curve} and SHA-{sha_type}"
self.hash_alg = getattr(self, f"SHA{sha_type}")
def prepare_key(self, raw_data):
key = ECKey.import_key(raw_data)
if key["crv"] != self.curve:
raise ValueError(
f'Key for "{self.name}" not supported, only "{self.curve}" allowed'
)
return key
def sign(self, msg, key):
op_key = key.get_op_key("sign")
der_sig = op_key.sign(msg, ECDSA(self.hash_alg()))
r, s = decode_dss_signature(der_sig)
size = key.curve_key_size
return encode_int(r, size) + encode_int(s, size)
def verify(self, msg, sig, key):
key_size = key.curve_key_size
length = (key_size + 7) // 8
if len(sig) != 2 * length:
return False
r = decode_int(sig[:length])
s = decode_int(sig[length:])
der_sig = encode_dss_signature(r, s)
try:
op_key = key.get_op_key("verify")
op_key.verify(der_sig, msg, ECDSA(self.hash_alg()))
return True
except InvalidSignature:
return False
class RSAPSSAlgorithm(JWSAlgorithm):
"""RSASSA-PSS using SHA algorithms for JWS. Available algorithms:
- PS256: RSASSA-PSS using SHA-256 and MGF1 with SHA-256
- PS384: RSASSA-PSS using SHA-384 and MGF1 with SHA-384
- PS512: RSASSA-PSS using SHA-512 and MGF1 with SHA-512
"""
SHA256 = hashes.SHA256
SHA384 = hashes.SHA384
SHA512 = hashes.SHA512
def __init__(self, sha_type):
self.name = f"PS{sha_type}"
tpl = "RSASSA-PSS using SHA-{} and MGF1 with SHA-{}"
self.description = tpl.format(sha_type, sha_type)
self.hash_alg = getattr(self, f"SHA{sha_type}")
def prepare_key(self, raw_data):
return RSAKey.import_key(raw_data)
def sign(self, msg, key):
op_key = key.get_op_key("sign")
return op_key.sign(
msg,
padding.PSS(
mgf=padding.MGF1(self.hash_alg()), salt_length=self.hash_alg.digest_size
),
self.hash_alg(),
)
def verify(self, msg, sig, key):
op_key = key.get_op_key("verify")
try:
op_key.verify(
sig,
msg,
padding.PSS(
mgf=padding.MGF1(self.hash_alg()),
salt_length=self.hash_alg.digest_size,
),
self.hash_alg(),
)
return True
except InvalidSignature:
return False
JWS_ALGORITHMS = [
NoneAlgorithm(), # none
HMACAlgorithm(256), # HS256
HMACAlgorithm(384), # HS384
HMACAlgorithm(512), # HS512
RSAAlgorithm(256), # RS256
RSAAlgorithm(384), # RS384
RSAAlgorithm(512), # RS512
ECAlgorithm("ES256", "P-256", 256),
ECAlgorithm("ES384", "P-384", 384),
ECAlgorithm("ES512", "P-521", 512),
ECAlgorithm("ES256K", "secp256k1", 256), # defined in RFC8812
RSAPSSAlgorithm(256), # PS256
RSAPSSAlgorithm(384), # PS384
RSAPSSAlgorithm(512), # PS512
]
@@ -0,0 +1,96 @@
import secrets
from authlib.common.encoding import to_bytes
from authlib.common.encoding import to_unicode
from authlib.common.encoding import urlsafe_b64decode
from authlib.common.encoding import urlsafe_b64encode
from ..rfc7517 import Key
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
class OctKey(Key):
"""Key class of the ``oct`` key type."""
kty = "oct"
REQUIRED_JSON_FIELDS = ["k"]
def __init__(self, raw_key=None, options=None):
super().__init__(options)
self.raw_key = raw_key
@property
def public_only(self):
return False
def get_op_key(self, operation):
"""Get the raw key for the given key_op. This method will also
check if the given key_op is supported by this key.
:param operation: key operation value, such as "sign", "encrypt".
:return: raw key
"""
self.check_key_op(operation)
if not self.raw_key:
self.load_raw_key()
return self.raw_key
def load_raw_key(self):
self.raw_key = urlsafe_b64decode(to_bytes(self.tokens["k"]))
def load_dict_key(self):
k = to_unicode(urlsafe_b64encode(self.raw_key))
self._dict_data = {"kty": self.kty, "k": k}
def as_dict(self, is_private=False, **params):
tokens = self.tokens
if "kid" not in tokens:
tokens["kid"] = self.thumbprint()
tokens.update(params)
return tokens
@classmethod
def validate_raw_key(cls, key):
return isinstance(key, bytes)
@classmethod
def import_key(cls, raw, options=None):
"""Import a key from bytes, string, or dict data."""
if isinstance(raw, cls):
if options is not None:
raw.options.update(options)
return raw
if isinstance(raw, dict):
cls.check_required_fields(raw)
key = cls(options=options)
key._dict_data = raw
else:
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
key = cls(raw_key=raw_key, options=options)
return key
@classmethod
def generate_key(cls, key_size=256, options=None, is_private=True):
"""Generate a ``OctKey`` with the given bit size."""
if not is_private:
raise ValueError("oct key can not be generated as public")
if key_size % 8 != 0:
raise ValueError("Invalid bit size for oct key")
return cls.import_key(secrets.token_bytes(int(key_size / 8)), options)
@@ -0,0 +1,127 @@
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKeyWithSerialization
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateNumbers
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from cryptography.hazmat.primitives.asymmetric.rsa import rsa_crt_dmp1
from cryptography.hazmat.primitives.asymmetric.rsa import rsa_crt_dmq1
from cryptography.hazmat.primitives.asymmetric.rsa import rsa_crt_iqmp
from cryptography.hazmat.primitives.asymmetric.rsa import rsa_recover_prime_factors
from authlib.common.encoding import base64_to_int
from authlib.common.encoding import int_to_base64
from ..rfc7517 import AsymmetricKey
class RSAKey(AsymmetricKey):
"""Key class of the ``RSA`` key type."""
kty = "RSA"
PUBLIC_KEY_CLS = RSAPublicKey
PRIVATE_KEY_CLS = RSAPrivateKeyWithSerialization
PUBLIC_KEY_FIELDS = ["e", "n"]
PRIVATE_KEY_FIELDS = ["d", "dp", "dq", "e", "n", "p", "q", "qi"]
REQUIRED_JSON_FIELDS = ["e", "n"]
SSH_PUBLIC_PREFIX = b"ssh-rsa"
def dumps_private_key(self):
numbers = self.private_key.private_numbers()
return {
"n": int_to_base64(numbers.public_numbers.n),
"e": int_to_base64(numbers.public_numbers.e),
"d": int_to_base64(numbers.d),
"p": int_to_base64(numbers.p),
"q": int_to_base64(numbers.q),
"dp": int_to_base64(numbers.dmp1),
"dq": int_to_base64(numbers.dmq1),
"qi": int_to_base64(numbers.iqmp),
}
def dumps_public_key(self):
numbers = self.public_key.public_numbers()
return {"n": int_to_base64(numbers.n), "e": int_to_base64(numbers.e)}
def load_private_key(self):
obj = self._dict_data
if "oth" in obj: # pragma: no cover
# https://tools.ietf.org/html/rfc7518#section-6.3.2.7
raise ValueError('"oth" is not supported yet')
public_numbers = RSAPublicNumbers(
base64_to_int(obj["e"]), base64_to_int(obj["n"])
)
if has_all_prime_factors(obj):
numbers = RSAPrivateNumbers(
d=base64_to_int(obj["d"]),
p=base64_to_int(obj["p"]),
q=base64_to_int(obj["q"]),
dmp1=base64_to_int(obj["dp"]),
dmq1=base64_to_int(obj["dq"]),
iqmp=base64_to_int(obj["qi"]),
public_numbers=public_numbers,
)
else:
d = base64_to_int(obj["d"])
p, q = rsa_recover_prime_factors(public_numbers.n, d, public_numbers.e)
numbers = RSAPrivateNumbers(
d=d,
p=p,
q=q,
dmp1=rsa_crt_dmp1(d, p),
dmq1=rsa_crt_dmq1(d, q),
iqmp=rsa_crt_iqmp(p, q),
public_numbers=public_numbers,
)
return numbers.private_key(default_backend())
def load_public_key(self):
numbers = RSAPublicNumbers(
base64_to_int(self._dict_data["e"]), base64_to_int(self._dict_data["n"])
)
return numbers.public_key(default_backend())
@classmethod
def generate_key(cls, key_size=2048, options=None, is_private=False) -> "RSAKey":
if key_size < 512:
raise ValueError("key_size must not be less than 512")
if key_size % 8 != 0:
raise ValueError("Invalid key_size for RSAKey")
raw_key = rsa.generate_private_key(
public_exponent=65537,
key_size=key_size,
backend=default_backend(),
)
if not is_private:
raw_key = raw_key.public_key()
return cls.import_key(raw_key, options=options)
@classmethod
def import_dict_key(cls, raw, options=None):
cls.check_required_fields(raw)
key = cls(options=options)
key._dict_data = raw
if "d" in raw and not has_all_prime_factors(raw):
# reload dict key
key.load_raw_key()
key.load_dict_key()
return key
def has_all_prime_factors(obj):
props = ["p", "q", "dp", "dq", "qi"]
props_found = [prop in obj for prop in props]
if all(props_found):
return True
if any(props_found):
raise ValueError(
"RSA key must include all parameters if any are present besides d"
)
return False
@@ -0,0 +1,12 @@
import binascii
def encode_int(num, bits):
length = ((bits + 7) // 8) * 2
padded_hex = f"{num:0{length}x}"
big_endian = binascii.a2b_hex(padded_hex.encode("ascii"))
return big_endian
def decode_int(b):
return int(binascii.b2a_hex(b), 16)
@@ -0,0 +1,14 @@
"""authlib.jose.rfc7519.
~~~~~~~~~~~~~~~~~~~~
This module represents a direct implementation of
JSON Web Token (JWT).
https://tools.ietf.org/html/rfc7519
"""
from .claims import BaseClaims
from .claims import JWTClaims
from .jwt import JsonWebToken
__all__ = ["JsonWebToken", "BaseClaims", "JWTClaims"]
@@ -0,0 +1,227 @@
import time
from authlib.jose.errors import ExpiredTokenError
from authlib.jose.errors import InvalidClaimError
from authlib.jose.errors import InvalidTokenError
from authlib.jose.errors import MissingClaimError
class BaseClaims(dict):
"""Payload claims for JWT, which contains a validate interface.
:param payload: the payload dict of JWT
:param header: the header dict of JWT
:param options: validate options
:param params: other params
An example on ``options`` parameter, the format is inspired by
`OpenID Connect Claims`_::
{
"iss": {
"essential": True,
"values": ["https://example.com", "https://example.org"]
},
"sub": {
"essential": True
"value": "248289761001"
},
"jti": {
"validate": validate_jti
}
}
.. _`OpenID Connect Claims`:
http://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests
"""
REGISTERED_CLAIMS = []
def __init__(self, payload, header, options=None, params=None):
super().__init__(payload)
self.header = header
self.options = options or {}
self.params = params or {}
def __getattr__(self, key):
try:
return object.__getattribute__(self, key)
except AttributeError as error:
if key in self.REGISTERED_CLAIMS:
return self.get(key)
raise error
def _validate_essential_claims(self):
for k in self.options:
if self.options[k].get("essential"):
if k not in self:
raise MissingClaimError(k)
elif not self.get(k):
raise InvalidClaimError(k)
def _validate_claim_value(self, claim_name):
option = self.options.get(claim_name)
if not option:
return
value = self.get(claim_name)
option_value = option.get("value")
if option_value and value != option_value:
raise InvalidClaimError(claim_name)
option_values = option.get("values")
if option_values and value not in option_values:
raise InvalidClaimError(claim_name)
validate = option.get("validate")
if validate and not validate(self, value):
raise InvalidClaimError(claim_name)
def get_registered_claims(self): # pragma: no cover
rv = {}
for k in self.REGISTERED_CLAIMS:
if k in self:
rv[k] = self[k]
return rv
class JWTClaims(BaseClaims):
REGISTERED_CLAIMS = ["iss", "sub", "aud", "exp", "nbf", "iat", "jti"]
def validate(self, now=None, leeway=0):
"""Validate everything in claims payload."""
self._validate_essential_claims()
if now is None:
now = int(time.time())
self.validate_iss()
self.validate_sub()
self.validate_aud()
self.validate_exp(now, leeway)
self.validate_nbf(now, leeway)
self.validate_iat(now, leeway)
self.validate_jti()
# Validate custom claims
for key in self.options.keys():
if key not in self.REGISTERED_CLAIMS:
self._validate_claim_value(key)
def validate_iss(self):
"""The "iss" (issuer) claim identifies the principal that issued the
JWT. The processing of this claim is generally application specific.
The "iss" value is a case-sensitive string containing a StringOrURI
value. Use of this claim is OPTIONAL.
"""
self._validate_claim_value("iss")
def validate_sub(self):
"""The "sub" (subject) claim identifies the principal that is the
subject of the JWT. The claims in a JWT are normally statements
about the subject. The subject value MUST either be scoped to be
locally unique in the context of the issuer or be globally unique.
The processing of this claim is generally application specific. The
"sub" value is a case-sensitive string containing a StringOrURI
value. Use of this claim is OPTIONAL.
"""
self._validate_claim_value("sub")
def validate_aud(self):
"""The "aud" (audience) claim identifies the recipients that the JWT is
intended for. Each principal intended to process the JWT MUST
identify itself with a value in the audience claim. If the principal
processing the claim does not identify itself with a value in the
"aud" claim when this claim is present, then the JWT MUST be
rejected. In the general case, the "aud" value is an array of case-
sensitive strings, each containing a StringOrURI value. In the
special case when the JWT has one audience, the "aud" value MAY be a
single case-sensitive string containing a StringOrURI value. The
interpretation of audience values is generally application specific.
Use of this claim is OPTIONAL.
"""
aud_option = self.options.get("aud")
aud = self.get("aud")
if not aud_option or not aud:
return
aud_values = aud_option.get("values")
if not aud_values:
aud_value = aud_option.get("value")
if aud_value:
aud_values = [aud_value]
if not aud_values:
return
if isinstance(self["aud"], list):
aud_list = self["aud"]
else:
aud_list = [self["aud"]]
if not any([v in aud_list for v in aud_values]):
raise InvalidClaimError("aud")
def validate_exp(self, now, leeway):
"""The "exp" (expiration time) claim identifies the expiration time on
or after which the JWT MUST NOT be accepted for processing. The
processing of the "exp" claim requires that the current date/time
MUST be before the expiration date/time listed in the "exp" claim.
Implementers MAY provide for some small leeway, usually no more than
a few minutes, to account for clock skew. Its value MUST be a number
containing a NumericDate value. Use of this claim is OPTIONAL.
"""
if "exp" in self:
exp = self["exp"]
if not _validate_numeric_time(exp):
raise InvalidClaimError("exp")
if exp < (now - leeway):
raise ExpiredTokenError()
def validate_nbf(self, now, leeway):
"""The "nbf" (not before) claim identifies the time before which the JWT
MUST NOT be accepted for processing. The processing of the "nbf"
claim requires that the current date/time MUST be after or equal to
the not-before date/time listed in the "nbf" claim. Implementers MAY
provide for some small leeway, usually no more than a few minutes, to
account for clock skew. Its value MUST be a number containing a
NumericDate value. Use of this claim is OPTIONAL.
"""
if "nbf" in self:
nbf = self["nbf"]
if not _validate_numeric_time(nbf):
raise InvalidClaimError("nbf")
if nbf > (now + leeway):
raise InvalidTokenError()
def validate_iat(self, now, leeway):
"""The "iat" (issued at) claim identifies the time at which the JWT was
issued. This claim can be used to determine the age of the JWT.
Implementers MAY provide for some small leeway, usually no more
than a few minutes, to account for clock skew. Its value MUST be a
number containing a NumericDate value. Use of this claim is OPTIONAL.
"""
if "iat" in self:
iat = self["iat"]
if not _validate_numeric_time(iat):
raise InvalidClaimError("iat")
if iat > (now + leeway):
raise InvalidTokenError(
description="The token is not valid as it was issued in the future"
)
def validate_jti(self):
"""The "jti" (JWT ID) claim provides a unique identifier for the JWT.
The identifier value MUST be assigned in a manner that ensures that
there is a negligible probability that the same value will be
accidentally assigned to a different data object; if the application
uses multiple issuers, collisions MUST be prevented among values
produced by different issuers as well. The "jti" claim can be used
to prevent the JWT from being replayed. The "jti" value is a case-
sensitive string. Use of this claim is OPTIONAL.
"""
self._validate_claim_value("jti")
def _validate_numeric_time(s):
return isinstance(s, (int, float))
@@ -0,0 +1,191 @@
import calendar
import datetime
import random
import re
from authlib.common.encoding import json_dumps
from authlib.common.encoding import json_loads
from authlib.common.encoding import to_bytes
from authlib.common.encoding import to_unicode
from ..errors import DecodeError
from ..errors import InsecureClaimError
from ..rfc7515 import JsonWebSignature
from ..rfc7516 import JsonWebEncryption
from ..rfc7517 import Key
from ..rfc7517 import KeySet
from .claims import JWTClaims
class JsonWebToken:
SENSITIVE_NAMES = ("password", "token", "secret", "secret_key")
# Thanks to sentry SensitiveDataFilter
SENSITIVE_VALUES = re.compile(
r"|".join(
[
# http://www.richardsramblings.com/regex/credit-card-numbers/
r"\b(?:3[47]\d|(?:4\d|5[1-5]|65)\d{2}|6011)\d{12}\b",
# various private keys
r"-----BEGIN[A-Z ]+PRIVATE KEY-----.+-----END[A-Z ]+PRIVATE KEY-----",
# social security numbers (US)
r"^\b(?!(000|666|9))\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b",
]
),
re.DOTALL,
)
def __init__(self, algorithms, private_headers=None):
self._jws = JsonWebSignature(algorithms, private_headers=private_headers)
self._jwe = JsonWebEncryption(algorithms, private_headers=private_headers)
def check_sensitive_data(self, payload):
"""Check if payload contains sensitive information."""
for k in payload:
# check claims key name
if k in self.SENSITIVE_NAMES:
raise InsecureClaimError(k)
# check claims values
v = payload[k]
if isinstance(v, str) and self.SENSITIVE_VALUES.search(v):
raise InsecureClaimError(k)
def encode(self, header, payload, key, check=True):
"""Encode a JWT with the given header, payload and key.
:param header: A dict of JWS header
:param payload: A dict to be encoded
:param key: key used to sign the signature
:param check: check if sensitive data in payload
:return: bytes
"""
header.setdefault("typ", "JWT")
for k in ["exp", "iat", "nbf"]:
# convert datetime into timestamp
claim = payload.get(k)
if isinstance(claim, datetime.datetime):
payload[k] = calendar.timegm(claim.utctimetuple())
if check:
self.check_sensitive_data(payload)
key = find_encode_key(key, header)
text = to_bytes(json_dumps(payload))
if "enc" in header:
return self._jwe.serialize_compact(header, text, key)
else:
return self._jws.serialize_compact(header, text, key)
def decode(self, s, key, claims_cls=None, claims_options=None, claims_params=None):
"""Decode the JWT with the given key. This is similar with
:meth:`verify`, except that it will raise BadSignatureError when
signature doesn't match.
:param s: text of JWT
:param key: key used to verify the signature
:param claims_cls: class to be used for JWT claims
:param claims_options: `options` parameters for claims_cls
:param claims_params: `params` parameters for claims_cls
:return: claims_cls instance
:raise: BadSignatureError
"""
if claims_cls is None:
claims_cls = JWTClaims
if callable(key):
load_key = key
else:
load_key = create_load_key(prepare_raw_key(key))
s = to_bytes(s)
dot_count = s.count(b".")
if dot_count == 2:
data = self._jws.deserialize_compact(s, load_key, decode_payload)
elif dot_count == 4:
data = self._jwe.deserialize_compact(s, load_key, decode_payload)
else:
raise DecodeError("Invalid input segments length")
return claims_cls(
data["payload"],
data["header"],
options=claims_options,
params=claims_params,
)
def decode_payload(bytes_payload):
try:
payload = json_loads(to_unicode(bytes_payload))
except ValueError as exc:
raise DecodeError("Invalid payload value") from exc
if not isinstance(payload, dict):
raise DecodeError("Invalid payload type")
return payload
def prepare_raw_key(raw):
if isinstance(raw, KeySet):
return raw
if isinstance(raw, str) and raw.startswith("{") and raw.endswith("}"):
raw = json_loads(raw)
elif isinstance(raw, (tuple, list)):
raw = {"keys": raw}
return raw
def find_encode_key(key, header):
if isinstance(key, KeySet):
kid = header.get("kid")
if kid:
return key.find_by_kid(kid)
rv = random.choice(key.keys)
# use side effect to add kid value into header
header["kid"] = rv.kid
return rv
if isinstance(key, dict) and "keys" in key:
keys = key["keys"]
kid = header.get("kid")
for k in keys:
if k.get("kid") == kid:
return k
if not kid:
rv = random.choice(keys)
header["kid"] = rv["kid"]
return rv
raise ValueError("Invalid JSON Web Key Set")
# append kid into header
if isinstance(key, dict) and "kid" in key:
header["kid"] = key["kid"]
elif isinstance(key, Key) and key.kid:
header["kid"] = key.kid
return key
def create_load_key(key):
def load_key(header, payload):
if isinstance(key, KeySet):
return key.find_by_kid(header.get("kid"))
if isinstance(key, dict) and "keys" in key:
keys = key["keys"]
kid = header.get("kid")
if kid is not None:
# look for the requested key
for k in keys:
if k.get("kid") == kid:
return k
else:
# use the only key
if len(keys) == 1:
return keys[0]
raise ValueError("Invalid JSON Web Key Set")
return key
return load_key
@@ -0,0 +1,4 @@
from .jws_eddsa import register_jws_rfc8037
from .okp_key import OKPKey
__all__ = ["register_jws_rfc8037", "OKPKey"]
@@ -0,0 +1,28 @@
from cryptography.exceptions import InvalidSignature
from ..rfc7515 import JWSAlgorithm
from .okp_key import OKPKey
class EdDSAAlgorithm(JWSAlgorithm):
name = "EdDSA"
description = "Edwards-curve Digital Signature Algorithm for JWS"
def prepare_key(self, raw_data):
return OKPKey.import_key(raw_data)
def sign(self, msg, key):
op_key = key.get_op_key("sign")
return op_key.sign(msg)
def verify(self, msg, sig, key):
op_key = key.get_op_key("verify")
try:
op_key.verify(sig, msg)
return True
except InvalidSignature:
return False
def register_jws_rfc8037(cls):
cls.register_algorithm(EdDSAAlgorithm())
@@ -0,0 +1,99 @@
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PublicKey
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.asymmetric.x448 import X448PrivateKey
from cryptography.hazmat.primitives.asymmetric.x448 import X448PublicKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.hazmat.primitives.serialization import NoEncryption
from cryptography.hazmat.primitives.serialization import PrivateFormat
from cryptography.hazmat.primitives.serialization import PublicFormat
from authlib.common.encoding import to_bytes
from authlib.common.encoding import to_unicode
from authlib.common.encoding import urlsafe_b64decode
from authlib.common.encoding import urlsafe_b64encode
from ..rfc7517 import AsymmetricKey
PUBLIC_KEYS_MAP = {
"Ed25519": Ed25519PublicKey,
"Ed448": Ed448PublicKey,
"X25519": X25519PublicKey,
"X448": X448PublicKey,
}
PRIVATE_KEYS_MAP = {
"Ed25519": Ed25519PrivateKey,
"Ed448": Ed448PrivateKey,
"X25519": X25519PrivateKey,
"X448": X448PrivateKey,
}
class OKPKey(AsymmetricKey):
"""Key class of the ``OKP`` key type."""
kty = "OKP"
REQUIRED_JSON_FIELDS = ["crv", "x"]
PUBLIC_KEY_FIELDS = REQUIRED_JSON_FIELDS
PRIVATE_KEY_FIELDS = ["crv", "d"]
PUBLIC_KEY_CLS = tuple(PUBLIC_KEYS_MAP.values())
PRIVATE_KEY_CLS = tuple(PRIVATE_KEYS_MAP.values())
SSH_PUBLIC_PREFIX = b"ssh-ed25519"
def exchange_shared_key(self, pubkey):
# used in ECDHESAlgorithm
private_key = self.get_private_key()
if private_key and isinstance(private_key, (X25519PrivateKey, X448PrivateKey)):
return private_key.exchange(pubkey)
raise ValueError("Invalid key for exchanging shared key")
@staticmethod
def get_key_curve(key):
if isinstance(key, (Ed25519PublicKey, Ed25519PrivateKey)):
return "Ed25519"
elif isinstance(key, (Ed448PublicKey, Ed448PrivateKey)):
return "Ed448"
elif isinstance(key, (X25519PublicKey, X25519PrivateKey)):
return "X25519"
elif isinstance(key, (X448PublicKey, X448PrivateKey)):
return "X448"
def load_private_key(self):
crv_key = PRIVATE_KEYS_MAP[self._dict_data["crv"]]
d_bytes = urlsafe_b64decode(to_bytes(self._dict_data["d"]))
return crv_key.from_private_bytes(d_bytes)
def load_public_key(self):
crv_key = PUBLIC_KEYS_MAP[self._dict_data["crv"]]
x_bytes = urlsafe_b64decode(to_bytes(self._dict_data["x"]))
return crv_key.from_public_bytes(x_bytes)
def dumps_private_key(self):
obj = self.dumps_public_key(self.private_key.public_key())
d_bytes = self.private_key.private_bytes(
Encoding.Raw, PrivateFormat.Raw, NoEncryption()
)
obj["d"] = to_unicode(urlsafe_b64encode(d_bytes))
return obj
def dumps_public_key(self, public_key=None):
if public_key is None:
public_key = self.public_key
x_bytes = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
return {
"crv": self.get_key_curve(public_key),
"x": to_unicode(urlsafe_b64encode(x_bytes)),
}
@classmethod
def generate_key(cls, crv="Ed25519", options=None, is_private=False) -> "OKPKey":
if crv not in PRIVATE_KEYS_MAP:
raise ValueError(f'Invalid crv value: "{crv}"')
private_key_cls = PRIVATE_KEYS_MAP[crv]
raw_key = private_key_cls.generate()
if not is_private:
raw_key = raw_key.public_key()
return cls.import_key(raw_key, options=options)
@@ -0,0 +1,46 @@
import binascii
from authlib.common.encoding import json_loads
from authlib.common.encoding import to_unicode
from authlib.common.encoding import urlsafe_b64decode
from authlib.jose.errors import DecodeError
def extract_header(header_segment, error_cls):
if len(header_segment) > 256000:
raise ValueError("Value of header is too long")
header_data = extract_segment(header_segment, error_cls, "header")
try:
header = json_loads(header_data.decode("utf-8"))
except ValueError as e:
raise error_cls(f"Invalid header string: {e}") from e
if not isinstance(header, dict):
raise error_cls("Header must be a json object")
return header
def extract_segment(segment, error_cls, name="payload"):
if len(segment) > 256000:
raise ValueError(f"Value of {name} is too long")
try:
return urlsafe_b64decode(segment)
except (TypeError, binascii.Error) as exc:
msg = f"Invalid {name} padding"
raise error_cls(msg) from exc
def ensure_dict(s, structure_name):
if not isinstance(s, dict):
try:
s = json_loads(to_unicode(s))
except (ValueError, TypeError) as exc:
raise DecodeError(f"Invalid {structure_name}") from exc
if not isinstance(s, dict):
raise DecodeError(f"Invalid {structure_name}")
return s
@@ -0,0 +1,31 @@
from .rfc5849 import SIGNATURE_HMAC_SHA1
from .rfc5849 import SIGNATURE_PLAINTEXT
from .rfc5849 import SIGNATURE_RSA_SHA1
from .rfc5849 import SIGNATURE_TYPE_BODY
from .rfc5849 import SIGNATURE_TYPE_HEADER
from .rfc5849 import SIGNATURE_TYPE_QUERY
from .rfc5849 import AuthorizationServer
from .rfc5849 import ClientAuth
from .rfc5849 import ClientMixin
from .rfc5849 import OAuth1Request
from .rfc5849 import ResourceProtector
from .rfc5849 import TemporaryCredential
from .rfc5849 import TemporaryCredentialMixin
from .rfc5849 import TokenCredentialMixin
__all__ = [
"OAuth1Request",
"ClientAuth",
"SIGNATURE_HMAC_SHA1",
"SIGNATURE_RSA_SHA1",
"SIGNATURE_PLAINTEXT",
"SIGNATURE_TYPE_HEADER",
"SIGNATURE_TYPE_QUERY",
"SIGNATURE_TYPE_BODY",
"ClientMixin",
"TemporaryCredentialMixin",
"TokenCredentialMixin",
"TemporaryCredential",
"AuthorizationServer",
"ResourceProtector",
]
@@ -0,0 +1,186 @@
from authlib.common.encoding import json_loads
from authlib.common.urls import add_params_to_uri
from authlib.common.urls import url_decode
from authlib.common.urls import urlparse
from .rfc5849 import SIGNATURE_HMAC_SHA1
from .rfc5849 import SIGNATURE_TYPE_HEADER
from .rfc5849 import ClientAuth
class OAuth1Client:
auth_class = ClientAuth
def __init__(
self,
session,
client_id,
client_secret=None,
token=None,
token_secret=None,
redirect_uri=None,
rsa_key=None,
verifier=None,
signature_method=SIGNATURE_HMAC_SHA1,
signature_type=SIGNATURE_TYPE_HEADER,
force_include_body=False,
realm=None,
**kwargs,
):
if not client_id:
raise ValueError('Missing "client_id"')
self.session = session
self.auth = self.auth_class(
client_id,
client_secret=client_secret,
token=token,
token_secret=token_secret,
redirect_uri=redirect_uri,
signature_method=signature_method,
signature_type=signature_type,
rsa_key=rsa_key,
verifier=verifier,
realm=realm,
force_include_body=force_include_body,
)
self._kwargs = kwargs
@property
def redirect_uri(self):
return self.auth.redirect_uri
@redirect_uri.setter
def redirect_uri(self, uri):
self.auth.redirect_uri = uri
@property
def token(self):
return dict(
oauth_token=self.auth.token,
oauth_token_secret=self.auth.token_secret,
oauth_verifier=self.auth.verifier,
)
@token.setter
def token(self, token):
"""This token setter is designed for an easy integration for
OAuthClient. Make sure both OAuth1Session and OAuth2Session
have token setters.
"""
if token is None:
self.auth.token = None
self.auth.token_secret = None
self.auth.verifier = None
elif "oauth_token" in token:
self.auth.token = token["oauth_token"]
if "oauth_token_secret" in token:
self.auth.token_secret = token["oauth_token_secret"]
if "oauth_verifier" in token:
self.auth.verifier = token["oauth_verifier"]
else:
message = f"oauth_token is missing: {token!r}"
self.handle_error("missing_token", message)
def create_authorization_url(self, url, request_token=None, **kwargs):
"""Create an authorization URL by appending request_token and optional
kwargs to url.
This is the second step in the OAuth 1 workflow. The user should be
redirected to this authorization URL, grant access to you, and then
be redirected back to you. The redirection back can either be specified
during client registration or by supplying a callback URI per request.
:param url: The authorization endpoint URL.
:param request_token: The previously obtained request token.
:param kwargs: Optional parameters to append to the URL.
:returns: The authorization URL with new parameters embedded.
"""
kwargs["oauth_token"] = request_token or self.auth.token
if self.auth.redirect_uri:
kwargs["oauth_callback"] = self.auth.redirect_uri
return add_params_to_uri(url, kwargs.items())
def fetch_request_token(self, url, **kwargs):
"""Method for fetching an access token from the token endpoint.
This is the first step in the OAuth 1 workflow. A request token is
obtained by making a signed post request to url. The token is then
parsed from the application/x-www-form-urlencoded response and ready
to be used to construct an authorization url.
:param url: Request Token endpoint.
:param kwargs: Extra parameters to include for fetching token.
:return: A Request Token dict.
"""
return self._fetch_token(url, **kwargs)
def fetch_access_token(self, url, verifier=None, **kwargs):
"""Method for fetching an access token from the token endpoint.
This is the final step in the OAuth 1 workflow. An access token is
obtained using all previously obtained credentials, including the
verifier from the authorization step.
:param url: Access Token endpoint.
:param verifier: A verifier string to prove authorization was granted.
:param kwargs: Extra parameters to include for fetching access token.
:return: A token dict.
"""
if verifier:
self.auth.verifier = verifier
if not self.auth.verifier:
self.handle_error("missing_verifier", 'Missing "verifier" value')
return self._fetch_token(url, **kwargs)
def parse_authorization_response(self, url):
"""Extract parameters from the post authorization redirect
response URL.
:param url: The full URL that resulted from the user being redirected
back from the OAuth provider to you, the client.
:returns: A dict of parameters extracted from the URL.
"""
token = dict(url_decode(urlparse.urlparse(url).query))
self.token = token
return token
def _fetch_token(self, url, **kwargs):
resp = self.session.post(url, auth=self.auth, **kwargs)
token = self.parse_response_token(resp.status_code, resp.text)
self.token = token
self.auth.verifier = None
return token
def parse_response_token(self, status_code, text):
if status_code >= 400:
message = (
f"Token request failed with code {status_code}, response was '{text}'."
)
self.handle_error("fetch_token_denied", message)
try:
text = text.strip()
if text.startswith("{"):
token = json_loads(text)
else:
token = dict(url_decode(text))
except (TypeError, ValueError) as e:
error = (
"Unable to decode token from token response. "
"This is commonly caused by an unsuccessful request where"
" a non urlencoded error message is returned. "
f"The decoding error was {e}"
)
raise ValueError(error) from e
return token
@staticmethod
def handle_error(error_type, error_description):
raise ValueError(f"{error_type}: {error_description}")
def __del__(self):
try:
del self.session
except AttributeError:
pass

Some files were not shown because too many files have changed in this diff Show More