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