first commit
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"""authlib.oidc.core.
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
OpenID Connect Core 1.0 Implementation.
|
||||
|
||||
http://openid.net/specs/openid-connect-core-1_0.html
|
||||
"""
|
||||
|
||||
from .claims import CodeIDToken
|
||||
from .claims import HybridIDToken
|
||||
from .claims import IDToken
|
||||
from .claims import ImplicitIDToken
|
||||
from .claims import UserInfo
|
||||
from .claims import get_claim_cls_by_response_type
|
||||
from .grants import OpenIDCode
|
||||
from .grants import OpenIDHybridGrant
|
||||
from .grants import OpenIDImplicitGrant
|
||||
from .grants import OpenIDToken
|
||||
from .models import AuthorizationCodeMixin
|
||||
from .userinfo import UserInfoEndpoint
|
||||
|
||||
__all__ = [
|
||||
"AuthorizationCodeMixin",
|
||||
"IDToken",
|
||||
"CodeIDToken",
|
||||
"ImplicitIDToken",
|
||||
"HybridIDToken",
|
||||
"UserInfo",
|
||||
"UserInfoEndpoint",
|
||||
"get_claim_cls_by_response_type",
|
||||
"OpenIDToken",
|
||||
"OpenIDCode",
|
||||
"OpenIDHybridGrant",
|
||||
"OpenIDImplicitGrant",
|
||||
]
|
||||
@@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
from joserfc.errors import InvalidClaimError
|
||||
from joserfc.errors import MissingClaimError
|
||||
|
||||
from authlib.common.encoding import to_bytes
|
||||
from authlib.common.language import is_valid_language_tag
|
||||
from authlib.oauth2.claims import JWTClaims
|
||||
from authlib.oauth2.rfc6749.util import scope_to_list
|
||||
|
||||
from .util import create_half_hash
|
||||
|
||||
__all__ = [
|
||||
"IDToken",
|
||||
"CodeIDToken",
|
||||
"ImplicitIDToken",
|
||||
"HybridIDToken",
|
||||
"UserInfo",
|
||||
"get_claim_cls_by_response_type",
|
||||
]
|
||||
|
||||
_REGISTERED_CLAIMS = [
|
||||
"iss",
|
||||
"sub",
|
||||
"aud",
|
||||
"exp",
|
||||
"nbf",
|
||||
"iat",
|
||||
"auth_time",
|
||||
"nonce",
|
||||
"acr",
|
||||
"amr",
|
||||
"azp",
|
||||
"at_hash",
|
||||
]
|
||||
|
||||
|
||||
class IDToken(JWTClaims):
|
||||
ESSENTIAL_CLAIMS = ["iss", "sub", "aud", "exp", "iat"]
|
||||
|
||||
def validate(self, now=None, leeway=0):
|
||||
for k in self.ESSENTIAL_CLAIMS:
|
||||
if k not in self:
|
||||
raise MissingClaimError(k)
|
||||
|
||||
super().validate(now, leeway)
|
||||
self.validate_auth_time()
|
||||
self.validate_nonce()
|
||||
self.validate_amr()
|
||||
self.validate_azp()
|
||||
self.validate_at_hash()
|
||||
|
||||
def validate_auth_time(self):
|
||||
"""Time when the End-User authentication occurred. Its value is a JSON
|
||||
number representing the number of seconds from 1970-01-01T0:0:0Z as
|
||||
measured in UTC until the date/time. When a max_age request is made or
|
||||
when auth_time is requested as an Essential Claim, then this Claim is
|
||||
REQUIRED; otherwise, its inclusion is OPTIONAL.
|
||||
"""
|
||||
auth_time = self.get("auth_time")
|
||||
if self.params.get("max_age") and not auth_time:
|
||||
raise MissingClaimError("auth_time")
|
||||
|
||||
if auth_time and not isinstance(auth_time, (int, float)):
|
||||
raise InvalidClaimError("auth_time")
|
||||
|
||||
def validate_nonce(self):
|
||||
"""String value used to associate a Client session with an ID Token,
|
||||
and to mitigate replay attacks. The value is passed through unmodified
|
||||
from the Authentication Request to the ID Token. If present in the ID
|
||||
Token, Clients MUST verify that the nonce Claim Value is equal to the
|
||||
value of the nonce parameter sent in the Authentication Request. If
|
||||
present in the Authentication Request, Authorization Servers MUST
|
||||
include a nonce Claim in the ID Token with the Claim Value being the
|
||||
nonce value sent in the Authentication Request. Authorization Servers
|
||||
SHOULD perform no other processing on nonce values used. The nonce
|
||||
value is a case sensitive string.
|
||||
"""
|
||||
nonce_value = self.params.get("nonce")
|
||||
if nonce_value:
|
||||
if "nonce" not in self:
|
||||
raise MissingClaimError("nonce")
|
||||
if nonce_value != self["nonce"]:
|
||||
raise InvalidClaimError("nonce")
|
||||
|
||||
def validate_amr(self):
|
||||
"""OPTIONAL. Authentication Methods References. JSON array of strings
|
||||
that are identifiers for authentication methods used in the
|
||||
authentication. For instance, values might indicate that both password
|
||||
and OTP authentication methods were used. The definition of particular
|
||||
values to be used in the amr Claim is beyond the scope of this
|
||||
specification. Parties using this claim will need to agree upon the
|
||||
meanings of the values used, which may be context-specific. The amr
|
||||
value is an array of case sensitive strings.
|
||||
"""
|
||||
amr = self.get("amr")
|
||||
if amr and not isinstance(self["amr"], list):
|
||||
raise InvalidClaimError("amr")
|
||||
|
||||
def validate_azp(self):
|
||||
"""OPTIONAL. Authorized party - the party to which the ID Token was
|
||||
issued. If present, it MUST contain the OAuth 2.0 Client ID of this
|
||||
party. This Claim is only needed when the ID Token has a single
|
||||
audience value and that audience is different than the authorized
|
||||
party. It MAY be included even when the authorized party is the same
|
||||
as the sole audience. The azp value is a case sensitive string
|
||||
containing a StringOrURI value.
|
||||
"""
|
||||
aud = self.get("aud")
|
||||
client_id = self.params.get("client_id")
|
||||
required = False
|
||||
if aud and client_id:
|
||||
if isinstance(aud, list) and len(aud) == 1:
|
||||
aud = aud[0]
|
||||
if aud != client_id:
|
||||
required = True
|
||||
|
||||
azp = self.get("azp")
|
||||
if required and not azp:
|
||||
raise MissingClaimError("azp")
|
||||
|
||||
if azp and client_id and azp != client_id:
|
||||
raise InvalidClaimError("azp")
|
||||
|
||||
def validate_at_hash(self):
|
||||
"""OPTIONAL. Access Token hash value. Its value is the base64url
|
||||
encoding of the left-most half of the hash of the octets of the ASCII
|
||||
representation of the access_token value, where the hash algorithm
|
||||
used is the hash algorithm used in the alg Header Parameter of the
|
||||
ID Token's JOSE Header. For instance, if the alg is RS256, hash the
|
||||
access_token value with SHA-256, then take the left-most 128 bits and
|
||||
base64url encode them. The at_hash value is a case sensitive string.
|
||||
"""
|
||||
access_token = self.params.get("access_token")
|
||||
at_hash = self.get("at_hash")
|
||||
if at_hash and access_token:
|
||||
if not _verify_hash(at_hash, access_token, self.header["alg"]):
|
||||
raise InvalidClaimError("at_hash")
|
||||
|
||||
|
||||
class CodeIDToken(IDToken):
|
||||
RESPONSE_TYPES = ("code",)
|
||||
REGISTERED_CLAIMS = _REGISTERED_CLAIMS
|
||||
|
||||
|
||||
class ImplicitIDToken(IDToken):
|
||||
RESPONSE_TYPES = ("id_token", "id_token token")
|
||||
ESSENTIAL_CLAIMS = ["iss", "sub", "aud", "exp", "iat", "nonce"]
|
||||
REGISTERED_CLAIMS = _REGISTERED_CLAIMS
|
||||
|
||||
def validate_at_hash(self):
|
||||
"""If the ID Token is issued from the Authorization Endpoint with an
|
||||
access_token value, which is the case for the response_type value
|
||||
id_token token, this is REQUIRED; it MAY NOT be used when no Access
|
||||
Token is issued, which is the case for the response_type value
|
||||
id_token.
|
||||
"""
|
||||
access_token = self.params.get("access_token")
|
||||
if access_token and "at_hash" not in self:
|
||||
raise MissingClaimError("at_hash")
|
||||
super().validate_at_hash()
|
||||
|
||||
|
||||
class HybridIDToken(ImplicitIDToken):
|
||||
RESPONSE_TYPES = ("code id_token", "code token", "code id_token token")
|
||||
REGISTERED_CLAIMS = _REGISTERED_CLAIMS + ["c_hash"]
|
||||
|
||||
def validate(self, now=None, leeway=0):
|
||||
super().validate(now=now, leeway=leeway)
|
||||
self.validate_c_hash()
|
||||
|
||||
def validate_c_hash(self):
|
||||
"""Code hash value. Its value is the base64url encoding of the
|
||||
left-most half of the hash of the octets of the ASCII representation
|
||||
of the code value, where the hash algorithm used is the hash algorithm
|
||||
used in the alg Header Parameter of the ID Token's JOSE Header. For
|
||||
instance, if the alg is HS512, hash the code value with SHA-512, then
|
||||
take the left-most 256 bits and base64url encode them. The c_hash
|
||||
value is a case sensitive string.
|
||||
If the ID Token is issued from the Authorization Endpoint with a code,
|
||||
which is the case for the response_type values code id_token and code
|
||||
id_token token, this is REQUIRED; otherwise, its inclusion is OPTIONAL.
|
||||
"""
|
||||
code = self.params.get("code")
|
||||
c_hash = self.get("c_hash")
|
||||
if code:
|
||||
if not c_hash:
|
||||
raise MissingClaimError("c_hash")
|
||||
if not _verify_hash(c_hash, code, self.header["alg"]):
|
||||
raise InvalidClaimError("c_hash")
|
||||
|
||||
|
||||
class UserInfo(dict):
|
||||
"""The standard claims of a UserInfo object. Defined per `Section 5.1`_.
|
||||
|
||||
.. _`Section 5.1`: http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
"""
|
||||
|
||||
#: registered claims that UserInfo supports
|
||||
REGISTERED_CLAIMS = [
|
||||
"sub",
|
||||
"name",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"middle_name",
|
||||
"nickname",
|
||||
"preferred_username",
|
||||
"profile",
|
||||
"picture",
|
||||
"website",
|
||||
"email",
|
||||
"email_verified",
|
||||
"gender",
|
||||
"birthdate",
|
||||
"zoneinfo",
|
||||
"locale",
|
||||
"phone_number",
|
||||
"phone_number_verified",
|
||||
"address",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
SCOPES_CLAIMS_MAPPING = {
|
||||
"openid": ["sub"],
|
||||
"profile": [
|
||||
"name",
|
||||
"family_name",
|
||||
"given_name",
|
||||
"middle_name",
|
||||
"nickname",
|
||||
"preferred_username",
|
||||
"profile",
|
||||
"picture",
|
||||
"website",
|
||||
"gender",
|
||||
"birthdate",
|
||||
"zoneinfo",
|
||||
"locale",
|
||||
"updated_at",
|
||||
],
|
||||
"email": ["email", "email_verified"],
|
||||
"address": ["address"],
|
||||
"phone": ["phone_number", "phone_number_verified"],
|
||||
}
|
||||
|
||||
def validate_locale(self):
|
||||
"""Validate the locale claim is a BCP 47 language tag."""
|
||||
locale = self.get("locale")
|
||||
if locale is not None and not is_valid_language_tag(locale):
|
||||
raise ValueError('"locale" MUST be a BCP 47 language tag')
|
||||
|
||||
def filter(self, scope: str):
|
||||
"""Return a new UserInfo object containing only the claims matching the scope passed in parameter."""
|
||||
scope = scope_to_list(scope)
|
||||
filtered_claims = [
|
||||
claim
|
||||
for scope_part in scope
|
||||
for claim in self.SCOPES_CLAIMS_MAPPING.get(scope_part, [])
|
||||
]
|
||||
filtered_items = {
|
||||
key: val for key, val in self.items() if key in filtered_claims
|
||||
}
|
||||
return UserInfo(filtered_items)
|
||||
|
||||
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 get_claim_cls_by_response_type(response_type):
|
||||
claims_classes = (CodeIDToken, ImplicitIDToken, HybridIDToken)
|
||||
for claims_cls in claims_classes:
|
||||
if response_type in claims_cls.RESPONSE_TYPES:
|
||||
return claims_cls
|
||||
|
||||
|
||||
def _verify_hash(signature, s, alg):
|
||||
hash_value = create_half_hash(s, alg)
|
||||
if hash_value is None:
|
||||
return False
|
||||
return hmac.compare_digest(hash_value, to_bytes(signature))
|
||||
@@ -0,0 +1,87 @@
|
||||
from authlib.oauth2 import OAuth2Error
|
||||
|
||||
|
||||
class InteractionRequiredError(OAuth2Error):
|
||||
"""The Authorization Server requires End-User interaction of some form
|
||||
to proceed. This error MAY be returned when the prompt parameter value
|
||||
in the Authentication Request is none, but the Authentication Request
|
||||
cannot be completed without displaying a user interface for End-User
|
||||
interaction.
|
||||
|
||||
http://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
||||
"""
|
||||
|
||||
error = "interaction_required"
|
||||
|
||||
|
||||
class LoginRequiredError(OAuth2Error):
|
||||
"""The Authorization Server requires End-User authentication. This error
|
||||
MAY be returned when the prompt parameter value in the Authentication
|
||||
Request is none, but the Authentication Request cannot be completed
|
||||
without displaying a user interface for End-User authentication.
|
||||
|
||||
http://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
||||
"""
|
||||
|
||||
error = "login_required"
|
||||
|
||||
|
||||
class AccountSelectionRequiredError(OAuth2Error):
|
||||
"""The End-User is REQUIRED to select a session at the Authorization
|
||||
Server. The End-User MAY be authenticated at the Authorization Server
|
||||
with different associated accounts, but the End-User did not select a
|
||||
session. This error MAY be returned when the prompt parameter value in
|
||||
the Authentication Request is none, but the Authentication Request cannot
|
||||
be completed without displaying a user interface to prompt for a session
|
||||
to use.
|
||||
|
||||
http://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
||||
"""
|
||||
|
||||
error = "account_selection_required"
|
||||
|
||||
|
||||
class ConsentRequiredError(OAuth2Error):
|
||||
"""The Authorization Server requires End-User consent. This error MAY be
|
||||
returned when the prompt parameter value in the Authentication Request is
|
||||
none, but the Authentication Request cannot be completed without
|
||||
displaying a user interface for End-User consent.
|
||||
|
||||
http://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
||||
"""
|
||||
|
||||
error = "consent_required"
|
||||
|
||||
|
||||
class InvalidRequestURIError(OAuth2Error):
|
||||
"""The request_uri in the Authorization Request returns an error or
|
||||
contains invalid data.
|
||||
|
||||
http://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
||||
"""
|
||||
|
||||
error = "invalid_request_uri"
|
||||
|
||||
|
||||
class InvalidRequestObjectError(OAuth2Error):
|
||||
"""The request parameter contains an invalid Request Object."""
|
||||
|
||||
error = "invalid_request_object"
|
||||
|
||||
|
||||
class RequestNotSupportedError(OAuth2Error):
|
||||
"""The OP does not support use of the request parameter."""
|
||||
|
||||
error = "request_not_supported"
|
||||
|
||||
|
||||
class RequestURINotSupportedError(OAuth2Error):
|
||||
"""The OP does not support use of the request_uri parameter."""
|
||||
|
||||
error = "request_uri_not_supported"
|
||||
|
||||
|
||||
class RegistrationNotSupportedError(OAuth2Error):
|
||||
"""The OP does not support use of the registration parameter."""
|
||||
|
||||
error = "registration_not_supported"
|
||||
@@ -0,0 +1,11 @@
|
||||
from .code import OpenIDCode
|
||||
from .code import OpenIDToken
|
||||
from .hybrid import OpenIDHybridGrant
|
||||
from .implicit import OpenIDImplicitGrant
|
||||
|
||||
__all__ = [
|
||||
"OpenIDToken",
|
||||
"OpenIDCode",
|
||||
"OpenIDImplicitGrant",
|
||||
"OpenIDHybridGrant",
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
import time
|
||||
import warnings
|
||||
|
||||
from authlib.oauth2 import OAuth2Request
|
||||
|
||||
|
||||
class LegacyMixin:
|
||||
DEFAULT_EXPIRES_IN = 3600
|
||||
|
||||
def resolve_client_private_key(self, client):
|
||||
"""Resolve the client private key for encoding ``id_token`` Developers
|
||||
MUST implement this method in subclass, e.g.::
|
||||
|
||||
import json
|
||||
from joserfc.jwk import KeySet
|
||||
|
||||
|
||||
def resolve_client_private_key(self, client):
|
||||
with open(jwks_file_path) as f:
|
||||
data = json.load(f)
|
||||
return KeySet.import_key_set(data)
|
||||
"""
|
||||
config = self._compatible_resolve_jwt_config(None, client)
|
||||
return config["key"]
|
||||
|
||||
def get_client_algorithm(self, client):
|
||||
"""Return the algorithm for encoding ``id_token``.
|
||||
|
||||
Per `OpenID Connect Registration 1.0 Section 2
|
||||
<https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata>`_,
|
||||
the client metadata ``id_token_signed_response_alg`` takes precedence.
|
||||
It falls back to ``get_jwt_config()["alg"]`` (acting as a server-wide
|
||||
default) and finally to ``RS256``.
|
||||
"""
|
||||
if hasattr(client, "id_token_signed_response_alg") and (
|
||||
client_alg := client.id_token_signed_response_alg
|
||||
):
|
||||
return client_alg
|
||||
|
||||
config = self._compatible_resolve_jwt_config(None, client)
|
||||
alg = config.get("alg")
|
||||
if alg:
|
||||
return alg
|
||||
|
||||
return "RS256"
|
||||
|
||||
def get_client_claims(self, client):
|
||||
"""Return the default client claims for encoding the ``id_token``. Developers
|
||||
MUST implement this method in subclass, e.g.::
|
||||
|
||||
def get_client_claims(self, client):
|
||||
return {
|
||||
"iss": "your-service-url",
|
||||
"aud": [client.get_client_id()],
|
||||
}
|
||||
"""
|
||||
config = self._compatible_resolve_jwt_config(None, client)
|
||||
claims = {k: config[k] for k in config if k not in ["key", "alg"]}
|
||||
if "exp" in config:
|
||||
now = int(time.time())
|
||||
claims["exp"] = now + config["exp"]
|
||||
return claims
|
||||
|
||||
def get_encode_header(self, client):
|
||||
config = self._compatible_resolve_jwt_config(None, client)
|
||||
kid = config.get("kid")
|
||||
header = {"alg": self.get_client_algorithm(client)}
|
||||
if kid:
|
||||
header["kid"] = kid
|
||||
return header
|
||||
|
||||
def get_compatible_claims(self, request: OAuth2Request):
|
||||
now = int(time.time())
|
||||
|
||||
claims = self.get_client_claims(request.client)
|
||||
claims.setdefault("iat", now)
|
||||
claims.setdefault("exp", now + self.DEFAULT_EXPIRES_IN)
|
||||
claims.setdefault("auth_time", now)
|
||||
|
||||
# compatible code
|
||||
if "aud" not in claims and hasattr(self, "get_audiences"):
|
||||
warnings.warn(
|
||||
"get_audiences(self, request) is deprecated and will be removed in version 1.8. "
|
||||
"You can set the ``aud`` value in get_client_claims instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
claims["aud"] = self.get_audiences(request)
|
||||
|
||||
claims.setdefault("aud", [request.client.get_client_id()])
|
||||
return claims
|
||||
|
||||
def _compatible_resolve_jwt_config(self, grant, client):
|
||||
if not hasattr(self, "get_jwt_config"):
|
||||
return {}
|
||||
|
||||
warnings.warn(
|
||||
"get_jwt_config(self, grant) is deprecated and will be removed in version 1.8. "
|
||||
"Use resolve_client_private_key, get_client_claims, get_client_algorithm instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
try:
|
||||
config = self.get_jwt_config(grant, client)
|
||||
except TypeError:
|
||||
config = self.get_jwt_config(grant)
|
||||
return config
|
||||
@@ -0,0 +1,159 @@
|
||||
"""authlib.oidc.core.grants.code.
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Implementation of Authentication using the Authorization Code Flow
|
||||
per `Section 3.1`_.
|
||||
|
||||
.. _`Section 3.1`: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from joserfc import jwt
|
||||
|
||||
from authlib._joserfc_helpers import import_any_key
|
||||
from authlib.oauth2.rfc6749 import OAuth2Request
|
||||
|
||||
from ..models import AuthorizationCodeMixin
|
||||
from ._legacy import LegacyMixin
|
||||
from .util import create_half_hash
|
||||
from .util import is_openid_scope
|
||||
from .util import validate_nonce
|
||||
from .util import validate_request_prompt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenIDToken(LegacyMixin):
|
||||
def get_authorization_code_claims(self, authorization_code: AuthorizationCodeMixin):
|
||||
claims = {}
|
||||
if nonce := authorization_code.get_nonce():
|
||||
claims["nonce"] = nonce
|
||||
|
||||
if auth_time := authorization_code.get_auth_time():
|
||||
claims["auth_time"] = auth_time
|
||||
|
||||
if acr := authorization_code.get_acr():
|
||||
claims["acr"] = acr
|
||||
|
||||
if amr := authorization_code.get_amr():
|
||||
claims["amr"] = amr
|
||||
return claims
|
||||
|
||||
def generate_user_info(self, user, scope):
|
||||
"""Provide user information for the given scope. Developers
|
||||
MUST implement this method in subclass, e.g.::
|
||||
|
||||
from authlib.oidc.core import UserInfo
|
||||
|
||||
|
||||
def generate_user_info(self, user, scope):
|
||||
user_info = UserInfo(sub=user.id, name=user.name)
|
||||
if "email" in scope:
|
||||
user_info["email"] = user.email
|
||||
return user_info
|
||||
|
||||
:param user: user instance
|
||||
:param scope: scope of the token
|
||||
:return: ``authlib.oidc.core.UserInfo`` instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def encode_id_token(self, token, request: OAuth2Request):
|
||||
alg = self.get_client_algorithm(request.client)
|
||||
header = self.get_encode_header(request.client)
|
||||
|
||||
claims = self.get_compatible_claims(request)
|
||||
if request.authorization_code:
|
||||
claims.update(
|
||||
self.get_authorization_code_claims(request.authorization_code)
|
||||
)
|
||||
|
||||
access_token = token.get("access_token")
|
||||
if access_token:
|
||||
at_hash = create_half_hash(access_token, alg)
|
||||
if at_hash is not None:
|
||||
claims["at_hash"] = at_hash.decode("utf-8")
|
||||
|
||||
user_info = self.generate_user_info(request.user, token["scope"])
|
||||
claims.update(user_info)
|
||||
|
||||
if alg == "none":
|
||||
private_key = None
|
||||
else:
|
||||
key = self.resolve_client_private_key(request.client)
|
||||
private_key = import_any_key(key)
|
||||
|
||||
return jwt.encode(header, claims, private_key, [alg])
|
||||
|
||||
def process_token(self, grant, response):
|
||||
_, token, _ = response
|
||||
scope = token.get("scope")
|
||||
if not scope or not is_openid_scope(scope):
|
||||
# standard authorization code flow
|
||||
return token
|
||||
|
||||
request: OAuth2Request = grant.request
|
||||
id_token = self.encode_id_token(token, request)
|
||||
token["id_token"] = id_token
|
||||
return token
|
||||
|
||||
def __call__(self, grant):
|
||||
grant.register_hook("after_create_token_response", self.process_token)
|
||||
|
||||
|
||||
class OpenIDCode(OpenIDToken):
|
||||
"""An extension from OpenID Connect for "grant_type=code" request. Developers
|
||||
MUST implement the missing methods::
|
||||
|
||||
class MyOpenIDCode(OpenIDCode):
|
||||
def resolve_client_private_key(self, client):
|
||||
with open(jwks_file_path) as f:
|
||||
data = json.load(f)
|
||||
return KeySet.import_key_set(data)
|
||||
|
||||
def exists_nonce(self, nonce, request):
|
||||
return check_if_nonce_in_cache(request.payload.client_id, nonce)
|
||||
|
||||
def generate_user_info(self, user, scope):
|
||||
return {...}
|
||||
|
||||
The register this extension with AuthorizationCodeGrant::
|
||||
|
||||
authorization_server.register_grant(
|
||||
AuthorizationCodeGrant, extensions=[MyOpenIDCode()]
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, require_nonce=False):
|
||||
self.require_nonce = require_nonce
|
||||
|
||||
def exists_nonce(self, nonce, request):
|
||||
"""Check if the given nonce is existing in your database. Developers
|
||||
MUST implement this method in subclass, e.g.::
|
||||
|
||||
def exists_nonce(self, nonce, request):
|
||||
exists = AuthorizationCode.query.filter_by(
|
||||
client_id=request.payload.client_id, nonce=nonce
|
||||
).first()
|
||||
return bool(exists)
|
||||
|
||||
:param nonce: A string of "nonce" parameter in request
|
||||
:param request: OAuth2Request instance
|
||||
:return: Boolean
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def validate_openid_authorization_request(self, grant, redirect_uri):
|
||||
validate_nonce(grant.request, self.exists_nonce, self.require_nonce)
|
||||
|
||||
def __call__(self, grant):
|
||||
grant.register_hook("after_create_token_response", self.process_token)
|
||||
if is_openid_scope(grant.request.payload.scope):
|
||||
grant.register_hook(
|
||||
"after_validate_authorization_request_payload",
|
||||
self.validate_openid_authorization_request,
|
||||
)
|
||||
grant.register_hook(
|
||||
"after_validate_consent_request", validate_request_prompt
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
import logging
|
||||
|
||||
from authlib.common.security import generate_token
|
||||
from authlib.oauth2.rfc6749 import InvalidScopeError
|
||||
from authlib.oauth2.rfc6749.grants.authorization_code import (
|
||||
validate_code_authorization_request,
|
||||
)
|
||||
|
||||
from .implicit import OpenIDImplicitGrant
|
||||
from .util import is_openid_scope
|
||||
from .util import validate_nonce
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenIDHybridGrant(OpenIDImplicitGrant):
|
||||
#: Generated "code" length
|
||||
AUTHORIZATION_CODE_LENGTH = 48
|
||||
|
||||
RESPONSE_TYPES = {"code id_token", "code token", "code id_token token"}
|
||||
GRANT_TYPE = "code"
|
||||
DEFAULT_RESPONSE_MODE = "fragment"
|
||||
|
||||
def generate_authorization_code(self):
|
||||
""" "The method to generate "code" value for authorization code data.
|
||||
Developers may rewrite this method, or customize the code length with::
|
||||
|
||||
class MyAuthorizationCodeGrant(AuthorizationCodeGrant):
|
||||
AUTHORIZATION_CODE_LENGTH = 32 # default is 48
|
||||
"""
|
||||
return generate_token(self.AUTHORIZATION_CODE_LENGTH)
|
||||
|
||||
def save_authorization_code(self, code, request):
|
||||
"""Save authorization_code for later use. Developers MUST implement
|
||||
it in subclass. Here is an example::
|
||||
|
||||
def save_authorization_code(self, code, request):
|
||||
client = request.client
|
||||
auth_code = AuthorizationCode(
|
||||
code=code,
|
||||
client_id=client.client_id,
|
||||
redirect_uri=request.payload.redirect_uri,
|
||||
scope=request.payload.scope,
|
||||
nonce=request.payload.data.get("nonce"),
|
||||
user_id=request.user.id,
|
||||
)
|
||||
auth_code.save()
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def validate_authorization_request(self):
|
||||
self.register_hook(
|
||||
"after_validate_authorization_request_payload",
|
||||
lambda grant, redirect_uri: validate_nonce(
|
||||
grant.request, grant.exists_nonce, required=True
|
||||
),
|
||||
)
|
||||
redirect_uri = validate_code_authorization_request(self)
|
||||
if not is_openid_scope(self.request.payload.scope):
|
||||
raise InvalidScopeError(
|
||||
"Missing 'openid' scope",
|
||||
redirect_uri=redirect_uri,
|
||||
redirect_fragment=True,
|
||||
)
|
||||
return redirect_uri
|
||||
|
||||
def create_granted_params(self, grant_user):
|
||||
self.request.user = grant_user
|
||||
client = self.request.client
|
||||
code = self.generate_authorization_code()
|
||||
self.save_authorization_code(code, self.request)
|
||||
params = [("code", code)]
|
||||
token = self.generate_token(
|
||||
grant_type="implicit",
|
||||
user=grant_user,
|
||||
scope=self.request.payload.scope,
|
||||
include_refresh_token=False,
|
||||
)
|
||||
|
||||
response_types = self.request.payload.response_type.split()
|
||||
if "token" in response_types:
|
||||
log.debug("Grant token %r to %r", token, client)
|
||||
self.server.save_token(token, self.request)
|
||||
if "id_token" in response_types:
|
||||
token = self.process_implicit_token(token, code)
|
||||
else:
|
||||
# response_type is "code id_token"
|
||||
token = {"expires_in": token["expires_in"], "scope": token["scope"]}
|
||||
token = self.process_implicit_token(token, code)
|
||||
|
||||
params.extend([(k, token[k]) for k in token])
|
||||
return params
|
||||
@@ -0,0 +1,186 @@
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
from joserfc import jwt
|
||||
|
||||
from authlib._joserfc_helpers import import_any_key
|
||||
from authlib.oauth2.rfc6749 import AccessDeniedError
|
||||
from authlib.oauth2.rfc6749 import ImplicitGrant
|
||||
from authlib.oauth2.rfc6749 import InvalidScopeError
|
||||
from authlib.oauth2.rfc6749 import OAuth2Error
|
||||
from authlib.oauth2.rfc6749.errors import InvalidRequestError
|
||||
from authlib.oauth2.rfc6749.hooks import hooked
|
||||
|
||||
from ._legacy import LegacyMixin
|
||||
from .util import create_half_hash
|
||||
from .util import create_response_mode_response
|
||||
from .util import is_openid_scope
|
||||
from .util import validate_nonce
|
||||
from .util import validate_request_prompt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenIDImplicitGrant(LegacyMixin, ImplicitGrant):
|
||||
RESPONSE_TYPES = {"id_token token", "id_token"}
|
||||
DEFAULT_RESPONSE_MODE = "fragment"
|
||||
|
||||
def exists_nonce(self, nonce, request):
|
||||
"""Check if the given nonce is existing in your database. Developers
|
||||
should implement this method in subclass, e.g.::
|
||||
|
||||
def exists_nonce(self, nonce, request):
|
||||
exists = AuthorizationCode.query.filter_by(
|
||||
client_id=request.payload.client_id, nonce=nonce
|
||||
).first()
|
||||
return bool(exists)
|
||||
|
||||
:param nonce: A string of "nonce" parameter in request
|
||||
:param request: OAuth2Request instance
|
||||
:return: Boolean
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_user_info(self, user, scope):
|
||||
"""Provide user information for the given scope. Developers
|
||||
MUST implement this method in subclass, e.g.::
|
||||
|
||||
from authlib.oidc.core import UserInfo
|
||||
|
||||
|
||||
def generate_user_info(self, user, scope):
|
||||
user_info = UserInfo(sub=user.id, name=user.name)
|
||||
if "email" in scope:
|
||||
user_info["email"] = user.email
|
||||
return user_info
|
||||
|
||||
:param user: user instance
|
||||
:param scope: scope of the token
|
||||
:return: ``authlib.oidc.core.UserInfo`` instance
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_audiences(self, request):
|
||||
"""Parse `aud` value for id_token, default value is client id. Developers
|
||||
MAY rewrite this method to provide a customized audience value.
|
||||
"""
|
||||
client = request.client
|
||||
return [client.get_client_id()]
|
||||
|
||||
def validate_authorization_request(self):
|
||||
redirect_uri = super().validate_authorization_request()
|
||||
if not is_openid_scope(self.request.payload.scope):
|
||||
raise InvalidScopeError(
|
||||
"Missing 'openid' scope",
|
||||
redirect_uri=redirect_uri,
|
||||
redirect_fragment=True,
|
||||
)
|
||||
try:
|
||||
validate_nonce(self.request, self.exists_nonce, required=True)
|
||||
except OAuth2Error as error:
|
||||
error.redirect_uri = redirect_uri
|
||||
error.redirect_fragment = True
|
||||
raise error
|
||||
return redirect_uri
|
||||
|
||||
@hooked
|
||||
def validate_consent_request(self):
|
||||
redirect_uri = self.validate_authorization_request()
|
||||
validate_request_prompt(self, redirect_uri, redirect_fragment=True)
|
||||
return redirect_uri
|
||||
|
||||
def create_authorization_response(self, redirect_uri, grant_user):
|
||||
state = self.request.payload.state
|
||||
if grant_user:
|
||||
params = self.create_granted_params(grant_user)
|
||||
if state:
|
||||
params.append(("state", state))
|
||||
else:
|
||||
error = AccessDeniedError()
|
||||
params = error.get_body()
|
||||
|
||||
# http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes
|
||||
response_mode = self.request.payload.data.get(
|
||||
"response_mode", self.DEFAULT_RESPONSE_MODE
|
||||
)
|
||||
return create_response_mode_response(
|
||||
redirect_uri=redirect_uri,
|
||||
params=params,
|
||||
response_mode=response_mode,
|
||||
)
|
||||
|
||||
def create_granted_params(self, grant_user):
|
||||
self.request.user = grant_user
|
||||
client = self.request.client
|
||||
token = self.generate_token(
|
||||
user=grant_user,
|
||||
scope=self.request.payload.scope,
|
||||
include_refresh_token=False,
|
||||
)
|
||||
if self.request.payload.response_type == "id_token":
|
||||
token = {
|
||||
"expires_in": token["expires_in"],
|
||||
"scope": token["scope"],
|
||||
}
|
||||
token = self.process_implicit_token(token)
|
||||
else:
|
||||
log.debug("Grant token %r to %r", token, client)
|
||||
self.server.save_token(token, self.request)
|
||||
token = self.process_implicit_token(token)
|
||||
params = [(k, token[k]) for k in token]
|
||||
return params
|
||||
|
||||
def process_implicit_token(self, token, code=None):
|
||||
alg = self.get_client_algorithm(self.request.client)
|
||||
if alg == "none":
|
||||
# According to oidc-registration §2 the 'none' alg is not valid in
|
||||
# implicit flows:
|
||||
# The value none MUST NOT be used as the ID Token alg value unless
|
||||
# the Client uses only Response Types that return no ID Token from
|
||||
# the Authorization Endpoint (such as when only using the
|
||||
# Authorization Code Flow).
|
||||
raise InvalidRequestError(
|
||||
"id_token must be signed in implicit flows",
|
||||
redirect_uri=self.request.payload.redirect_uri,
|
||||
redirect_fragment=True,
|
||||
)
|
||||
|
||||
claims = self.get_compatible_claims(self.request)
|
||||
nonce = self.request.payload.data.get("nonce")
|
||||
if nonce:
|
||||
claims["nonce"] = nonce
|
||||
|
||||
if code is not None:
|
||||
c_hash = create_half_hash(code, alg)
|
||||
if c_hash is not None:
|
||||
claims["c_hash"] = c_hash.decode("utf-8")
|
||||
|
||||
access_token = token.get("access_token")
|
||||
if access_token:
|
||||
at_hash = create_half_hash(access_token, alg)
|
||||
if at_hash is not None:
|
||||
claims["at_hash"] = at_hash.decode("utf-8")
|
||||
|
||||
user_info = self.generate_user_info(self.request.user, token["scope"])
|
||||
claims.update(user_info)
|
||||
key = self.resolve_client_private_key(self.request.client)
|
||||
private_key = import_any_key(key)
|
||||
header = self.get_encode_header(self.request.client)
|
||||
id_token = jwt.encode(header, claims, private_key, [alg])
|
||||
token["id_token"] = id_token
|
||||
return token
|
||||
|
||||
def _compatible_resolve_jwt_config(self, grant, client):
|
||||
if not hasattr(self, "get_jwt_config"):
|
||||
return {}
|
||||
warnings.warn(
|
||||
"get_jwt_config(self, client) is deprecated and will be removed in version 1.8. "
|
||||
"Use resolve_client_private_key, get_client_claims, get_client_algorithm instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
try:
|
||||
config = self.get_jwt_config(client)
|
||||
except TypeError:
|
||||
config = self.get_jwt_config()
|
||||
return config
|
||||
@@ -0,0 +1,167 @@
|
||||
import time
|
||||
|
||||
from joserfc import jwt
|
||||
|
||||
from authlib._joserfc_helpers import import_any_key
|
||||
from authlib.common.encoding import to_native
|
||||
from authlib.common.urls import add_params_to_uri
|
||||
from authlib.common.urls import quote_url
|
||||
from authlib.oauth2.rfc6749 import InvalidRequestError
|
||||
from authlib.oauth2.rfc6749 import scope_to_list
|
||||
|
||||
from ..errors import AccountSelectionRequiredError
|
||||
from ..errors import ConsentRequiredError
|
||||
from ..errors import LoginRequiredError
|
||||
from ..util import create_half_hash
|
||||
|
||||
|
||||
def is_openid_scope(scope):
|
||||
scopes = scope_to_list(scope)
|
||||
return scopes and "openid" in scopes
|
||||
|
||||
|
||||
def validate_request_prompt(grant, redirect_uri, redirect_fragment=False):
|
||||
prompt = grant.request.payload.data.get("prompt")
|
||||
end_user = grant.request.user
|
||||
if not prompt:
|
||||
if not end_user:
|
||||
grant.prompt = "login"
|
||||
return grant
|
||||
|
||||
if prompt == "none" and not end_user:
|
||||
raise LoginRequiredError(
|
||||
redirect_uri=redirect_uri, redirect_fragment=redirect_fragment
|
||||
)
|
||||
|
||||
prompts = prompt.split()
|
||||
if "none" in prompts and len(prompts) > 1:
|
||||
# If this parameter contains none with any other value,
|
||||
# an error is returned
|
||||
raise InvalidRequestError(
|
||||
"Invalid 'prompt' parameter.",
|
||||
redirect_uri=redirect_uri,
|
||||
redirect_fragment=redirect_fragment,
|
||||
)
|
||||
|
||||
prompt = _guess_prompt_value(
|
||||
end_user, prompts, redirect_uri, redirect_fragment=redirect_fragment
|
||||
)
|
||||
if prompt:
|
||||
grant.prompt = prompt
|
||||
return grant
|
||||
|
||||
|
||||
def validate_nonce(request, exists_nonce, required=False):
|
||||
nonce = request.payload.data.get("nonce")
|
||||
if not nonce:
|
||||
if required:
|
||||
raise InvalidRequestError("Missing 'nonce' in request.")
|
||||
return True
|
||||
|
||||
if exists_nonce(nonce, request):
|
||||
raise InvalidRequestError("Replay attack")
|
||||
|
||||
|
||||
def generate_id_token(
|
||||
token,
|
||||
user_info,
|
||||
key,
|
||||
iss,
|
||||
aud,
|
||||
alg="RS256",
|
||||
exp=3600,
|
||||
nonce=None,
|
||||
auth_time=None,
|
||||
acr=None,
|
||||
amr=None,
|
||||
code=None,
|
||||
kid=None,
|
||||
):
|
||||
now = int(time.time())
|
||||
if auth_time is None:
|
||||
auth_time = now
|
||||
|
||||
header = {"alg": alg}
|
||||
if kid:
|
||||
header["kid"] = kid
|
||||
|
||||
payload = {
|
||||
"iss": iss,
|
||||
"aud": aud,
|
||||
"iat": now,
|
||||
"exp": now + exp,
|
||||
"auth_time": auth_time,
|
||||
}
|
||||
if nonce:
|
||||
payload["nonce"] = nonce
|
||||
|
||||
if acr:
|
||||
payload["acr"] = acr
|
||||
|
||||
if amr:
|
||||
payload["amr"] = amr
|
||||
|
||||
if code:
|
||||
c_hash = create_half_hash(code, alg)
|
||||
if c_hash is not None:
|
||||
payload["c_hash"] = to_native(c_hash)
|
||||
|
||||
access_token = token.get("access_token")
|
||||
if access_token:
|
||||
at_hash = create_half_hash(access_token, alg)
|
||||
if at_hash is not None:
|
||||
payload["at_hash"] = to_native(at_hash)
|
||||
|
||||
payload.update(user_info)
|
||||
if alg == "none":
|
||||
private_key = None
|
||||
else:
|
||||
private_key = import_any_key(key)
|
||||
|
||||
return jwt.encode(header, payload, private_key, [alg])
|
||||
|
||||
|
||||
def create_response_mode_response(redirect_uri, params, response_mode):
|
||||
if response_mode == "form_post":
|
||||
tpl = (
|
||||
"<html><head><title>Redirecting</title></head>"
|
||||
'<body onload="javascript:document.forms[0].submit()">'
|
||||
'<form method="post" action="{}">{}</form></body></html>'
|
||||
)
|
||||
inputs = "".join(
|
||||
[
|
||||
f'<input type="hidden" name="{quote_url(k)}" value="{quote_url(v)}"/>'
|
||||
for k, v in params
|
||||
]
|
||||
)
|
||||
body = tpl.format(quote_url(redirect_uri), inputs)
|
||||
return 200, body, [("Content-Type", "text/html; charset=utf-8")]
|
||||
|
||||
if response_mode == "query":
|
||||
uri = add_params_to_uri(redirect_uri, params, fragment=False)
|
||||
elif response_mode == "fragment":
|
||||
uri = add_params_to_uri(redirect_uri, params, fragment=True)
|
||||
else:
|
||||
raise InvalidRequestError('Invalid "response_mode" value')
|
||||
|
||||
return 302, "", [("Location", uri)]
|
||||
|
||||
|
||||
def _guess_prompt_value(end_user, prompts, redirect_uri, redirect_fragment):
|
||||
# http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
|
||||
|
||||
if not end_user or "login" in prompts:
|
||||
return "login"
|
||||
|
||||
if "consent" in prompts:
|
||||
if not end_user:
|
||||
raise ConsentRequiredError(
|
||||
redirect_uri=redirect_uri, redirect_fragment=redirect_fragment
|
||||
)
|
||||
return "consent"
|
||||
elif "select_account" in prompts:
|
||||
if not end_user:
|
||||
raise AccountSelectionRequiredError(
|
||||
redirect_uri=redirect_uri, redirect_fragment=redirect_fragment
|
||||
)
|
||||
return "select_account"
|
||||
@@ -0,0 +1,29 @@
|
||||
from authlib.oauth2.rfc6749 import AuthorizationCodeMixin as _AuthorizationCodeMixin
|
||||
|
||||
|
||||
class AuthorizationCodeMixin(_AuthorizationCodeMixin):
|
||||
def get_nonce(self):
|
||||
"""Get "nonce" value of the authorization code object."""
|
||||
# OPs MUST support the prompt parameter, as defined in Section 3.1.2, including the specified user interface behaviors such as none and login.
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_auth_time(self):
|
||||
"""Get "auth_time" value of the authorization code object."""
|
||||
# OPs MUST support returning the time at which the End-User authenticated via the auth_time Claim, when requested, as defined in Section 2.
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_acr(self) -> str:
|
||||
"""Get the "acr" (Authentication Method Class) value of the authorization code object."""
|
||||
# OPs MUST support requests for specific Authentication Context Class Reference values via the acr_values parameter, as defined in Section 3.1.2. (Note that the minimum level of support required for this parameter is simply to have its use not result in an error.)
|
||||
return None
|
||||
|
||||
def get_amr(self) -> list[str]:
|
||||
"""Get the "amr" (Authentication Method Reference) value of the authorization code object.
|
||||
|
||||
Have a look at :rfc:`RFC8176 <8176>` to see the full list of registered amr.
|
||||
|
||||
def get_amr(self) -> list[str]:
|
||||
return ["pwd", "otp"]
|
||||
|
||||
"""
|
||||
return None
|
||||
@@ -0,0 +1,133 @@
|
||||
from joserfc import jwt
|
||||
from joserfc.jws import JWSRegistry
|
||||
|
||||
from authlib._joserfc_helpers import import_any_key
|
||||
from authlib.consts import default_json_headers
|
||||
from authlib.oauth2.rfc6749.authorization_server import AuthorizationServer
|
||||
from authlib.oauth2.rfc6749.authorization_server import OAuth2Request
|
||||
from authlib.oauth2.rfc6749.resource_protector import ResourceProtector
|
||||
|
||||
from .claims import UserInfo
|
||||
|
||||
|
||||
class UserInfoEndpoint:
|
||||
"""OpenID Connect Core UserInfo Endpoint.
|
||||
|
||||
This endpoint returns information about a given user, as a JSON payload or as a JWT.
|
||||
It must be subclassed and a few methods needs to be manually implemented::
|
||||
|
||||
class UserInfoEndpoint(oidc.core.UserInfoEndpoint):
|
||||
def get_issuer(self):
|
||||
return "https://auth.example"
|
||||
|
||||
def generate_user_info(self, user, scope):
|
||||
return UserInfo(
|
||||
sub=user.id,
|
||||
name=user.name,
|
||||
...
|
||||
).filter(scope)
|
||||
|
||||
def resolve_private_key(self):
|
||||
return server_private_jwk_set()
|
||||
|
||||
It is also needed to pass a :class:`~authlib.oauth2.rfc6749.ResourceProtector` instance
|
||||
with a registered :class:`~authlib.oauth2.rfc6749.TokenValidator` at initialization,
|
||||
so the access to the endpoint can be restricter to valid token bearers::
|
||||
|
||||
resource_protector = ResourceProtector()
|
||||
resource_protector.register_token_validator(BearerTokenValidator())
|
||||
server.register_endpoint(
|
||||
UserInfoEndpoint(resource_protector=resource_protector)
|
||||
)
|
||||
|
||||
And then you can plug the endpoint to your application::
|
||||
|
||||
@app.route("/oauth/userinfo", methods=["GET", "POST"])
|
||||
def userinfo():
|
||||
return server.create_endpoint_response("userinfo")
|
||||
|
||||
"""
|
||||
|
||||
ENDPOINT_NAME = "userinfo"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server: AuthorizationServer | None = None,
|
||||
resource_protector: ResourceProtector | None = None,
|
||||
):
|
||||
self.server = server
|
||||
self.resource_protector = resource_protector
|
||||
|
||||
def create_endpoint_request(self, request: OAuth2Request):
|
||||
return self.server.create_oauth2_request(request)
|
||||
|
||||
def __call__(self, request: OAuth2Request):
|
||||
token = self.resource_protector.acquire_token("openid")
|
||||
client = token.get_client()
|
||||
user = token.get_user()
|
||||
user_info = self.generate_user_info(user, token.scope)
|
||||
|
||||
if alg := client.client_metadata.get("userinfo_signed_response_alg"):
|
||||
# If signed, the UserInfo Response MUST contain the Claims iss
|
||||
# (issuer) and aud (audience) as members. The iss value MUST be
|
||||
# the OP's Issuer Identifier URL. The aud value MUST be or
|
||||
# include the RP's Client ID value.
|
||||
user_info["iss"] = self.get_issuer()
|
||||
user_info["aud"] = client.client_id
|
||||
|
||||
key = import_any_key(self.resolve_private_key())
|
||||
algorithms = self.get_supported_algorithms()
|
||||
data = jwt.encode({"alg": alg}, user_info, key, algorithms)
|
||||
return 200, data, [("Content-Type", "application/jwt")]
|
||||
|
||||
return 200, user_info, default_json_headers
|
||||
|
||||
def get_supported_algorithms(self) -> list[str]:
|
||||
"""Return the supported algorithms for userinfo signing.
|
||||
By default, it uses the recommended algorithms from ``joserfc``.
|
||||
Developer can override this method to customize the supported algorithms::
|
||||
|
||||
def get_supported_algorithms(self) -> list[str]:
|
||||
return ["RS256"]
|
||||
"""
|
||||
return JWSRegistry.recommended
|
||||
|
||||
def generate_user_info(self, user, scope: str) -> UserInfo:
|
||||
"""
|
||||
Generate a :class:`~authlib.oidc.core.UserInfo` object for an user::
|
||||
|
||||
def generate_user_info(self, user, scope: str) -> UserInfo:
|
||||
return UserInfo(
|
||||
given_name=user.given_name,
|
||||
family_name=user.last_name,
|
||||
email=user.email,
|
||||
...
|
||||
).filter(scope)
|
||||
|
||||
This method must be implemented by developers.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_issuer(self) -> str:
|
||||
"""The OP's Issuer Identifier URL.
|
||||
|
||||
The value is used to fill the ``iss`` claim that is mandatory in signed userinfo::
|
||||
|
||||
def get_issuer(self) -> str:
|
||||
return "https://auth.example"
|
||||
|
||||
This method must be implemented by developers to support JWT userinfo.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def resolve_private_key(self):
|
||||
"""Return the server JSON Web Key Set.
|
||||
|
||||
This is used to sign userinfo payloads::
|
||||
|
||||
def resolve_private_key(self):
|
||||
return server_private_jwk_set()
|
||||
|
||||
This method must be implemented by developers to support JWT userinfo signing.
|
||||
"""
|
||||
return None # pragma: no cover
|
||||
@@ -0,0 +1,18 @@
|
||||
import hashlib
|
||||
|
||||
from authlib.common.encoding import to_bytes
|
||||
from authlib.common.encoding import urlsafe_b64encode
|
||||
|
||||
|
||||
def create_half_hash(s, alg):
|
||||
if alg == "EdDSA":
|
||||
hash_alg = hashlib.sha512
|
||||
else:
|
||||
hash_type = f"sha{alg[2:]}"
|
||||
hash_alg = getattr(hashlib, hash_type, None)
|
||||
if not hash_alg:
|
||||
return None
|
||||
|
||||
data_digest = hash_alg(to_bytes(s)).digest()
|
||||
slice_index = int(len(data_digest) / 2)
|
||||
return urlsafe_b64encode(data_digest[:slice_index])
|
||||
Reference in New Issue
Block a user