venv added, updated

This commit is contained in:
Norbert
2024-09-13 09:46:28 +02:00
parent 577596d9f3
commit 82af8c809a
4812 changed files with 640223 additions and 2 deletions

View File

@@ -0,0 +1,23 @@
"""
authlib.oidc.core
~~~~~~~~~~~~~~~~~
OpenID Connect Core 1.0 Implementation.
http://openid.net/specs/openid-connect-core-1_0.html
"""
from .models import AuthorizationCodeMixin
from .claims import (
IDToken, CodeIDToken, ImplicitIDToken, HybridIDToken,
UserInfo, get_claim_cls_by_response_type,
)
from .grants import OpenIDToken, OpenIDCode, OpenIDHybridGrant, OpenIDImplicitGrant
__all__ = [
'AuthorizationCodeMixin',
'IDToken', 'CodeIDToken', 'ImplicitIDToken', 'HybridIDToken',
'UserInfo', 'get_claim_cls_by_response_type',
'OpenIDToken', 'OpenIDCode', 'OpenIDHybridGrant', 'OpenIDImplicitGrant',
]

View File

@@ -0,0 +1,242 @@
import time
import hmac
from authlib.common.encoding import to_bytes
from authlib.jose import JWTClaims
from authlib.jose.errors import (
MissingClaimError,
InvalidClaimError,
)
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)
self._validate_essential_claims()
if now is None:
now = int(time.time())
self.validate_iss()
self.validate_sub()
self.validate_aud()
self.validate_exp(now, leeway)
self.validate_nbf(now, leeway)
self.validate_iat(now, leeway)
self.validate_auth_time()
self.validate_nonce()
self.validate_acr()
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_acr(self):
"""OPTIONAL. Authentication Context Class Reference. String specifying
an Authentication Context Class Reference value that identifies the
Authentication Context Class that the authentication performed
satisfied. The value "0" indicates the End-User authentication did not
meet the requirements of `ISO/IEC 29115`_ level 1. Authentication
using a long-lived browser cookie, for instance, is one example where
the use of "level 0" is appropriate. Authentications with level 0
SHOULD NOT be used to authorize access to any resource of any monetary
value. An absolute URI or an `RFC 6711`_ registered name SHOULD be
used as the acr value; registered names MUST NOT be used with a
different meaning than that which is registered. Parties using this
claim will need to agree upon the meanings of the values used, which
may be context-specific. The acr value is a case sensitive string.
.. _`ISO/IEC 29115`: https://www.iso.org/standard/45138.html
.. _`RFC 6711`: https://tools.ietf.org/html/rfc6711
"""
return self._validate_claim_value('acr')
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',
]
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 not hash_value:
return True
return hmac.compare_digest(hash_value, to_bytes(signature))

View File

@@ -0,0 +1,78 @@
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'

View File

@@ -0,0 +1,10 @@
from .code import OpenIDToken, OpenIDCode
from .implicit import OpenIDImplicitGrant
from .hybrid import OpenIDHybridGrant
__all__ = [
'OpenIDToken',
'OpenIDCode',
'OpenIDImplicitGrant',
'OpenIDHybridGrant',
]

View File

