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])
|
||||
@@ -0,0 +1,12 @@
|
||||
"""authlib.oidc.discover.
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
OpenID Connect Discovery 1.0 Implementation.
|
||||
|
||||
https://openid.net/specs/openid-connect-discovery-1_0.html
|
||||
"""
|
||||
|
||||
from .models import OpenIDProviderMetadata
|
||||
from .well_known import get_well_known_url
|
||||
|
||||
__all__ = ["OpenIDProviderMetadata", "get_well_known_url"]
|
||||
@@ -0,0 +1,294 @@
|
||||
from authlib.oauth2.rfc8414 import AuthorizationServerMetadata
|
||||
from authlib.oauth2.rfc8414.models import validate_array_value
|
||||
from authlib.oauth2.rfc8414.models import validate_boolean_value
|
||||
from authlib.oauth2.rfc8414.models import validate_language_tags_array
|
||||
|
||||
|
||||
class OpenIDProviderMetadata(AuthorizationServerMetadata):
|
||||
"""OpenID Provider Metadata for OpenID Connect Discovery.
|
||||
|
||||
The :meth:`validate` method can compose extension classes via the
|
||||
``metadata_classes`` parameter. For example, to validate RP-Initiated
|
||||
Logout metadata::
|
||||
|
||||
from authlib.oidc import discovery, rpinitiated
|
||||
|
||||
metadata = discovery.OpenIDProviderMetadata(data)
|
||||
metadata.validate(metadata_classes=[rpinitiated.OpenIDProviderMetadata])
|
||||
"""
|
||||
|
||||
REGISTRY_KEYS = [
|
||||
"issuer",
|
||||
"authorization_endpoint",
|
||||
"token_endpoint",
|
||||
"jwks_uri",
|
||||
"registration_endpoint",
|
||||
"scopes_supported",
|
||||
"response_types_supported",
|
||||
"response_modes_supported",
|
||||
"grant_types_supported",
|
||||
"token_endpoint_auth_methods_supported",
|
||||
"service_documentation",
|
||||
"ui_locales_supported",
|
||||
"op_policy_uri",
|
||||
"op_tos_uri",
|
||||
# added by OpenID
|
||||
"token_endpoint_auth_signing_alg_values_supported",
|
||||
"acr_values_supported",
|
||||
"subject_types_supported",
|
||||
"id_token_signing_alg_values_supported",
|
||||
"id_token_encryption_alg_values_supported",
|
||||
"id_token_encryption_enc_values_supported",
|
||||
"userinfo_signing_alg_values_supported",
|
||||
"userinfo_encryption_alg_values_supported",
|
||||
"userinfo_encryption_enc_values_supported",
|
||||
"request_object_signing_alg_values_supported",
|
||||
"request_object_encryption_alg_values_supported",
|
||||
"request_object_encryption_enc_values_supported",
|
||||
"display_values_supported",
|
||||
"claim_types_supported",
|
||||
"claims_supported",
|
||||
"claims_locales_supported",
|
||||
"claims_parameter_supported",
|
||||
"request_parameter_supported",
|
||||
"request_uri_parameter_supported",
|
||||
"require_request_uri_registration",
|
||||
# not defined by OpenID
|
||||
# 'revocation_endpoint',
|
||||
# 'revocation_endpoint_auth_methods_supported',
|
||||
# 'revocation_endpoint_auth_signing_alg_values_supported',
|
||||
# 'introspection_endpoint',
|
||||
# 'introspection_endpoint_auth_methods_supported',
|
||||
# 'introspection_endpoint_auth_signing_alg_values_supported',
|
||||
# 'code_challenge_methods_supported',
|
||||
]
|
||||
|
||||
def validate_jwks_uri(self):
|
||||
# REQUIRED in OpenID Connect
|
||||
jwks_uri = self.get("jwks_uri")
|
||||
if jwks_uri is None:
|
||||
raise ValueError('"jwks_uri" is required')
|
||||
return super().validate_jwks_uri()
|
||||
|
||||
def validate_acr_values_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the Authentication
|
||||
Context Class References that this OP supports.
|
||||
"""
|
||||
validate_array_value(self, "acr_values_supported")
|
||||
|
||||
def validate_subject_types_supported(self):
|
||||
"""REQUIRED. JSON array containing a list of the Subject Identifier
|
||||
types that this OP supports. Valid types include pairwise and public.
|
||||
"""
|
||||
# 1. REQUIRED
|
||||
values = self.get("subject_types_supported")
|
||||
if values is None:
|
||||
raise ValueError('"subject_types_supported" is required')
|
||||
|
||||
# 2. JSON array
|
||||
if not isinstance(values, list):
|
||||
raise ValueError('"subject_types_supported" MUST be JSON array')
|
||||
|
||||
# 3. Valid types include pairwise and public
|
||||
valid_types = {"pairwise", "public"}
|
||||
if not valid_types.issuperset(set(values)):
|
||||
raise ValueError('"subject_types_supported" contains invalid values')
|
||||
|
||||
def validate_id_token_signing_alg_values_supported(self):
|
||||
"""REQUIRED. JSON array containing a list of the JWS signing
|
||||
algorithms (alg values) supported by the OP for the ID Token to
|
||||
encode the Claims in a JWT [JWT]. The algorithm RS256 MUST be
|
||||
included. The value none MAY be supported, but MUST NOT be used
|
||||
unless the Response Type used returns no ID Token from the
|
||||
Authorization Endpoint (such as when using the Authorization
|
||||
Code Flow).
|
||||
"""
|
||||
# 1. REQUIRED
|
||||
values = self.get("id_token_signing_alg_values_supported")
|
||||
if values is None:
|
||||
raise ValueError('"id_token_signing_alg_values_supported" is required')
|
||||
|
||||
# 2. JSON array
|
||||
if not isinstance(values, list):
|
||||
raise ValueError(
|
||||
'"id_token_signing_alg_values_supported" MUST be JSON array'
|
||||
)
|
||||
|
||||
# 3. The algorithm RS256 MUST be included
|
||||
if "RS256" not in values:
|
||||
raise ValueError(
|
||||
'"RS256" MUST be included in "id_token_signing_alg_values_supported"'
|
||||
)
|
||||
|
||||
def validate_id_token_encryption_alg_values_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the JWE encryption
|
||||
algorithms (alg values) supported by the OP for the ID Token to
|
||||
encode the Claims in a JWT.
|
||||
"""
|
||||
validate_array_value(self, "id_token_encryption_alg_values_supported")
|
||||
|
||||
def validate_id_token_encryption_enc_values_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the JWE encryption
|
||||
algorithms (enc values) supported by the OP for the ID Token to
|
||||
encode the Claims in a JWT.
|
||||
"""
|
||||
validate_array_value(self, "id_token_encryption_enc_values_supported")
|
||||
|
||||
def validate_userinfo_signing_alg_values_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the JWS signing
|
||||
algorithms (alg values) [JWA] supported by the UserInfo Endpoint
|
||||
to encode the Claims in a JWT. The value none MAY be included.
|
||||
"""
|
||||
validate_array_value(self, "userinfo_signing_alg_values_supported")
|
||||
|
||||
def validate_userinfo_encryption_alg_values_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the JWE encryption
|
||||
algorithms (alg values) [JWA] supported by the UserInfo Endpoint
|
||||
to encode the Claims in a JWT.
|
||||
"""
|
||||
validate_array_value(self, "userinfo_encryption_alg_values_supported")
|
||||
|
||||
def validate_userinfo_encryption_enc_values_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the JWE encryption
|
||||
algorithms (enc values) [JWA] supported by the UserInfo Endpoint
|
||||
to encode the Claims in a JWT.
|
||||
"""
|
||||
validate_array_value(self, "userinfo_encryption_enc_values_supported")
|
||||
|
||||
def validate_request_object_signing_alg_values_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the JWS signing
|
||||
algorithms (alg values) supported by the OP for Request Objects,
|
||||
which are described in Section 6.1 of OpenID Connect Core 1.0.
|
||||
These algorithms are used both when the Request Object is passed
|
||||
by value (using the request parameter) and when it is passed by
|
||||
reference (using the request_uri parameter). Servers SHOULD support
|
||||
none and RS256.
|
||||
"""
|
||||
values = self.get("request_object_signing_alg_values_supported")
|
||||
if not values:
|
||||
return
|
||||
|
||||
if not isinstance(values, list):
|
||||
raise ValueError(
|
||||
'"request_object_signing_alg_values_supported" MUST be JSON array'
|
||||
)
|
||||
|
||||
def validate_request_object_encryption_alg_values_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the JWE encryption
|
||||
algorithms (alg values) supported by the OP for Request Objects.
|
||||
These algorithms are used both when the Request Object is passed
|
||||
by value and when it is passed by reference.
|
||||
"""
|
||||
validate_array_value(self, "request_object_encryption_alg_values_supported")
|
||||
|
||||
def validate_request_object_encryption_enc_values_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the JWE encryption
|
||||
algorithms (enc values) supported by the OP for Request Objects.
|
||||
These algorithms are used both when the Request Object is passed
|
||||
by value and when it is passed by reference.
|
||||
"""
|
||||
validate_array_value(self, "request_object_encryption_enc_values_supported")
|
||||
|
||||
def validate_display_values_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the display parameter
|
||||
values that the OpenID Provider supports. These values are described
|
||||
in Section 3.1.2.1 of OpenID Connect Core 1.0.
|
||||
"""
|
||||
values = self.get("display_values_supported")
|
||||
if not values:
|
||||
return
|
||||
|
||||
if not isinstance(values, list):
|
||||
raise ValueError('"display_values_supported" MUST be JSON array')
|
||||
|
||||
valid_values = {"page", "popup", "touch", "wap"}
|
||||
if not valid_values.issuperset(set(values)):
|
||||
raise ValueError('"display_values_supported" contains invalid values')
|
||||
|
||||
def validate_claim_types_supported(self):
|
||||
"""OPTIONAL. JSON array containing a list of the Claim Types that
|
||||
the OpenID Provider supports. These Claim Types are described in
|
||||
Section 5.6 of OpenID Connect Core 1.0. Values defined by this
|
||||
specification are normal, aggregated, and distributed. If omitted,
|
||||
the implementation supports only normal Claims.
|
||||
"""
|
||||
values = self.get("claim_types_supported")
|
||||
if not values:
|
||||
return
|
||||
|
||||
if not isinstance(values, list):
|
||||
raise ValueError('"claim_types_supported" MUST be JSON array')
|
||||
|
||||
valid_values = {"normal", "aggregated", "distributed"}
|
||||
if not valid_values.issuperset(set(values)):
|
||||
raise ValueError('"claim_types_supported" contains invalid values')
|
||||
|
||||
def validate_claims_supported(self):
|
||||
"""RECOMMENDED. JSON array containing a list of the Claim Names
|
||||
of the Claims that the OpenID Provider MAY be able to supply values
|
||||
for. Note that for privacy or other reasons, this might not be an
|
||||
exhaustive list.
|
||||
"""
|
||||
validate_array_value(self, "claims_supported")
|
||||
|
||||
def validate_claims_locales_supported(self):
|
||||
"""OPTIONAL. Languages and scripts supported for values in Claims
|
||||
being returned, represented as a JSON array of BCP47 [RFC5646]
|
||||
language tag values. Not all languages and scripts are necessarily
|
||||
supported for all Claim values.
|
||||
"""
|
||||
validate_language_tags_array(self, "claims_locales_supported")
|
||||
|
||||
def validate_claims_parameter_supported(self):
|
||||
"""OPTIONAL. Boolean value specifying whether the OP supports use of
|
||||
the claims parameter, with true indicating support. If omitted, the
|
||||
default value is false.
|
||||
"""
|
||||
validate_boolean_value(self, "claims_parameter_supported")
|
||||
|
||||
def validate_request_parameter_supported(self):
|
||||
"""OPTIONAL. Boolean value specifying whether the OP supports use of
|
||||
the request parameter, with true indicating support. If omitted, the
|
||||
default value is false.
|
||||
"""
|
||||
validate_boolean_value(self, "request_parameter_supported")
|
||||
|
||||
def validate_request_uri_parameter_supported(self):
|
||||
"""OPTIONAL. Boolean value specifying whether the OP supports use of
|
||||
the request_uri parameter, with true indicating support. If omitted,
|
||||
the default value is true.
|
||||
"""
|
||||
validate_boolean_value(self, "request_uri_parameter_supported")
|
||||
|
||||
def validate_require_request_uri_registration(self):
|
||||
"""OPTIONAL. Boolean value specifying whether the OP requires any
|
||||
request_uri values used to be pre-registered using the request_uris
|
||||
registration parameter. Pre-registration is REQUIRED when the value
|
||||
is true. If omitted, the default value is false.
|
||||
"""
|
||||
validate_boolean_value(self, "require_request_uri_registration")
|
||||
|
||||
@property
|
||||
def claim_types_supported(self):
|
||||
# If omitted, the implementation supports only normal Claims
|
||||
return self.get("claim_types_supported", ["normal"])
|
||||
|
||||
@property
|
||||
def claims_parameter_supported(self):
|
||||
# If omitted, the default value is false.
|
||||
return self.get("claims_parameter_supported", False)
|
||||
|
||||
@property
|
||||
def request_parameter_supported(self):
|
||||
# If omitted, the default value is false.
|
||||
return self.get("request_parameter_supported", False)
|
||||
|
||||
@property
|
||||
def request_uri_parameter_supported(self):
|
||||
# If omitted, the default value is true.
|
||||
return self.get("request_uri_parameter_supported", True)
|
||||
|
||||
@property
|
||||
def require_request_uri_registration(self):
|
||||
# If omitted, the default value is false.
|
||||
return self.get("require_request_uri_registration", False)
|
||||
@@ -0,0 +1,17 @@
|
||||
from authlib.common.urls import urlparse
|
||||
|
||||
|
||||
def get_well_known_url(issuer, external=False):
|
||||
"""Get well-known URI with issuer via Section 4.1.
|
||||
|
||||
:param issuer: URL of the issuer
|
||||
:param external: return full external url or not
|
||||
:return: URL
|
||||
"""
|
||||
# https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
|
||||
if external:
|
||||
return issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||
|
||||
parsed = urlparse.urlparse(issuer)
|
||||
path = parsed.path
|
||||
return path.rstrip("/") + "/.well-known/openid-configuration"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .claims import ClientMetadataClaims
|
||||
|
||||
__all__ = ["ClientMetadataClaims"]
|
||||
@@ -0,0 +1,334 @@
|
||||
from joserfc.errors import InvalidClaimError
|
||||
|
||||
from authlib.common.urls import is_valid_url
|
||||
from authlib.oauth2.claims import BaseClaims
|
||||
|
||||
|
||||
class ClientMetadataClaims(BaseClaims):
|
||||
REGISTERED_CLAIMS = [
|
||||
"token_endpoint_auth_signing_alg",
|
||||
"application_type",
|
||||
"sector_identifier_uri",
|
||||
"subject_type",
|
||||
"id_token_signed_response_alg",
|
||||
"id_token_encrypted_response_alg",
|
||||
"id_token_encrypted_response_enc",
|
||||
"userinfo_signed_response_alg",
|
||||
"userinfo_encrypted_response_alg",
|
||||
"userinfo_encrypted_response_enc",
|
||||
"default_max_age",
|
||||
"require_auth_time",
|
||||
"default_acr_values",
|
||||
"initiate_login_uri",
|
||||
"request_object_signing_alg",
|
||||
"request_object_encryption_alg",
|
||||
"request_object_encryption_enc",
|
||||
"request_uris",
|
||||
]
|
||||
|
||||
def validate(self, now=None, leeway=0):
|
||||
super().validate(now, leeway)
|
||||
self.validate_token_endpoint_auth_signing_alg()
|
||||
self.validate_application_type()
|
||||
self.validate_sector_identifier_uri()
|
||||
self.validate_subject_type()
|
||||
self.validate_id_token_signed_response_alg()
|
||||
self.validate_id_token_encrypted_response_alg()
|
||||
self.validate_id_token_encrypted_response_enc()
|
||||
self.validate_userinfo_signed_response_alg()
|
||||
self.validate_userinfo_encrypted_response_alg()
|
||||
self.validate_userinfo_encrypted_response_enc()
|
||||
self.validate_default_max_age()
|
||||
self.validate_require_auth_time()
|
||||
self.validate_default_acr_values()
|
||||
self.validate_initiate_login_uri()
|
||||
self.validate_request_object_signing_alg()
|
||||
self.validate_request_object_encryption_alg()
|
||||
self.validate_request_object_encryption_enc()
|
||||
self.validate_request_uris()
|
||||
|
||||
def _validate_uri(self, key):
|
||||
uri = self.get(key)
|
||||
uris = uri if isinstance(uri, list) else [uri]
|
||||
for uri in uris:
|
||||
if uri and not is_valid_url(uri):
|
||||
raise InvalidClaimError(key)
|
||||
|
||||
@classmethod
|
||||
def get_claims_options(self, metadata):
|
||||
"""Generate claims options validation from Authorization Server metadata."""
|
||||
options = {}
|
||||
|
||||
if acr_values_supported := metadata.get("acr_values_supported"):
|
||||
|
||||
def _validate_default_acr_values(claims, value):
|
||||
return not value or set(value).issubset(set(acr_values_supported))
|
||||
|
||||
options["default_acr_values"] = {"validate": _validate_default_acr_values}
|
||||
|
||||
values_mapping = {
|
||||
"token_endpoint_auth_signing_alg_values_supported": "token_endpoint_auth_signing_alg",
|
||||
"subject_types_supported": "subject_type",
|
||||
"id_token_signing_alg_values_supported": "id_token_signed_response_alg",
|
||||
"id_token_encryption_alg_values_supported": "id_token_encrypted_response_alg",
|
||||
"id_token_encryption_enc_values_supported": "id_token_encrypted_response_enc",
|
||||
"userinfo_signing_alg_values_supported": "userinfo_signed_response_alg",
|
||||
"userinfo_encryption_alg_values_supported": "userinfo_encrypted_response_alg",
|
||||
"userinfo_encryption_enc_values_supported": "userinfo_encrypted_response_enc",
|
||||
"request_object_signing_alg_values_supported": "request_object_signing_alg",
|
||||
"request_object_encryption_alg_values_supported": "request_object_encryption_alg",
|
||||
"request_object_encryption_enc_values_supported": "request_object_encryption_enc",
|
||||
}
|
||||
|
||||
def make_validator(metadata_claim_values):
|
||||
def _validate(claims, value):
|
||||
return not value or value in metadata_claim_values
|
||||
|
||||
return _validate
|
||||
|
||||
for metadata_claim_name, request_claim_name in values_mapping.items():
|
||||
if metadata_claim_values := metadata.get(metadata_claim_name):
|
||||
options[request_claim_name] = {
|
||||
"validate": make_validator(metadata_claim_values)
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
def validate_token_endpoint_auth_signing_alg(self):
|
||||
"""JWS [JWS] alg algorithm [JWA] that MUST be used for signing the JWT [JWT]
|
||||
used to authenticate the Client at the Token Endpoint for the private_key_jwt
|
||||
and client_secret_jwt authentication methods.
|
||||
|
||||
All Token Requests using these authentication methods from this Client MUST be
|
||||
rejected, if the JWT is not signed with this algorithm. Servers SHOULD support
|
||||
RS256. The value none MUST NOT be used. The default, if omitted, is that any
|
||||
algorithm supported by the OP and the RP MAY be used.
|
||||
"""
|
||||
if self.get("token_endpoint_auth_signing_alg") == "none":
|
||||
raise InvalidClaimError("token_endpoint_auth_signing_alg")
|
||||
|
||||
def validate_application_type(self):
|
||||
"""Kind of the application.
|
||||
|
||||
The default, if omitted, is web. The defined values are native or web. Web
|
||||
Clients using the OAuth Implicit Grant Type MUST only register URLs using the
|
||||
https scheme as redirect_uris; they MUST NOT use localhost as the hostname.
|
||||
Native Clients MUST only register redirect_uris using custom URI schemes or
|
||||
loopback URLs using the http scheme; loopback URLs use localhost or the IP
|
||||
loopback literals 127.0.0.1 or [::1] as the hostname. Authorization Servers MAY
|
||||
place additional constraints on Native Clients. Authorization Servers MAY
|
||||
reject Redirection URI values using the http scheme, other than the loopback
|
||||
case for Native Clients. The Authorization Server MUST verify that all the
|
||||
registered redirect_uris conform to these constraints. This prevents sharing a
|
||||
Client ID across different types of Clients.
|
||||
"""
|
||||
self.setdefault("application_type", "web")
|
||||
if self.get("application_type") not in ("web", "native"):
|
||||
raise InvalidClaimError("application_type")
|
||||
|
||||
def validate_sector_identifier_uri(self):
|
||||
"""URL using the https scheme to be used in calculating Pseudonymous Identifiers
|
||||
by the OP.
|
||||
|
||||
The URL references a file with a single JSON array of redirect_uri values.
|
||||
Please see Section 5. Providers that use pairwise sub (subject) values SHOULD
|
||||
utilize the sector_identifier_uri value provided in the Subject Identifier
|
||||
calculation for pairwise identifiers.
|
||||
"""
|
||||
self._validate_uri("sector_identifier_uri")
|
||||
|
||||
def validate_subject_type(self):
|
||||
"""subject_type requested for responses to this Client.
|
||||
|
||||
The subject_types_supported discovery parameter contains a list of the supported
|
||||
subject_type values for the OP. Valid types include pairwise and public.
|
||||
"""
|
||||
|
||||
def validate_id_token_signed_response_alg(self):
|
||||
"""JWS alg algorithm [JWA] REQUIRED for signing the ID Token issued to this
|
||||
Client.
|
||||
|
||||
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). The default, if omitted,
|
||||
is RS256. The public key for validating the signature is provided by retrieving
|
||||
the JWK Set referenced by the jwks_uri element from OpenID Connect Discovery 1.0
|
||||
[OpenID.Discovery].
|
||||
"""
|
||||
if self.get(
|
||||
"id_token_signed_response_alg"
|
||||
) == "none" and "id_token" in self.get("response_type", ""):
|
||||
raise InvalidClaimError("id_token_signed_response_alg")
|
||||
|
||||
self.setdefault("id_token_signed_response_alg", "RS256")
|
||||
|
||||
def validate_id_token_encrypted_response_alg(self):
|
||||
"""JWE alg algorithm [JWA] REQUIRED for encrypting the ID Token issued to this
|
||||
Client.
|
||||
|
||||
If this is requested, the response will be signed then encrypted, with the
|
||||
result being a Nested JWT, as defined in [JWT]. The default, if omitted, is that
|
||||
no encryption is performed.
|
||||
"""
|
||||
|
||||
def validate_id_token_encrypted_response_enc(self):
|
||||
"""JWE enc algorithm [JWA] REQUIRED for encrypting the ID Token issued to this
|
||||
Client.
|
||||
|
||||
If id_token_encrypted_response_alg is specified, the default
|
||||
id_token_encrypted_response_enc value is A128CBC-HS256. When
|
||||
id_token_encrypted_response_enc is included, id_token_encrypted_response_alg
|
||||
MUST also be provided.
|
||||
"""
|
||||
if self.get("id_token_encrypted_response_enc") and not self.get(
|
||||
"id_token_encrypted_response_alg"
|
||||
):
|
||||
raise InvalidClaimError("id_token_encrypted_response_enc")
|
||||
|
||||
if self.get("id_token_encrypted_response_alg"):
|
||||
self.setdefault("id_token_encrypted_response_enc", "A128CBC-HS256")
|
||||
|
||||
def validate_userinfo_signed_response_alg(self):
|
||||
"""JWS alg algorithm [JWA] REQUIRED for signing UserInfo Responses.
|
||||
|
||||
If this is specified, the response will be JWT [JWT] serialized, and signed
|
||||
using JWS. The default, if omitted, is for the UserInfo Response to return the
|
||||
Claims as a UTF-8 [RFC3629] encoded JSON object using the application/json
|
||||
content-type.
|
||||
"""
|
||||
|
||||
def validate_userinfo_encrypted_response_alg(self):
|
||||
"""JWE [JWE] alg algorithm [JWA] REQUIRED for encrypting UserInfo Responses.
|
||||
|
||||
If both signing and encryption are requested, the response will be signed then
|
||||
encrypted, with the result being a Nested JWT, as defined in [JWT]. The default,
|
||||
if omitted, is that no encryption is performed.
|
||||
"""
|
||||
|
||||
def validate_userinfo_encrypted_response_enc(self):
|
||||
"""JWE enc algorithm [JWA] REQUIRED for encrypting UserInfo Responses.
|
||||
|
||||
If userinfo_encrypted_response_alg is specified, the default
|
||||
userinfo_encrypted_response_enc value is A128CBC-HS256. When
|
||||
userinfo_encrypted_response_enc is included, userinfo_encrypted_response_alg
|
||||
MUST also be provided.
|
||||
"""
|
||||
if self.get("userinfo_encrypted_response_enc") and not self.get(
|
||||
"userinfo_encrypted_response_alg"
|
||||
):
|
||||
raise InvalidClaimError("userinfo_encrypted_response_enc")
|
||||
|
||||
if self.get("userinfo_encrypted_response_alg"):
|
||||
self.setdefault("userinfo_encrypted_response_enc", "A128CBC-HS256")
|
||||
|
||||
def validate_default_max_age(self):
|
||||
"""Default Maximum Authentication Age.
|
||||
|
||||
Specifies that the End-User MUST be actively authenticated if the End-User was
|
||||
authenticated longer ago than the specified number of seconds. The max_age
|
||||
request parameter overrides this default value. If omitted, no default Maximum
|
||||
Authentication Age is specified.
|
||||
"""
|
||||
if self.get("default_max_age") is not None and not isinstance(
|
||||
self["default_max_age"], (int, float)
|
||||
):
|
||||
raise InvalidClaimError("default_max_age")
|
||||
|
||||
def validate_require_auth_time(self):
|
||||
"""Boolean value specifying whether the auth_time Claim in the ID Token is
|
||||
REQUIRED.
|
||||
|
||||
It is REQUIRED when the value is true. (If this is false, the auth_time Claim
|
||||
can still be dynamically requested as an individual Claim for the ID Token using
|
||||
the claims request parameter described in Section 5.5.1 of OpenID Connect Core
|
||||
1.0 [OpenID.Core].) If omitted, the default value is false.
|
||||
"""
|
||||
self.setdefault("require_auth_time", False)
|
||||
if self.get("require_auth_time") is not None and not isinstance(
|
||||
self["require_auth_time"], bool
|
||||
):
|
||||
raise InvalidClaimError("require_auth_time")
|
||||
|
||||
def validate_default_acr_values(self):
|
||||
"""Default requested Authentication Context Class Reference values.
|
||||
|
||||
Array of strings that specifies the default acr values that the OP is being
|
||||
requested to use for processing requests from this Client, with the values
|
||||
appearing in order of preference. The Authentication Context Class satisfied by
|
||||
the authentication performed is returned as the acr Claim Value in the issued ID
|
||||
Token. The acr Claim is requested as a Voluntary Claim by this parameter. The
|
||||
acr_values_supported discovery element contains a list of the supported acr
|
||||
values supported by the OP. Values specified in the acr_values request parameter
|
||||
or an individual acr Claim request override these default values.
|
||||
"""
|
||||
|
||||
def validate_initiate_login_uri(self):
|
||||
"""RI using the https scheme that a third party can use to initiate a login by
|
||||
the RP, as specified in Section 4 of OpenID Connect Core 1.0 [OpenID.Core].
|
||||
|
||||
The URI MUST accept requests via both GET and POST. The Client MUST understand
|
||||
the login_hint and iss parameters and SHOULD support the target_link_uri
|
||||
parameter.
|
||||
"""
|
||||
self._validate_uri("initiate_login_uri")
|
||||
|
||||
def validate_request_object_signing_alg(self):
|
||||
"""JWS [JWS] alg algorithm [JWA] that MUST be used for signing Request Objects
|
||||
sent to the OP.
|
||||
|
||||
All Request Objects from this Client MUST be rejected, if not signed with this
|
||||
algorithm. Request Objects are described in Section 6.1 of OpenID Connect Core
|
||||
1.0 [OpenID.Core]. This algorithm MUST be used both when the Request Object is
|
||||
passed by value (using the request parameter) and when it is passed by reference
|
||||
(using the request_uri parameter). Servers SHOULD support RS256. The value none
|
||||
MAY be used. The default, if omitted, is that any algorithm supported by the OP
|
||||
and the RP MAY be used.
|
||||
"""
|
||||
|
||||
def validate_request_object_encryption_alg(self):
|
||||
"""JWE [JWE] alg algorithm [JWA] the RP is declaring that it may use for
|
||||
encrypting Request Objects sent to the OP.
|
||||
|
||||
This parameter SHOULD be included when symmetric encryption will be used, since
|
||||
this signals to the OP that a client_secret value needs to be returned from
|
||||
which the symmetric key will be derived, that might not otherwise be returned.
|
||||
The RP MAY still use other supported encryption algorithms or send unencrypted
|
||||
Request Objects, even when this parameter is present. If both signing and
|
||||
encryption are requested, the Request Object will be signed then encrypted, with
|
||||
the result being a Nested JWT, as defined in [JWT]. The default, if omitted, is
|
||||
that the RP is not declaring whether it might encrypt any Request Objects.
|
||||
"""
|
||||
|
||||
def validate_request_object_encryption_enc(self):
|
||||
"""JWE enc algorithm [JWA] the RP is declaring that it may use for encrypting
|
||||
Request Objects sent to the OP.
|
||||
|
||||
If request_object_encryption_alg is specified, the default
|
||||
request_object_encryption_enc value is A128CBC-HS256. When
|
||||
request_object_encryption_enc is included, request_object_encryption_alg MUST
|
||||
also be provided.
|
||||
"""
|
||||
if self.get("request_object_encryption_enc") and not self.get(
|
||||
"request_object_encryption_alg"
|
||||
):
|
||||
raise InvalidClaimError("request_object_encryption_enc")
|
||||
|
||||
if self.get("request_object_encryption_alg"):
|
||||
self.setdefault("request_object_encryption_enc", "A128CBC-HS256")
|
||||
|
||||
def validate_request_uris(self):
|
||||
"""Array of request_uri values that are pre-registered by the RP for use at the
|
||||
OP.
|
||||
|
||||
These URLs MUST use the https scheme unless the target Request Object is signed
|
||||
in a way that is verifiable by the OP. Servers MAY cache the contents of the
|
||||
files referenced by these URIs and not retrieve them at the time they are used
|
||||
in a request. OPs can require that request_uri values used be pre-registered
|
||||
with the require_request_uri_registration discovery parameter. If the contents
|
||||
of the request file could ever change, these URI values SHOULD include the
|
||||
base64url-encoded SHA-256 hash value of the file contents referenced by the URI
|
||||
as the value of the URI fragment. If the fragment value used for a URI changes,
|
||||
that signals the server that its cached value for that URI with the old fragment
|
||||
value is no longer valid.
|
||||
"""
|
||||
self._validate_uri("request_uris")
|
||||
@@ -0,0 +1,19 @@
|
||||
"""authlib.oidc.rpinitiated.
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
OpenID Connect RP-Initiated Logout 1.0 Implementation.
|
||||
|
||||
https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
||||
"""
|
||||
|
||||
from .discovery import OpenIDProviderMetadata
|
||||
from .end_session import EndSessionEndpoint
|
||||
from .end_session import EndSessionRequest
|
||||
from .registration import ClientMetadataClaims
|
||||
|
||||
__all__ = [
|
||||
"EndSessionEndpoint",
|
||||
"EndSessionRequest",
|
||||
"ClientMetadataClaims",
|
||||
"OpenIDProviderMetadata",
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
from authlib.common.security import is_secure_transport
|
||||
|
||||
|
||||
class OpenIDProviderMetadata(dict):
|
||||
REGISTRY_KEYS = ["end_session_endpoint"]
|
||||
|
||||
def validate_end_session_endpoint(self):
|
||||
# rpinitiated §2.1: "end_session_endpoint - URL at the OP to which an
|
||||
# RP can perform a redirect to request that the End-User be logged out
|
||||
# at the OP. This URL MUST use the https scheme."
|
||||
url = self.get("end_session_endpoint")
|
||||
if url and not is_secure_transport(url):
|
||||
raise ValueError('"end_session_endpoint" MUST use "https" scheme')
|
||||
@@ -0,0 +1,285 @@
|
||||
"""OpenID Connect RP-Initiated Logout 1.0 implementation.
|
||||
|
||||
https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from joserfc import jwt
|
||||
from joserfc.errors import JoseError
|
||||
from joserfc.jwk import KeySet
|
||||
from joserfc.jws import JWSRegistry
|
||||
|
||||
from authlib.common.urls import add_params_to_uri
|
||||
from authlib.oauth2.rfc6749.endpoint import Endpoint
|
||||
from authlib.oauth2.rfc6749.endpoint import EndpointRequest
|
||||
from authlib.oauth2.rfc6749.errors import InvalidRequestError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authlib.oauth2.rfc6749.requests import OAuth2Request
|
||||
|
||||
|
||||
class _NonExpiringClaimsRegistry(jwt.JWTClaimsRegistry):
|
||||
"""Claims registry that skips expiration validation."""
|
||||
|
||||
# rpinitiated §2: "The OP SHOULD accept ID Tokens when the RP identified by the
|
||||
# ID Token's aud claim and/or sid claim has a current session or had a
|
||||
# recent session at the OP, even when the exp time has passed."
|
||||
def validate_exp(self, value: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndSessionRequest(EndpointRequest):
|
||||
"""Validated end session request data.
|
||||
|
||||
This object is returned by :meth:`EndSessionEndpoint.validate_request`
|
||||
and contains all the validated information from the logout request.
|
||||
"""
|
||||
|
||||
id_token_claims: dict | None = field(default=None, repr=False)
|
||||
redirect_uri: str | None = None
|
||||
logout_hint: str | None = None
|
||||
ui_locales: str | None = None
|
||||
|
||||
@property
|
||||
def needs_confirmation(self) -> bool:
|
||||
"""Whether user confirmation is recommended before logout."""
|
||||
|
||||
# rpinitiated §6: "Logout requests without a valid id_token_hint value are a
|
||||
# potential means of denial of service; therefore, OPs should obtain
|
||||
# explicit confirmation from the End-User before acting upon them."
|
||||
return self.id_token_claims is None
|
||||
|
||||
|
||||
class EndSessionEndpoint(Endpoint):
|
||||
"""OpenID Connect RP-Initiated Logout endpoint.
|
||||
|
||||
This endpoint follows a two-phase pattern for interactive flows:
|
||||
|
||||
1. Call ``server.validate_endpoint_request("end_session")`` to validate
|
||||
the request and get an :class:`EndSessionRequest`
|
||||
2. Check ``end_session_request.needs_confirmation`` and show UI if needed
|
||||
3. Call ``server.create_endpoint_response("end_session", end_session_request)``
|
||||
to execute logout and create the response
|
||||
|
||||
Example usage::
|
||||
|
||||
class MyEndSessionEndpoint(EndSessionEndpoint):
|
||||
def get_server_jwks(self):
|
||||
return load_jwks()
|
||||
|
||||
def end_session(self, end_session_request):
|
||||
session.clear()
|
||||
|
||||
|
||||
server.register_endpoint(MyEndSessionEndpoint)
|
||||
|
||||
|
||||
@app.route("/logout", methods=["GET", "POST"])
|
||||
def logout():
|
||||
try:
|
||||
req = server.validate_endpoint_request("end_session")
|
||||
except OAuth2Error as error:
|
||||
return server.handle_error_response(None, error)
|
||||
|
||||
if req.needs_confirmation and request.method == "GET":
|
||||
return render_template("confirm_logout.html", client=req.client)
|
||||
|
||||
return server.create_endpoint_response(
|
||||
"end_session", req
|
||||
) or render_template("logged_out.html")
|
||||
|
||||
For non-interactive usage (no confirmation page), use the standard pattern::
|
||||
|
||||
@app.route("/logout", methods=["GET", "POST"])
|
||||
def logout():
|
||||
return server.create_endpoint_response("end_session") or render_template(
|
||||
"logged_out.html"
|
||||
)
|
||||
"""
|
||||
|
||||
ENDPOINT_NAME = "end_session"
|
||||
|
||||
def validate_request(self, request: OAuth2Request) -> EndSessionRequest:
|
||||
"""Validate an end session request.
|
||||
|
||||
:param request: The OAuth2Request to validate
|
||||
:returns: EndSessionRequest with validated data
|
||||
:raises InvalidRequestError: If validation fails
|
||||
"""
|
||||
data = request.payload.data
|
||||
|
||||
id_token_hint = data.get("id_token_hint")
|
||||
client_id = data.get("client_id")
|
||||
post_logout_redirect_uri = data.get("post_logout_redirect_uri")
|
||||
state = data.get("state")
|
||||
logout_hint = data.get("logout_hint")
|
||||
ui_locales = data.get("ui_locales")
|
||||
|
||||
# rpinitiated §2: "When an id_token_hint parameter is present, the OP MUST
|
||||
# validate that it was the issuer of the ID Token."
|
||||
id_token_claims = None
|
||||
if id_token_hint:
|
||||
id_token_claims = self._validate_id_token_hint(id_token_hint)
|
||||
|
||||
# Resolve client
|
||||
client = None
|
||||
if client_id:
|
||||
client = self.server.query_client(client_id)
|
||||
elif id_token_claims:
|
||||
client = self.resolve_client_from_id_token_claims(id_token_claims)
|
||||
|
||||
# rpinitiated §2: "When both client_id and id_token_hint are present, the OP
|
||||
# MUST verify that the Client Identifier matches the one used as the
|
||||
# audience of the ID Token."
|
||||
if client_id and id_token_claims:
|
||||
aud = id_token_claims.get("aud")
|
||||
aud_list = [aud] if isinstance(aud, str) else (aud or [])
|
||||
if client_id not in aud_list:
|
||||
raise InvalidRequestError("'client_id' does not match 'aud' claim")
|
||||
|
||||
# rpinitiated §3: "The OP MUST NOT perform post-logout redirection if
|
||||
# the post_logout_redirect_uri value supplied does not exactly match
|
||||
# one of the previously registered post_logout_redirect_uris values."
|
||||
redirect_uri = None
|
||||
if (
|
||||
post_logout_redirect_uri
|
||||
and client
|
||||
and self._is_valid_post_logout_redirect_uri(
|
||||
client, post_logout_redirect_uri
|
||||
)
|
||||
and (
|
||||
id_token_claims
|
||||
or self.is_post_logout_redirect_uri_legitimate(
|
||||
request, post_logout_redirect_uri, client, logout_hint
|
||||
)
|
||||
)
|
||||
):
|
||||
redirect_uri = post_logout_redirect_uri
|
||||
# rpinitiated §3: "If the post_logout_redirect_uri value is provided
|
||||
# and the preceding conditions are met, the OP MUST include the
|
||||
# state value if the RP's initial Logout Request included state."
|
||||
if state:
|
||||
redirect_uri = add_params_to_uri(redirect_uri, {"state": state})
|
||||
|
||||
return EndSessionRequest(
|
||||
request=request,
|
||||
client=client,
|
||||
id_token_claims=id_token_claims,
|
||||
redirect_uri=redirect_uri,
|
||||
logout_hint=logout_hint,
|
||||
ui_locales=ui_locales,
|
||||
)
|
||||
|
||||
def create_response(
|
||||
self, validated_request: EndSessionRequest
|
||||
) -> tuple[int, Any, list[tuple[str, str]]] | None:
|
||||
"""Create the end session HTTP response.
|
||||
|
||||
Executes the logout via :meth:`end_session`, then returns a redirect
|
||||
response if a valid redirect_uri is present, or None to let the
|
||||
application provide its own response.
|
||||
|
||||
:param validated_request: The validated EndSessionRequest
|
||||
:returns: Tuple of (status_code, body, headers) for redirect, or None
|
||||
"""
|
||||
req: EndSessionRequest = validated_request # type: ignore[assignment]
|
||||
self.end_session(req)
|
||||
|
||||
if req.redirect_uri:
|
||||
return 302, "", [("Location", req.redirect_uri)]
|
||||
return None
|
||||
|
||||
def _validate_id_token_hint(self, id_token_hint: str) -> dict:
|
||||
"""Validate that the OP was the issuer of the ID Token."""
|
||||
# rpinitiated §2: "When an id_token_hint parameter is present, the OP MUST
|
||||
# validate that it was the issuer of the ID Token."
|
||||
# This is done by verifying the signature against the server's JWKS.
|
||||
jwks = self.get_server_jwks()
|
||||
if isinstance(jwks, dict):
|
||||
jwks = KeySet.import_key_set(jwks)
|
||||
|
||||
# rpinitiated §4: "When the OP detects errors in the RP-Initiated
|
||||
# Logout request, the OP MUST not perform post-logout redirection."
|
||||
try:
|
||||
token = jwt.decode(id_token_hint, jwks, algorithms=self.get_algorithms())
|
||||
claims_registry = _NonExpiringClaimsRegistry(nbf={"essential": False})
|
||||
claims_registry.validate(token.claims)
|
||||
except JoseError as exc:
|
||||
raise InvalidRequestError(exc.description) from exc
|
||||
|
||||
return dict(token.claims)
|
||||
|
||||
def resolve_client_from_id_token_claims(self, id_token_claims: dict) -> Any | None:
|
||||
"""Resolve client from id_token aud claim.
|
||||
|
||||
When aud is a single string, resolves the client directly.
|
||||
When aud is a list, returns None (ambiguous case).
|
||||
Override for custom resolution logic.
|
||||
"""
|
||||
aud = id_token_claims.get("aud")
|
||||
if isinstance(aud, str):
|
||||
return self.server.query_client(aud)
|
||||
return None
|
||||
|
||||
def _is_valid_post_logout_redirect_uri(
|
||||
self, client, post_logout_redirect_uri: str
|
||||
) -> bool:
|
||||
"""Check if post_logout_redirect_uri is registered for the client."""
|
||||
registered_uris = client.client_metadata.get("post_logout_redirect_uris", [])
|
||||
return post_logout_redirect_uri in registered_uris
|
||||
|
||||
def is_post_logout_redirect_uri_legitimate(
|
||||
self,
|
||||
request: OAuth2Request,
|
||||
post_logout_redirect_uri: str,
|
||||
client,
|
||||
logout_hint: str | None,
|
||||
) -> bool:
|
||||
"""Confirm redirect_uri legitimacy when no id_token_hint is provided.
|
||||
|
||||
Override if you have alternative confirmation mechanisms, e.g.::
|
||||
|
||||
def is_post_logout_redirect_uri_legitimate(self, ...):
|
||||
return client and client.is_trusted
|
||||
|
||||
By default returns False (no redirection without id_token_hint).
|
||||
"""
|
||||
# rpinitiated §3: "if it is not supplied with post_logout_redirect_uri,
|
||||
# the OP MUST NOT perform post-logout redirection unless the OP has
|
||||
# other means of confirming the legitimacy"
|
||||
return False
|
||||
|
||||
def get_server_jwks(self) -> dict | KeySet:
|
||||
"""Return the server's JSON Web Key Set for validating ID tokens."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_algorithms(self) -> list[str]:
|
||||
"""Return the list of allowed algorithms for ID token validation.
|
||||
|
||||
By default, returns all algorithms compatible with the keys in the JWKS.
|
||||
Override to restrict to specific algorithms.
|
||||
"""
|
||||
jwks = self.get_server_jwks()
|
||||
if isinstance(jwks, dict):
|
||||
jwks = KeySet.import_key_set(jwks)
|
||||
return [alg.name for alg in JWSRegistry.filter_algorithms(jwks)]
|
||||
|
||||
def end_session(self, end_session_request: EndSessionRequest) -> None:
|
||||
"""Terminate the user's session.
|
||||
|
||||
Implement this method to perform the actual logout logic,
|
||||
such as clearing session data, revoking tokens, etc.
|
||||
|
||||
Use ``end_session_request.logout_hint`` to help identify the user
|
||||
(e.g. email, username) when no ``id_token_hint`` is provided.
|
||||
|
||||
:param end_session_request: The validated EndSessionRequest
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Client metadata for OpenID Connect RP-Initiated Logout 1.0.
|
||||
|
||||
https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
||||
"""
|
||||
|
||||
from joserfc.errors import InvalidClaimError
|
||||
|
||||
from authlib.common.security import is_secure_transport
|
||||
from authlib.common.urls import is_valid_url
|
||||
from authlib.oauth2.claims import BaseClaims
|
||||
|
||||
|
||||
class ClientMetadataClaims(BaseClaims):
|
||||
"""Client metadata for OpenID Connect RP-Initiated Logout 1.0.
|
||||
|
||||
This can be used with :ref:`specs/rfc7591` and :ref:`specs/rfc7592` endpoints::
|
||||
|
||||
server.register_endpoint(
|
||||
ClientRegistrationEndpoint(
|
||||
claims_classes=[
|
||||
rfc7591.ClientMetadataClaims,
|
||||
oidc.registration.ClientMetadataClaims,
|
||||
oidc.rpinitiated.ClientMetadataClaims,
|
||||
]
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
REGISTERED_CLAIMS = [
|
||||
"post_logout_redirect_uris",
|
||||
]
|
||||
|
||||
def validate(self, now=None, leeway=0):
|
||||
super().validate(now, leeway)
|
||||
self._validate_post_logout_redirect_uris()
|
||||
|
||||
def _validate_post_logout_redirect_uris(self):
|
||||
# rpinitiated §3.1: "post_logout_redirect_uris - Array of URLs supplied
|
||||
# by the RP to which it MAY request that the End-User's User Agent be
|
||||
# redirected using the post_logout_redirect_uri parameter after a
|
||||
# logout has been performed. These URLs SHOULD use the https scheme
|
||||
# [...]; however, they MAY use the http scheme, provided that the
|
||||
# Client Type is confidential."
|
||||
uris = self.get("post_logout_redirect_uris")
|
||||
if not uris:
|
||||
return
|
||||
|
||||
is_public = self.get("token_endpoint_auth_method") == "none"
|
||||
|
||||
for uri in uris:
|
||||
if not is_valid_url(uri):
|
||||
raise InvalidClaimError("post_logout_redirect_uris")
|
||||
|
||||
if is_public and not is_secure_transport(uri):
|
||||
raise ValueError(
|
||||
'"post_logout_redirect_uris" MUST use "https" scheme for public clients'
|
||||
)
|
||||
Reference in New Issue
Block a user