diff --git a/example/flask_rp/conf.json b/example/flask_rp/conf.json index 2325f03..b7cd241 100644 --- a/example/flask_rp/conf.json +++ b/example/flask_rp/conf.json @@ -108,7 +108,7 @@ "client_secret_post" ] }, - "redirect_uris": "None", + "redirect_uris": [], "services": { "discovery": { "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", diff --git a/example/flask_rp/dpop_conf.json b/example/flask_rp/dpop_conf.json new file mode 100644 index 0000000..f887487 --- /dev/null +++ b/example/flask_rp/dpop_conf.json @@ -0,0 +1,218 @@ +{ + "logging": { + "version": 1, + "disable_existing_loggers": false, + "root": { + "handlers": [ + "file" + ], + "level": "DEBUG" + }, + "loggers": { + "idp": { + "level": "DEBUG" + } + }, + "handlers": { + "file": { + "class": "logging.FileHandler", + "filename": "dpoop_debug.log", + "formatter": "default" + } + }, + "formatters": { + "default": { + "format": "%(asctime)s %(name)s %(levelname)s %(message)s" + } + } + }, + "port": 8090, + "domain": "127.0.0.1", + "base_url": "https://{domain}:{port}", + "httpc_params": { + "verify": false + }, + "rp_keys": { + "private_path": "private/jwks.json", + "key_defs": [ + { + "type": "RSA", + "key": "", + "use": [ + "sig" + ] + }, + { + "type": "EC", + "crv": "P-256", + "use": [ + "sig" + ] + } + ], + "public_path": "static/jwks.json", + "read_only": false + }, + "services": { + "discovery": { + "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", + "kwargs": {} + }, + "registration": { + "class": "oidcrp.oidc.registration.Registration", + "kwargs": {} + }, + "authorization": { + "class": "oidcrp.oidc.authorization.Authorization", + "kwargs": {} + }, + "accesstoken": { + "class": "oidcrp.oidc.access_token.AccessToken", + "kwargs": {} + }, + "userinfo": { + "class": "oidcrp.oidc.userinfo.UserInfo", + "kwargs": {} + }, + "end_session": { + "class": "oidcrp.oidc.end_session.EndSession", + "kwargs": {} + } + }, + "clients": { + "": { + "client_preferences": { + "application_name": "rphandler", + "application_type": "web", + "contacts": [ + "ops@example.com" + ], + "response_types": [ + "code" + ], + "scope": [ + "openid", + "profile", + "email", + "address", + "phone" + ], + "token_endpoint_auth_method": [ + "client_secret_basic", + "client_secret_post" + ] + }, + "redirect_uris": [], + "services": { + "discovery": { + "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", + "kwargs": {} + }, + "registration": { + "class": "oidcrp.oidc.registration.Registration", + "kwargs": {} + }, + "authorization": { + "class": "oidcrp.oidc.authorization.Authorization", + "kwargs": {} + }, + "accesstoken": { + "class": "oidcrp.oidc.access_token.AccessToken", + "kwargs": {} + }, + "userinfo": { + "class": "oidcrp.oidc.userinfo.UserInfo", + "kwargs": {} + }, + "end_session": { + "class": "oidcrp.oidc.end_session.EndSession", + "kwargs": {} + } + } + }, + "flask_provider": { + "client_preferences": { + "application_name": "rphandler", + "application_type": "web", + "contacts": [ + "ops@example.com" + ], + "response_types": [ + "code" + ], + "scope": [ + "openid", + "profile", + "email", + "address", + "phone" + ], + "token_endpoint_auth_method": [ + "client_secret_basic", + "client_secret_post" + ] + }, + "issuer": "https://127.0.0.1:5000/", + "redirect_uris": [ + "https://{domain}:{port}/authz_cb/local" + ], + "post_logout_redirect_uris": [ + "https://{domain}:{port}/session_logout/local" + ], + "frontchannel_logout_uri": "https://{domain}:{port}/fc_logout/local", + "frontchannel_logout_session_required": true, + "backchannel_logout_uri": "https://{domain}:{port}/bc_logout/local", + "backchannel_logout_session_required": true, + "services": { + "discovery": { + "class": "oidcrp.oidc.provider_info_discovery.ProviderInfoDiscovery", + "kwargs": {} + }, + "registration": { + "class": "oidcrp.oidc.registration.Registration", + "kwargs": {} + }, + "authorization": { + "class": "oidcrp.oidc.authorization.Authorization", + "kwargs": {} + }, + "accesstoken": { + "class": "oidcrp.oidc.access_token.AccessToken", + "kwargs": {} + }, + "userinfo": { + "class": "oidcrp.oidc.userinfo.UserInfo", + "kwargs": {} + }, + "end_session": { + "class": "oidcrp.oidc.end_session.EndSession", + "kwargs": {} + } + }, + "add_ons": { + "pkce": { + "function": "oidcrp.oauth2.add_on.pkce.add_support", + "kwargs": { + "code_challenge_length": 64, + "code_challenge_method": "S256" + } + }, + "dpop": { + "function": "oidcrp.oauth2.add_on.dpop.add_support", + "kwargs": { + "signing_algorithms": [ + "ES256", "ES384", "ES512" + ] + } + } + } + } + }, + "webserver": { + "port": 8090, + "domain": "127.0.0.1", + "server_cert": "certs/cert.pem", + "server_key": "certs/key.pem", + "debug": true + } +} diff --git a/example/flask_rp/templates/opbyuid.html b/example/flask_rp/templates/opbyuid.html index d2c766b..a91b6b9 100644 --- a/example/flask_rp/templates/opbyuid.html +++ b/example/flask_rp/templates/opbyuid.html @@ -16,9 +16,11 @@

OP by UID

Start sign in flow

By entering your unique identifier:

- + +

an issuer ID

+

Or you can chose one of the preconfigured OpenID Connect Providers

- {% for op in providers %} diff --git a/example/flask_rp/views.py b/example/flask_rp/views.py index 10b7b36..5af68ae 100644 --- a/example/flask_rp/views.py +++ b/example/flask_rp/views.py @@ -1,6 +1,6 @@ import logging -import urllib from urllib.parse import parse_qs +from urllib.parse import splitquery from flask import Blueprint from flask import current_app @@ -43,55 +43,55 @@ def index(): @oidc_rp_views.route('/rp') def rp(): - try: - iss = request.args['iss'] - except KeyError: - link = '' - else: - link = iss + iss = request.args['dyn_iss'] + if not iss: + iss = request.args['static_iss'] - try: + if not iss: uid = request.args['uid'] - except KeyError: + else: uid = '' - if link or uid: + if iss or uid: if uid: args = {'user_id': uid} else: args = {} - session['op_hash'] = link + session['op_identifier'] = iss try: - result = current_app.rph.begin(link, **args) + result = current_app.rph.begin(iss, **args) except Exception as err: return make_response('Something went wrong:{}'.format(err), 400) else: - return redirect(result['url'], 303) + response = redirect(result['url'], 303) + return response else: _providers = current_app.rp_config.clients.keys() return render_template('opbyuid.html', providers=_providers) -def get_rp(op_hash): +def get_rp(op_identifier): try: - _iss = current_app.rph.hash2issuer[op_hash] + _iss = current_app.rph.hash2issuer[op_identifier] except KeyError: - logger.error('Unkown issuer: {} not among {}'.format( - op_hash, list(current_app.rph.hash2issuer.keys()))) - return make_response("Unknown hash: {}".format(op_hash), 400) + try: + rp = current_app.rph.issuer2rp[op_identifier] + except KeyError: + logger.error('Unkown issuer: {} not among {}'.format( + op_identifier, list(current_app.rph.hash2issuer.keys()))) + return make_response(f"Unknown OP identifier: {op_identifier}", 400) else: try: rp = current_app.rph.issuer2rp[_iss] except KeyError: - return make_response("Couldn't find client for {}".format(_iss), - 400) + return make_response(f"Couldn't find client for issuer: '{_iss}'", 400) return rp -def finalize(op_hash, request_args): - rp = get_rp(op_hash) +def finalize(op_identifier, request_args): + rp = get_rp(op_identifier) if hasattr(rp, 'status_code') and rp.status_code != 200: logger.error(rp.response[0].decode()) @@ -150,22 +150,22 @@ def finalize(op_hash, request_args): return make_response(res['error'], 400) -def get_ophash_by_cb_uri(url:str): - uri = urllib.parse.splitquery(request.url)[0] - clients = current_app.rp_config.clients - for k,v in clients.items(): +def get_op_identifier_by_cb_uri(url: str): + uri = splitquery(url)[0] + for k,v in current_app.rph.issuer2rp.items(): + _cntx = v.get_service_context() for endpoint in ("redirect_uris", "post_logout_redirect_uris", "frontchannel_logout_uri", "backchannel_logout_uri"): - if uri in clients[k].get(endpoint, []): + if uri in _cntx.get(endpoint, []): return k -@oidc_rp_views.route('/authz_cb/') -def authz_cb(op_hash): - op_hash = get_ophash_by_cb_uri(request.url) - return finalize(op_hash, request.args) +@oidc_rp_views.route('/authz_cb/') +def authz_cb(op_identifier): + op_identifier = get_op_identifier_by_cb_uri(request.url) + return finalize(op_identifier, request.args) @oidc_rp_views.errorhandler(werkzeug.exceptions.BadRequest) @@ -176,12 +176,12 @@ def handle_bad_request(e): @oidc_rp_views.route('/repost_fragment') def repost_fragment(): args = compact(parse_qs(request.args['url_fragment'])) - op_hash = request.args['op_hash'] - return finalize(op_hash, args) + op_identifier = request.args['op_identifier'] + return finalize(op_identifier, args) @oidc_rp_views.route('/ihf_cb') -def ihf_cb(self, op_hash='', **kwargs): +def ihf_cb(self, op_identifier='', **kwargs): logger.debug('implicit_hybrid_flow kwargs: {}'.format(kwargs)) return render_template('repost_fragment.html') @@ -190,11 +190,11 @@ def ihf_cb(self, op_hash='', **kwargs): def session_iframe(): # session management logger.debug('session_iframe request_args: {}'.format(request.args)) - _rp = get_rp(session['op_hash']) + _rp = get_rp(session['op_identifier']) _context = _rp.client_get("service_context") session_change_url = "{}/session_change".format(_context.base_url) - _issuer = current_app.rph.hash2issuer[session['op_hash']] + _issuer = current_app.rph.hash2issuer[session['op_identifier']] args = { 'client_id': session['client_id'], 'session_state': session['session_state'], @@ -208,8 +208,8 @@ def session_iframe(): # session management @oidc_rp_views.route('/session_change') def session_change(): - logger.debug('session_change: {}'.format(session['op_hash'])) - _rp = get_rp(session['op_hash']) + logger.debug('session_change: {}'.format(session['op_identifier'])) + _rp = get_rp(session['op_identifier']) # If there is an ID token send it along as a id_token_hint _aserv = _rp.client_get("service", 'authorization') @@ -227,10 +227,10 @@ def session_change(): # post_logout_redirect_uri -@oidc_rp_views.route('/session_logout/') -def session_logout(op_hash): - op_hash = get_ophash_by_cb_uri(request.url) - _rp = get_rp(op_hash) +@oidc_rp_views.route('/session_logout/') +def session_logout(op_identifier): + op_identifier = get_op_identifier_by_cb_uri(request.url) + _rp = get_rp(op_identifier) logger.debug('post_logout') return "Post logout from {}".format(_rp.client_get("service_context").issuer) @@ -244,9 +244,9 @@ def logout(): return redirect(_info['url'], 303) -@oidc_rp_views.route('/bc_logout/', methods=['GET', 'POST']) -def backchannel_logout(op_hash): - _rp = get_rp(op_hash) +@oidc_rp_views.route('/bc_logout/', methods=['GET', 'POST']) +def backchannel_logout(op_identifier): + _rp = get_rp(op_identifier) try: _state = rp_handler.backchannel_logout(_rp, request.data) except Exception as err: @@ -257,9 +257,9 @@ def backchannel_logout(op_hash): return "OK" -@oidc_rp_views.route('/fc_logout/', methods=['GET', 'POST']) -def frontchannel_logout(op_hash): - _rp = get_rp(op_hash) +@oidc_rp_views.route('/fc_logout/', methods=['GET', 'POST']) +def frontchannel_logout(op_identifier): + _rp = get_rp(op_identifier) sid = request.args['sid'] _iss = request.args['iss'] if _iss != _rp.client_get("service_context").get('issuer'): diff --git a/setup.py b/setup.py index 491527c..75a0fe5 100755 --- a/setup.py +++ b/setup.py @@ -67,7 +67,7 @@ def run_tests(self): "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules"], install_requires=[ - 'oidcmsg==1.3.2', + 'oidcmsg==1.3.3-1', 'pyyaml>=5.1.2', 'responses' ], diff --git a/src/oidcrp/__init__.py b/src/oidcrp/__init__.py index 4d3b843..23161e0 100644 --- a/src/oidcrp/__init__.py +++ b/src/oidcrp/__init__.py @@ -1,7 +1,7 @@ import logging __author__ = 'Roland Hedberg' -__version__ = '2.0.0' +__version__ = '2.0.1' logger = logging.getLogger(__name__) diff --git a/src/oidcrp/oauth2/__init__.py b/src/oidcrp/oauth2/__init__.py index 52fcf79..b195c8d 100755 --- a/src/oidcrp/oauth2/__init__.py +++ b/src/oidcrp/oauth2/__init__.py @@ -72,9 +72,7 @@ def __init__(self, client_authn_factory=None, keyjar=None, verify_ssl=True, conf # just ignore verify_ssl until it goes away self.verify_ssl = self.httpc_params.get("verify", True) - def do_request(self, request_type, response_body_type="", request_args=None, - **kwargs): - + def do_request(self, request_type, response_body_type="", request_args=None, **kwargs): _srv = self._service[request_type] _info = _srv.get_request_parameters(request_args=request_args, **kwargs) @@ -137,6 +135,7 @@ def service_request(self, service, url, method="GET", body=None, The method that sends the request and handles the response returned. This assumes that the response arrives in the HTTP response. + :param service: The Service instance :param url: The URL to which the request should be sent :param method: Which HTTP method to use :param body: A message body if any diff --git a/src/oidcrp/oauth2/add_on/dpop.py b/src/oidcrp/oauth2/add_on/dpop.py index 654db97..92bd575 100644 --- a/src/oidcrp/oauth2/add_on/dpop.py +++ b/src/oidcrp/oauth2/add_on/dpop.py @@ -85,20 +85,16 @@ def verify_header(self, dpop_header) -> Optional["DPoPProof"]: def dpop_header(service_context: ServiceContext, - request: Union[dict, Message], service_endpoint: str, http_method: str, headers: Optional[dict] = None, - authn_method: Optional[str] = "", **kwargs) -> dict: """ :param service_context: - :param request: :param service_endpoint: :param http_method: :param headers: - :param authn_method: :param kwargs: :return: """ @@ -155,9 +151,16 @@ def add_support(services, signing_algorithms: Optional[list] = None): :param signing_algorithms: """ + # Access token request should use DPoP header _service = services["accesstoken"] - _service.client_get("service_context").add_on['dpop'] = { + _context = _service.client_get("service_context") + _context.add_on['dpop'] = { # "key": key_by_alg(signing_algorithm), "sign_algs": signing_algorithms } _service.construct_extra_headers.append(dpop_header) + + # The same for userinfo requests + _userinfo_service = services.get("userinfo") + if _userinfo_service: + _userinfo_service.construct_extra_headers.append(dpop_header) diff --git a/src/oidcrp/rp_handler.py b/src/oidcrp/rp_handler.py index 5dc6750..e359939 100644 --- a/src/oidcrp/rp_handler.py +++ b/src/oidcrp/rp_handler.py @@ -488,8 +488,7 @@ def get_access_token(self, state, client: Optional[Client] = None): try: tokenresp = client.do_request( 'accesstoken', request_args=req_args, - authn_method=self.get_client_authn_method(client, - "token_endpoint"), + authn_method=self.get_client_authn_method(client, "token_endpoint"), state=state ) except Exception as err: diff --git a/tests/test_40_dpop.py b/tests/test_40_dpop.py index 490182a..ac893d3 100644 --- a/tests/test_40_dpop.py +++ b/tests/test_40_dpop.py @@ -1,15 +1,11 @@ import os -import pytest from cryptojwt.jws.jws import factory from cryptojwt.key_jar import init_key_jar +import pytest -from oidcrp.client_auth import factory as ca_factory from oidcrp.oauth2 import Client from oidcrp.oauth2 import DEFAULT_OAUTH2_SERVICES -from oidcrp.oauth2.add_on import do_add_ons -from oidcrp.service import init_services -from oidcrp.service_context import ServiceContext _dirname = os.path.dirname(os.path.abspath(__file__)) @@ -23,7 +19,7 @@ key_defs=KEYSPEC, issuer_id='client_id') -class TestDPoP: +class TestDPoPWithoutUserinfo: @pytest.fixture(autouse=True) def create_client(self): config = { @@ -43,14 +39,14 @@ def create_client(self): self.client = Client(keyjar=CLI_KEY, config=config, services=DEFAULT_OAUTH2_SERVICES) - self.client.client_get("service_context").provider_info= { + self.client.client_get("service_context").provider_info = { "authorization_endpoint": "https://example.com/auth", "token_endpoint": "https://example.com/token", "dpop_signing_alg_values_supported": ["RS256", "ES256"] } def test_add_header(self): - token_serv = self.client.client_get("service","accesstoken") + token_serv = self.client.client_get("service", "accesstoken") req_args = { "grant_type": "authorization_code", "code": "SplxlOBeZQQYbYS6WxSbIA", @@ -71,3 +67,92 @@ def test_add_header(self): assert _header["alg"] == "ES256" assert _header["jwk"]["kty"] == "EC" assert _header["jwk"]["crv"] == "P-256" + + +class TestDPoPWithUserinfo: + @pytest.fixture(autouse=True) + def create_client(self): + config = { + 'client_id': 'client_id', + 'client_secret': 'a longesh password', + 'redirect_uris': ['https://example.com/cli/authz_cb'], + 'behaviour': {'response_types': ['code']}, + 'add_ons': { + "dpop": { + "function": "oidcrp.oauth2.add_on.dpop.add_support", + "kwargs": { + "signing_algorithms": ["ES256", "ES512"] + } + } + } + } + + services = { + "discovery": { + 'class': 'oidcrp.oauth2.provider_info_discovery.ProviderInfoDiscovery' + }, + 'authorization': { + 'class': 'oidcrp.oauth2.authorization.Authorization' + }, + 'access_token': { + 'class': 'oidcrp.oauth2.access_token.AccessToken' + }, + 'refresh_access_token': { + 'class': 'oidcrp.oauth2.refresh_access_token.RefreshAccessToken' + }, + 'userinfo': { + 'class': 'oidcrp.oidc.userinfo.UserInfo' + } + } + self.client = Client(keyjar=CLI_KEY, config=config, services=services) + + self.client.client_get("service_context").provider_info = { + "authorization_endpoint": "https://example.com/auth", + "token_endpoint": "https://example.com/token", + "dpop_signing_alg_values_supported": ["RS256", "ES256"], + "userinfo_endpoint": "https://example.com/user", + } + + def test_add_header_token(self): + token_serv = self.client.client_get("service", "accesstoken") + req_args = { + "grant_type": "authorization_code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + "redirect_uri": "https://client/example.com/cb" + } + headers = token_serv.get_headers(request=req_args, http_method="POST") + assert headers + assert "dpop" in headers + + # Now for the content of the DPoP proof + _jws = factory(headers["dpop"]) + _payload = _jws.jwt.payload() + assert _payload["htu"] == "https://example.com/token" + assert _payload["htm"] == "POST" + _header = _jws.jwt.headers + assert "jwk" in _header + assert _header["typ"] == "dpop+jwt" + assert _header["alg"] == "ES256" + assert _header["jwk"]["kty"] == "EC" + assert _header["jwk"]["crv"] == "P-256" + + def test_add_header_userinfo(self): + userinfo_serv = self.client.client_get("service", "userinfo") + req_args = {} + access_token = 'access.token.sign' + headers = userinfo_serv.get_headers(request=req_args, http_method="GET", + access_token=access_token) + assert headers + assert "dpop" in headers + + # Now for the content of the DPoP proof + _jws = factory(headers["dpop"]) + _payload = _jws.jwt.payload() + assert _payload["htu"] == "https://example.com/user" + assert _payload["htm"] == "GET" + _header = _jws.jwt.headers + assert "jwk" in _header + assert _header["typ"] == "dpop+jwt" + assert _header["alg"] == "ES256" + assert _header["jwk"]["kty"] == "EC" + assert _header["jwk"]["crv"] == "P-256"