@@ -0,0 +1,142 @@
"""
authlib.oidc.core.grants.code
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Implementation of Authentication using the Authorization Code Flow
per `Section 3.1`_.
.. _`Section 3.1`: http://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
"""
import logging
from authlib.oauth2.rfc6749 import OAuth2Request
from .util import (
is_openid_scope,
validate_nonce,
validate_request_prompt,
generate_id_token,
)
log = logging.getLogger(__name__)
class OpenIDToken:
def get_jwt_config(self, grant): # pragma: no cover
"""Get the JWT configuration for OpenIDCode extension. The JWT
configuration will be used to generate ``id_token``. Developers
MUST implement this method in subclass, e.g.::
def get_jwt_config(self, grant):
return {
'key': read_private_key_file(key_path),
'alg': 'RS256',
'iss': 'issuer-identity',
'exp': 3600
}
:param grant: AuthorizationCodeGrant instance
:return: dict
"""
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 process_token(self, grant, token):
scope = token.get('scope')
if not scope or not is_openid_scope(scope):
# standard authorization code flow
return token
request: OAuth2Request = grant.request
authorization_code = request.authorization_code
config = self.get_jwt_config(grant)
config['aud'] = self.get_audiences(request)
if authorization_code:
config['nonce'] = authorization_code.get_nonce()
config['auth_time'] = authorization_code.get_auth_time()
user_info = self.generate_user_info(request.user, token['scope'])
id_token = generate_id_token(token, user_info, **config)
token['id_token'] = id_token
return token
def __call__(self, grant):
grant.register_hook('process_token', 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 get_jwt_config(self, grant):
return {...}
def exists_nonce(self, nonce, request):
return check_if_nonce_in_cache(request.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.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):
validate_nonce(grant.request, self.exists_nonce, self.require_nonce)
def __call__(self, grant):
grant.register_hook('process_token', self.process_token)
if is_openid_scope(grant.request.scope):
grant.register_hook(
'after_validate_authorization_request',
self.validate_openid_authorization_request
)
grant.register_hook(
'after_validate_consent_request',
validate_request_prompt
)

View File

@@ -0,0 +1,90 @@
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, 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.redirect_uri,
scope=request.scope,
nonce=request.data.get('nonce'),
user_id=request.user.id,
)
auth_code.save()
"""
raise NotImplementedError()
def validate_authorization_request(self):
if not is_openid_scope(self.request.scope):
raise InvalidScopeError(
'Missing "openid" scope',
redirect_uri=self.request.redirect_uri,
redirect_fragment=True,
)
self.register_hook(
'after_validate_authorization_request',
lambda grant: validate_nonce(
grant.request, grant.exists_nonce, required=True)
)
return validate_code_authorization_request(self)
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.scope,
include_refresh_token=False
)
response_types = self.request.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

View File

@@ -0,0 +1,150 @@
import logging
from authlib.oauth2.rfc6749 import (
OAuth2Error,
InvalidScopeError,
AccessDeniedError,
ImplicitGrant,
)
from .util import (
is_openid_scope,
validate_nonce,
validate_request_prompt,
create_response_mode_response,
generate_id_token,
)
log = logging.getLogger(__name__)
class OpenIDImplicitGrant(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.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 get_jwt_config(self):
"""Get the JWT configuration for OpenIDImplicitGrant. The JWT
configuration will be used to generate ``id_token``. Developers
MUST implement this method in subclass, e.g.::
def get_jwt_config(self):
return {
'key': read_private_key_file(key_path),
'alg': 'RS256',
'iss': 'issuer-identity',
'exp': 3600
}
:return: dict
"""
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):
if not is_openid_scope(self.request.scope):
raise InvalidScopeError(
'Missing "openid" scope',
redirect_uri=self.request.redirect_uri,
redirect_fragment=True,
)
redirect_uri = super().validate_authorization_request()
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
def validate_consent_request(self):
redirect_uri = self.validate_authorization_request()
validate_request_prompt(self, redirect_uri, redirect_fragment=True)
def create_authorization_response(self, redirect_uri, grant_user):
state = self.request.state
if grant_user:
params = self.create_granted_params(grant_user)
if state:
params.append(('state', state))
else:
error = AccessDeniedError(state=state)
params = error.get_body()
# http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes
response_mode = self.request.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.scope,
include_refresh_token=False
)
if self.request.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):
config = self.get_jwt_config()
config['aud'] = self.get_audiences(self.request)
config['nonce'] = self.request.data.get('nonce')
if code is not None:
config['code'] = code
user_info = self.generate_user_info(self.request.user, token['scope'])
id_token = generate_id_token(token, user_info, **config)
token['id_token'] = id_token
return token

View File

@@ -0,0 +1,131 @@
import time
from authlib.oauth2.rfc6749 import InvalidRequestError
from authlib.oauth2.rfc6749 import scope_to_list
from authlib.jose import jwt
from authlib.common.encoding import to_native
from authlib.common.urls import add_params_to_uri, quote_url
from ..util import create_half_hash
from ..errors import (
LoginRequiredError,
AccountSelectionRequiredError,
ConsentRequiredError,
)
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.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.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, code=None):
now = int(time.time())
if auth_time is None:
auth_time = now
payload = {
'iss': iss,
'aud': aud,
'iat': now,
'exp': now + exp,
'auth_time': auth_time,
}
if nonce:
payload['nonce'] = nonce
if code:
payload['c_hash'] = to_native(create_half_hash(code, alg))
access_token = token.get('access_token')
if access_token:
payload['at_hash'] = to_native(create_half_hash(access_token, alg))
payload.update(user_info)
return to_native(jwt.encode({'alg': alg}, payload, key))
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([
'<input type="hidden" name="{}" value="{}"/>'.format(
quote_url(k), 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 and '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'

View File

@@ -0,0 +1,13 @@
from authlib.oauth2.rfc6749 import (
AuthorizationCodeMixin as _AuthorizationCodeMixin
)
class AuthorizationCodeMixin(_AuthorizationCodeMixin):
def get_nonce(self):
"""Get "nonce" value of the authorization code object."""
raise NotImplementedError()
def get_auth_time(self):
"""Get "auth_time" value of the authorization code object."""
raise NotImplementedError()

View File

@@ -0,0 +1,12 @@
import hashlib
from authlib.common.encoding import to_bytes, urlsafe_b64encode
def create_half_hash(s, alg):
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])

View File

@@ -0,0 +1,13 @@
"""
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']

View File

@@ -0,0 +1,283 @@
from authlib.oauth2.rfc8414 import AuthorizationServerMetadata
from authlib.oauth2.rfc8414.models import validate_array_value
class OpenIDProviderMetadata(AuthorizationServerMetadata):
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',
'token_endpoint_auth_signing_alg_values_supported',
'service_documentation', 'ui_locales_supported',
'op_policy_uri', 'op_tos_uri',
# added by OpenID
'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')
# Servers SHOULD support none and RS256
if 'none' not in values or 'RS256' not in values:
raise ValueError(
'"request_object_signing_alg_values_supported" '
'SHOULD support none and RS256')
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_array_value(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)
def _validate_boolean_value(metadata, key):
if key not in metadata:
return
if metadata[key] not in (True, False):
raise ValueError(f'"{key}" MUST be boolean')

View File

@@ -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'