first commit

This commit is contained in:
linkitapps
2026-09-14 23:22:10 +09:00
commit 9f9c8fb69b
4207 changed files with 1333187 additions and 0 deletions
@@ -0,0 +1,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'
)