188 lines
6.8 KiB
Python
188 lines
6.8 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from joserfc import jwk
|
|
from joserfc import jws
|
|
from joserfc import jwt
|
|
from joserfc.errors import JoseError
|
|
from joserfc.util import to_bytes
|
|
|
|
from authlib._joserfc_helpers import import_any_key
|
|
from authlib.common.encoding import json_loads
|
|
from authlib.deprecate import deprecate
|
|
|
|
from ..rfc6749 import InvalidClientError
|
|
|
|
ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class JWTBearerClientAssertion:
|
|
"""Implementation of Using JWTs for Client Authentication, which is
|
|
defined by RFC7523.
|
|
"""
|
|
|
|
#: Value of ``client_assertion_type`` of JWTs
|
|
CLIENT_ASSERTION_TYPE = ASSERTION_TYPE
|
|
#: Name of the client authentication method
|
|
CLIENT_AUTH_METHOD = "client_assertion_jwt"
|
|
|
|
def __init__(self, token_url=None, validate_jti=True, leeway=60):
|
|
if token_url is not None: # pragma: no cover
|
|
deprecate(
|
|
"'token_url' is deprecated. Override 'get_audiences' instead.",
|
|
version="1.8",
|
|
)
|
|
self.token_url = token_url
|
|
self._validate_jti = validate_jti
|
|
# A small allowance of time, typically no more than a few minutes,
|
|
# to account for clock skew. The default is 60 seconds.
|
|
self.leeway = leeway
|
|
|
|
def __call__(self, query_client, request):
|
|
data = request.form
|
|
assertion_type = data.get("client_assertion_type")
|
|
assertion = data.get("client_assertion")
|
|
if assertion_type == ASSERTION_TYPE and assertion:
|
|
headers, claims = self.extract_assertion(assertion)
|
|
# "sub" is read before the signature and the claims are verified,
|
|
# so it cannot be assumed to be present nor to be a string.
|
|
if "sub" not in claims:
|
|
raise InvalidClientError(description="Missing claim: 'sub'")
|
|
|
|
client_id = claims["sub"]
|
|
if not isinstance(client_id, str):
|
|
raise InvalidClientError(description="Invalid claim: 'sub'")
|
|
|
|
client = query_client(client_id)
|
|
if not client:
|
|
raise InvalidClientError(
|
|
description="The client does not exist on this server."
|
|
)
|
|
|
|
try:
|
|
key = import_any_key(self.resolve_client_public_key(client))
|
|
except TypeError: # pragma: no cover
|
|
key = import_any_key(self.resolve_client_public_key(client, headers))
|
|
deprecate(
|
|
"resolve_client_public_key takes only 'client' parameter.",
|
|
version="1.8",
|
|
)
|
|
|
|
request.client = client
|
|
self.process_assertion_claims(assertion, key)
|
|
return self.authenticate_client(request.client)
|
|
log.debug("Authenticate via %r failed", self.CLIENT_AUTH_METHOD)
|
|
|
|
def verify_claims(self, claims: jwt.Claims):
|
|
# iss and sub MUST be the client_id
|
|
options = {
|
|
"iss": {"essential": True},
|
|
"sub": {"essential": True},
|
|
"aud": {"essential": True, "values": self.get_audiences()},
|
|
"exp": {"essential": True},
|
|
}
|
|
claims_requests = jwt.JWTClaimsRegistry(leeway=self.leeway, **options)
|
|
|
|
try:
|
|
claims_requests.validate(claims)
|
|
except JoseError as e:
|
|
log.debug("Assertion Error: %r", e)
|
|
raise InvalidClientError(description=e.description) from e
|
|
|
|
if claims["sub"] != claims["iss"]:
|
|
raise InvalidClientError(description="Issuer and Subject MUST match.")
|
|
|
|
if self._validate_jti:
|
|
if "jti" not in claims:
|
|
raise InvalidClientError(description="Missing JWT ID.")
|
|
|
|
if not self.validate_jti(claims, claims["jti"]):
|
|
raise InvalidClientError(description="JWT ID is used before.")
|
|
|
|
def get_audiences(self):
|
|
"""Return a list of valid audience identifiers for this authorization
|
|
server. Per RFC 7523 Section 3, the audience identifies the
|
|
authorization server as an intended audience.
|
|
|
|
Developers MUST implement this method::
|
|
|
|
def get_audiences(self):
|
|
return ["https://example.com/oauth/token", "https://example.com"]
|
|
|
|
:return: list of valid audience strings
|
|
"""
|
|
if self.token_url is not None: # pragma: no cover
|
|
return [self.token_url]
|
|
raise NotImplementedError() # pragma: no cover
|
|
|
|
def process_assertion_claims(self, assertion, resolve_key):
|
|
"""Extract JWT payload claims from request "assertion", per
|
|
`Section 3.1`_.
|
|
|
|
:param assertion: assertion string value in the request
|
|
:param resolve_key: function to resolve the sign key
|
|
:return: JWTClaims
|
|
:raise: InvalidClientError
|
|
|
|
.. _`Section 3.1`: https://tools.ietf.org/html/rfc7523#section-3.1
|
|
"""
|
|
try:
|
|
token = jwt.decode(assertion, resolve_key)
|
|
except JoseError as e:
|
|
log.debug("Assertion Error: %r", e)
|
|
raise InvalidClientError(description=e.description) from e
|
|
|
|
self.verify_claims(token.claims)
|
|
return token.claims
|
|
|
|
def authenticate_client(self, client):
|
|
if client.check_endpoint_auth_method(self.CLIENT_AUTH_METHOD, "token"):
|
|
return client
|
|
raise InvalidClientError(
|
|
description=f"The client cannot authenticate with method: {self.CLIENT_AUTH_METHOD}"
|
|
)
|
|
|
|
def extract_assertion(self, assertion: str):
|
|
try:
|
|
obj = jws.extract_compact(to_bytes(assertion))
|
|
except JoseError as e:
|
|
log.debug("Assertion Error: %r", e)
|
|
raise InvalidClientError(description="Invalid JWT assertion.") from e
|
|
|
|
try:
|
|
claims = json_loads(obj.payload)
|
|
except ValueError:
|
|
raise InvalidClientError(description="Invalid JWT payload.") from None
|
|
|
|
if not isinstance(claims, dict):
|
|
raise InvalidClientError(description="Invalid JWT payload.")
|
|
|
|
return obj.headers(), claims
|
|
|
|
def validate_jti(self, claims, jti):
|
|
"""Validate if the given ``jti`` value is used before. Developers
|
|
MUST implement this method::
|
|
|
|
def validate_jti(self, claims, jti):
|
|
key = "jti:{}-{}".format(claims["sub"], jti)
|
|
if redis.get(key):
|
|
return False
|
|
redis.set(key, 1, ex=3600)
|
|
return True
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def resolve_client_public_key(self, client) -> jwk.Key | jwk.KeySet:
|
|
"""Resolve the client public key for verifying the JWT signature.
|
|
Developers MUST implement this method::
|
|
|
|
from joserfc.jwk import KeySet
|
|
|
|
|
|
def resolve_client_public_key(self, client):
|
|
return KeySet.import_key_set(client.public_jwks)
|
|
"""
|
|
raise NotImplementedError()
|