From b492f6da42558758d440fc130c9621cc2bb33c2b Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 26 Jan 2022 02:34:04 +0100 Subject: [PATCH 01/15] Fix imports and config --- example/flask_op/server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/example/flask_op/server.py b/example/flask_op/server.py index caada009..36ffd7b3 100755 --- a/example/flask_op/server.py +++ b/example/flask_op/server.py @@ -4,9 +4,10 @@ import logging import os -from oidcop.configure import Configuration +from oidcmsg.configure import Configuration +from oidcmsg.configure import create_from_config_file + from oidcop.configure import OPConfiguration -from oidcop.configure import create_from_config_file from oidcop.utils import create_context try: @@ -62,7 +63,7 @@ def main(config_file, args): app = oidc_provider_init_app(config.op, 'oidc_op') app.logger = config.logger - web_conf = config.webserver + web_conf = config.web_conf context = create_context(dir_path, web_conf) From 49cde1d6fb7ca957971ec60fa57e205ef4b5db63 Mon Sep 17 00:00:00 2001 From: Nikos Sklikas Date: Wed, 26 Jan 2022 12:41:00 +0200 Subject: [PATCH 02/15] Support both list and dict --- src/oidcop/session/claims.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/oidcop/session/claims.py b/src/oidcop/session/claims.py index 7ca31cc7..bcea0580 100755 --- a/src/oidcop/session/claims.py +++ b/src/oidcop/session/claims.py @@ -117,7 +117,10 @@ def get_claims_from_request( _always_add = module.kwargs.get("always_add_claims", {}) if _always_add: - base_claims.update({k: None for k in _always_add}) + if isinstance(_always_add, list): + base_claims.update({k: None for k in _always_add}) + else: + base_claims.update(_always_add) if _claims_by_scope: if scopes is None: From ff7b3fc5dd634cf977f6848826afe5ec964fb3fc Mon Sep 17 00:00:00 2001 From: Roland Hedberg Date: Sat, 29 Jan 2022 16:56:07 +0100 Subject: [PATCH 03/15] An add_on that allows adding extra arguments to a response. There to support https://datatracker.ietf.org/doc/draft-ietf-oauth-iss-auth-resp . --- src/oidcop/endpoint_context.py | 1 + src/oidcop/oauth2/add_on/extra_args.py | 31 +++++ tests/test_61_add_on.py | 169 +++++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 src/oidcop/oauth2/add_on/extra_args.py create mode 100644 tests/test_61_add_on.py diff --git a/src/oidcop/endpoint_context.py b/src/oidcop/endpoint_context.py index fb513a58..b7f8c73b 100755 --- a/src/oidcop/endpoint_context.py +++ b/src/oidcop/endpoint_context.py @@ -148,6 +148,7 @@ def __init__( # Default values, to be changed below depending on configuration # arguments for endpoints add-ons + self.add_on = {} self.args = {} self.authn_broker = None self.authz = None diff --git a/src/oidcop/oauth2/add_on/extra_args.py b/src/oidcop/oauth2/add_on/extra_args.py new file mode 100644 index 00000000..68a8a84a --- /dev/null +++ b/src/oidcop/oauth2/add_on/extra_args.py @@ -0,0 +1,31 @@ +def pre_construct(response_args, request, endpoint_context, **kwargs): + """ + Add extra arguments to the request. + + :param response_args: + :param request: + :param endpoint_context: + :param kwargs: + :return: + """ + + _extra = endpoint_context.add_on.get("extra_args") + if _extra: + for arg, _param in _extra.items(): + _val = endpoint_context.get(_param) + if _val: + request[arg] = _val + + return request + + +def add_support(endpoint, **kwargs): + # + _added = False + for endpoint_name in list(kwargs.keys()): + _endp = endpoint[endpoint_name] + _endp.pre_construct.append(pre_construct) + + if _added is False: + _endp.server_get("endpoint_context").add_on["extra_args"] = kwargs + _added = True diff --git a/tests/test_61_add_on.py b/tests/test_61_add_on.py new file mode 100644 index 00000000..e6c2b8ea --- /dev/null +++ b/tests/test_61_add_on.py @@ -0,0 +1,169 @@ +import os + +from cryptojwt.jwk.ec import ECKey +from cryptojwt.jwk.ec import new_ec_key +from cryptojwt.jws.jws import factory +from cryptojwt.key_jar import init_key_jar +from oidcmsg.oauth2 import AccessTokenRequest +from oidcmsg.oauth2 import AuthorizationRequest +from oidcmsg.time_util import utc_time_sans_frac + +from oidcop.authn_event import create_authn_event +import pytest + +from oidcop import user_info +from oidcop.client_authn import verify_client +from oidcop.configure import OPConfiguration +from oidcop.oauth2.authorization import Authorization +from oidcop.oidc.token import Token +from oidcop.server import Server +from oidcop.user_authn.authn_context import INTERNETPROTOCOLPASSWORD + + +KEYDEFS = [ + {"type": "RSA", "key": "", "use": ["sig"]}, + {"type": "EC", "crv": "P-256", "use": ["sig"]}, +] + +ISSUER = "https://example.com/" + +KEYJAR = init_key_jar(key_defs=KEYDEFS, issuer_id=ISSUER) +KEYJAR.import_jwks(KEYJAR.export_jwks(True, ISSUER), "") + +RESPONSE_TYPES_SUPPORTED = [ + ["code"], + ["token"], + ["id_token"], + ["code", "token"], + ["code", "id_token"], + ["id_token", "token"], + ["code", "token", "id_token"], + ["none"], +] + +CAPABILITIES = { + "response_types_supported": [" ".join(x) for x in RESPONSE_TYPES_SUPPORTED], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", + "private_key_jwt", + ], + "response_modes_supported": ["query", "fragment", "form_post"], + "subject_types_supported": ["public", "pairwise", "ephemeral"], + "grant_types_supported": [ + "authorization_code", + "implicit", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + ], + "claim_types_supported": ["normal", "aggregated", "distributed"], + "claims_parameter_supported": True, + "request_parameter_supported": True, + "request_uri_parameter_supported": True, +} + +AUTH_REQ = AuthorizationRequest( + client_id="client_1", + redirect_uri="https://example.com/cb", + scope=["openid"], + state="STATE", + response_type="code", +) + +BASEDIR = os.path.abspath(os.path.dirname(__file__)) + + +class TestEndpoint(object): + @pytest.fixture(autouse=True) + def create_endpoint(self): + conf = { + "issuer": ISSUER, + "httpc_params": {"verify": False, "timeout": 1}, + "capabilities": CAPABILITIES, + "add_on": { + "extra_args": { + "function": "oidcop.oauth2.add_on.extra_args.add_support", + "kwargs": { + "authorization": {"iss": "issuer"} + } + }, + }, + "keys": {"uri_path": "jwks.json", "key_defs": KEYDEFS}, + "token_handler_args": { + "jwks_file": "private/token_jwks.json", + "code": {"lifetime": 600}, + "token": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": { + "lifetime": 3600, + "base_claims": {"eduperson_scoped_affiliation": None}, + "add_claims_by_scope": True, + "aud": ["https://example.org/appl"], + }, + }, + "refresh": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": {"lifetime": 3600, "aud": ["https://example.org/appl"], }, + }, + "id_token": { + "class": "oidcop.token.id_token.IDToken", + "kwargs": { + "base_claims": { + "email": {"essential": True}, + "email_verified": {"essential": True}, + } + }, + }, + }, + "endpoint": { + "authorization": { + "path": "{}/authorization", + "class": Authorization, + "kwargs": {}, + }, + "token": { + "path": "{}/token", + "class": Token, + "kwargs": {}}, + }, + "client_authn": verify_client, + "authentication": { + "anon": { + "acr": INTERNETPROTOCOLPASSWORD, + "class": "oidcop.user_authn.user.NoAuthn", + "kwargs": {"user": "diana"}, + } + }, + "template_dir": "template", + "userinfo": { + "class": user_info.UserInfo, + "kwargs": {"db_file": "users.json"}, + }, + } + server = Server(OPConfiguration(conf, base_path=BASEDIR), keyjar=KEYJAR) + self.endpoint_context = server.endpoint_context + self.endpoint_context.cdb["client_1"] = { + "client_secret": "hemligt", + "redirect_uris": [("https://example.com/cb", None)], + "client_salt": "salted", + "token_endpoint_auth_method": "client_secret_post", + "response_types": ["code", "token", "code id_token", "id_token"], + } + self.endpoint = server.server_get("endpoint", "authorization") + + def test_process_request(self): + _context = self.endpoint.server_get("endpoint_context") + assert _context.add_on["extra_args"] == {'authorization': {'iss': 'issuer'}} + + _pr_resp = self.endpoint.parse_request(AUTH_REQ.to_dict()) + _resp = self.endpoint.process_request(_pr_resp) + assert set(_resp.keys()) == { + "response_args", + "fragment_enc", + "return_uri", + "cookie", + "session_id", + } + + assert 'iss' in _resp["response_args"] + assert _resp["response_args"]["iss"] == _context.issuer From 936a6487c2e4f129580f629e03e29696410aadd2 Mon Sep 17 00:00:00 2001 From: roland Date: Mon, 31 Jan 2022 11:18:28 +0100 Subject: [PATCH 04/15] Response might not be a Message instance. --- src/oidcop/endpoint.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/oidcop/endpoint.py b/src/oidcop/endpoint.py index 854b85ae..2b98815f 100755 --- a/src/oidcop/endpoint.py +++ b/src/oidcop/endpoint.py @@ -1,3 +1,4 @@ +import json import logging from typing import Callable from typing import Optional @@ -363,7 +364,10 @@ def do_response( if self.response_placement == "body": if self.response_format == "json": content_type = "application/json; charset=utf-8" - resp = _response.to_json() + if isinstance(_response, Message): + resp = _response.to_json() + else: + resp = json.dumps(_response) elif self.response_format in ["jws", "jwe", "jose"]: content_type = "application/jose; charset=utf-8" resp = _response From 3d17752be00adec21a17c032dbf1c827a938605f Mon Sep 17 00:00:00 2001 From: Kushal Das Date: Mon, 31 Jan 2022 15:27:12 +0100 Subject: [PATCH 05/15] Fixes the sub value to real value As a typo it was getting assigned to client_id. Now, it is assigned to the real calculated value. --- src/oidcop/session/grant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/oidcop/session/grant.py b/src/oidcop/session/grant.py index 16b4eb0a..a7c6f8d0 100644 --- a/src/oidcop/session/grant.py +++ b/src/oidcop/session/grant.py @@ -227,7 +227,7 @@ def payload_arguments( if self.authorization_request: client_id = self.authorization_request.get("client_id") if client_id: - payload.update({"client_id": client_id, "sub": client_id}) + payload.update({"client_id": client_id, "sub": self.sub}) _claims_restriction = endpoint_context.claims_interface.get_claims( session_id, From ea9ee7a6fca3d8ccf994c0895c698128260889f3 Mon Sep 17 00:00:00 2001 From: Giuseppe De Marco Date: Mon, 31 Jan 2022 21:51:13 +0100 Subject: [PATCH 06/15] feat: added "OAuth 2.0 Authorization Server Issuer Identification" in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7c55abe5..f48ab077 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ It also comes with the following `add_on` modules. * [Proof Key for Code Exchange by OAuth Public Clients (PKCE)](https://tools.ietf.org/html/rfc7636) * [OAuth2 RAR](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar) * [OAuth2 DPoP](https://tools.ietf.org/id/draft-fett-oauth-dpop-04.html) +* [OAuth 2.0 Authorization Server Issuer Identification](https://datatracker.ietf.org/doc/draft-ietf-oauth-iss-auth-resp) The entire project code is open sourced and therefore licensed under the [Apache 2.0](https://en.wikipedia.org/wiki/Apache_License) From 390c5d7974a8046503c1c0861ffb43fa5dcbaf35 Mon Sep 17 00:00:00 2001 From: roland Date: Tue, 1 Feb 2022 09:42:26 +0100 Subject: [PATCH 07/15] The configure parameter scopes_to_claims was not handled correctly. --- src/oidcop/__init__.py | 2 +- src/oidcop/endpoint_context.py | 17 ++-- tests/test_05_jwt_token.py | 155 +++++++++++++++++++++++++++++++++ tests/users.json | 3 +- 4 files changed, 166 insertions(+), 11 deletions(-) diff --git a/src/oidcop/__init__.py b/src/oidcop/__init__.py index 90a3e6a1..a281f477 100644 --- a/src/oidcop/__init__.py +++ b/src/oidcop/__init__.py @@ -1,6 +1,6 @@ import secrets -__version__ = "2.3.1" +__version__ = "2.3.5" DEF_SIGN_ALG = { "id_token": "RS256", diff --git a/src/oidcop/endpoint_context.py b/src/oidcop/endpoint_context.py index fb513a58..94ddb0ec 100755 --- a/src/oidcop/endpoint_context.py +++ b/src/oidcop/endpoint_context.py @@ -119,13 +119,13 @@ class EndpointContext(OidcContext): } def __init__( - self, - conf: Union[dict, OPConfiguration], - server_get: Callable, - keyjar: Optional[KeyJar] = None, - cwd: Optional[str] = "", - cookie_handler: Optional[Any] = None, - httpc: Optional[Any] = None, + self, + conf: Union[dict, OPConfiguration], + server_get: Callable, + keyjar: Optional[KeyJar] = None, + cwd: Optional[str] = "", + cookie_handler: Optional[Any] = None, + httpc: Optional[Any] = None, ): OidcContext.__init__(self, conf, keyjar, entity_id=conf.get("issuer", "")) self.conf = conf @@ -161,7 +161,7 @@ def __init__( self.login_hint2acrs = None self.par_db = {} self.provider_info = {} - self.scope2claims = SCOPE2CLAIMS + self.scope2claims = conf.get("scopes_to_claims", SCOPE2CLAIMS) self.session_manager = None self.sso_ttl = 14400 # 4h self.symkey = rndstr(24) @@ -215,7 +215,6 @@ def __init__( "cookie_handler", "authentication", "id_token", - "scope2claims", ]: _func = getattr(self, "do_{}".format(item), None) if _func: diff --git a/tests/test_05_jwt_token.py b/tests/test_05_jwt_token.py index a1e07bf5..4d3eef3e 100644 --- a/tests/test_05_jwt_token.py +++ b/tests/test_05_jwt_token.py @@ -6,6 +6,7 @@ from oidcmsg.oidc import AccessTokenRequest from oidcmsg.oidc import AuthorizationRequest from oidcmsg.time_util import utc_time_sans_frac +from oidcop.scopes import SCOPE2CLAIMS from oidcop import user_info from oidcop.authn_event import create_authn_event @@ -275,3 +276,157 @@ def test_is_expired(self): assert access_token.is_active() # 4000 seconds in the future. Passed the lifetime. assert access_token.is_active(now=utc_time_sans_frac() + 4000) is False + + +class TestEndpointWebID(object): + @pytest.fixture(autouse=True) + def create_endpoint(self): + _scope2claims = SCOPE2CLAIMS.copy() + _scope2claims.update({"webid": ["webid"]}) + conf = { + "issuer": ISSUER, + "httpc_params": {"verify": False, "timeout": 1}, + "capabilities": CAPABILITIES, + "keys": {"uri_path": "jwks.json", "key_defs": KEYDEFS}, + "token_handler_args": { + "jwks_file": "private/token_jwks.json", + "code": {"lifetime": 600}, + "token": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": { + "lifetime": 3600, + "base_claims": {"eduperson_scoped_affiliation": None}, + "add_claims_by_scope": True, + "aud": ["https://example.org/appl"], + }, + }, + "refresh": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": {"lifetime": 3600, "aud": ["https://example.org/appl"], }, + }, + "id_token": { + "class": "oidcop.token.id_token.IDToken", + "kwargs": { + "base_claims": { + "email": {"essential": True}, + "email_verified": {"essential": True}, + } + }, + }, + }, + "endpoint": { + "provider_config": { + "path": "{}/.well-known/openid-configuration", + "class": ProviderConfiguration, + "kwargs": {}, + }, + "registration": {"path": "{}/registration", "class": Registration, "kwargs": {}, }, + "authorization": { + "path": "{}/authorization", + "class": Authorization, + "kwargs": {}, + }, + "token": {"path": "{}/token", "class": Token, "kwargs": {}}, + "session": {"path": "{}/end_session", "class": Session}, + "introspection": {"path": "{}/introspection", "class": Introspection}, + }, + "client_authn": verify_client, + "authentication": { + "anon": { + "acr": INTERNETPROTOCOLPASSWORD, + "class": "oidcop.user_authn.user.NoAuthn", + "kwargs": {"user": "diana"}, + } + }, + "template_dir": "template", + "userinfo": { + "class": user_info.UserInfo, + "kwargs": {"db_file": full_path("users.json")}, + }, + "authz": { + "class": AuthzHandling, + "kwargs": { + "grant_config": { + "usage_rules": { + "authorization_code": { + "supports_minting": ["access_token", "refresh_token", "id_token", ], + "max_usage": 1, + }, + "access_token": {}, + "refresh_token": { + "supports_minting": ["access_token", "refresh_token"], + }, + }, + "expires_in": 43200, + } + }, + }, + "claims_interface": {"class": "oidcop.session.claims.ClaimsInterface", "kwargs": {}}, + "scopes_to_claims": _scope2claims, + } + server = Server(conf, keyjar=KEYJAR) + self.endpoint_context = server.endpoint_context + self.endpoint_context.cdb["client_1"] = { + "client_secret": "hemligt", + "redirect_uris": [("https://example.com/cb", None)], + "client_salt": "salted", + "token_endpoint_auth_method": "client_secret_post", + "response_types": ["code", "token", "code id_token", "id_token"], + "add_claims": { + "always": {}, + "by_scope": {}, + }, + } + self.session_manager = self.endpoint_context.session_manager + self.user_id = "diana" + self.endpoint = server.server_get("endpoint", "session") + + def _create_session(self, auth_req, sub_type="public", sector_identifier=""): + if sector_identifier: + authz_req = auth_req.copy() + authz_req["sector_identifier_uri"] = sector_identifier + else: + authz_req = auth_req + client_id = authz_req["client_id"] + ae = create_authn_event(self.user_id) + return self.session_manager.create_session( + ae, authz_req, self.user_id, client_id=client_id, sub_type=sub_type + ) + + def _mint_token(self, token_class, grant, session_id, based_on=None, **kwargs): + # Constructing an authorization code is now done + return grant.mint_token( + session_id=session_id, + endpoint_context=self.endpoint_context, + token_class=token_class, + token_handler=self.session_manager.token_handler.handler[token_class], + expires_at=utc_time_sans_frac() + 300, # 5 minutes from now + based_on=based_on, + **kwargs + ) + + def test_parse(self): + _auth_req = AuthorizationRequest( + client_id="client_1", + redirect_uri="https://example.com/cb", + scope=["openid", "webid"], + state="STATE", + response_type="code", + ) + + session_id = self._create_session(_auth_req) + # apply consent + grant = self.endpoint_context.authz(session_id=session_id, request=_auth_req) + # grant = self.session_manager[session_id] + code = self._mint_token("authorization_code", grant, session_id) + access_token = self._mint_token( + "access_token", grant, session_id, code, resources=[_auth_req["client_id"]] + ) + + _verifier = JWT(self.endpoint_context.keyjar) + _info = _verifier.unpack(access_token.value) + + assert _info["token_class"] == "access_token" + # assert _info["eduperson_scoped_affiliation"] == ["staff@example.org"] + assert set(_info["aud"]) == {"client_1"} + assert "webid" in _info diff --git a/tests/users.json b/tests/users.json index 310ad98b..71aac3f9 100755 --- a/tests/users.json +++ b/tests/users.json @@ -15,7 +15,8 @@ }, "eduperson_scoped_affiliation": [ "staff@example.org" - ] + ], + "webid": "http://bblfish.net/#hjs" }, "babs": { "name": "Barbara J Jensen", From 226e4ae3df5371c8add5d58751633ce5231ccc7a Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 26 Jan 2022 02:34:04 +0100 Subject: [PATCH 08/15] Fix imports and config --- example/flask_op/server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/example/flask_op/server.py b/example/flask_op/server.py index caada009..36ffd7b3 100755 --- a/example/flask_op/server.py +++ b/example/flask_op/server.py @@ -4,9 +4,10 @@ import logging import os -from oidcop.configure import Configuration +from oidcmsg.configure import Configuration +from oidcmsg.configure import create_from_config_file + from oidcop.configure import OPConfiguration -from oidcop.configure import create_from_config_file from oidcop.utils import create_context try: @@ -62,7 +63,7 @@ def main(config_file, args): app = oidc_provider_init_app(config.op, 'oidc_op') app.logger = config.logger - web_conf = config.webserver + web_conf = config.web_conf context = create_context(dir_path, web_conf) From 8b67a5ccb0d18d35377f6734d651e8b9991dcce0 Mon Sep 17 00:00:00 2001 From: Nikos Sklikas Date: Wed, 26 Jan 2022 12:41:00 +0200 Subject: [PATCH 09/15] Support both list and dict --- src/oidcop/session/claims.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/oidcop/session/claims.py b/src/oidcop/session/claims.py index 7ca31cc7..bcea0580 100755 --- a/src/oidcop/session/claims.py +++ b/src/oidcop/session/claims.py @@ -117,7 +117,10 @@ def get_claims_from_request( _always_add = module.kwargs.get("always_add_claims", {}) if _always_add: - base_claims.update({k: None for k in _always_add}) + if isinstance(_always_add, list): + base_claims.update({k: None for k in _always_add}) + else: + base_claims.update(_always_add) if _claims_by_scope: if scopes is None: From 87d5c67583c654888de5a54c1b5340d07fa4d13f Mon Sep 17 00:00:00 2001 From: Kushal Das Date: Mon, 31 Jan 2022 15:27:12 +0100 Subject: [PATCH 10/15] Fixes the sub value to real value As a typo it was getting assigned to client_id. Now, it is assigned to the real calculated value. --- src/oidcop/session/grant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/oidcop/session/grant.py b/src/oidcop/session/grant.py index 16b4eb0a..a7c6f8d0 100644 --- a/src/oidcop/session/grant.py +++ b/src/oidcop/session/grant.py @@ -227,7 +227,7 @@ def payload_arguments( if self.authorization_request: client_id = self.authorization_request.get("client_id") if client_id: - payload.update({"client_id": client_id, "sub": client_id}) + payload.update({"client_id": client_id, "sub": self.sub}) _claims_restriction = endpoint_context.claims_interface.get_claims( session_id, From f2766d45822413fae87f46836984c488d65b3f95 Mon Sep 17 00:00:00 2001 From: roland Date: Mon, 31 Jan 2022 11:18:28 +0100 Subject: [PATCH 11/15] Response might not be a Message instance. --- src/oidcop/endpoint.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/oidcop/endpoint.py b/src/oidcop/endpoint.py index 854b85ae..2b98815f 100755 --- a/src/oidcop/endpoint.py +++ b/src/oidcop/endpoint.py @@ -1,3 +1,4 @@ +import json import logging from typing import Callable from typing import Optional @@ -363,7 +364,10 @@ def do_response( if self.response_placement == "body": if self.response_format == "json": content_type = "application/json; charset=utf-8" - resp = _response.to_json() + if isinstance(_response, Message): + resp = _response.to_json() + else: + resp = json.dumps(_response) elif self.response_format in ["jws", "jwe", "jose"]: content_type = "application/jose; charset=utf-8" resp = _response From d616204a815dd86a1a195ed0bb991f625ba2e7b8 Mon Sep 17 00:00:00 2001 From: Giuseppe De Marco Date: Mon, 31 Jan 2022 21:51:13 +0100 Subject: [PATCH 12/15] feat: added "OAuth 2.0 Authorization Server Issuer Identification" in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f412356c..3b39b93b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ It also comes with the following `add_on` modules. * [OAuth2 PAR](https://datatracker.ietf.org/doc/html/rfc9126) * [OAuth2 RAR](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar) * [OAuth2 DPoP](https://tools.ietf.org/id/draft-fett-oauth-dpop-04.html) +* [OAuth 2.0 Authorization Server Issuer Identification](https://datatracker.ietf.org/doc/draft-ietf-oauth-iss-auth-resp) The entire project code is open sourced and therefore licensed under the [Apache 2.0](https://en.wikipedia.org/wiki/Apache_License) From bb739715eb316065ff1ccde50d238d43935a8b5e Mon Sep 17 00:00:00 2001 From: Roland Hedberg Date: Sat, 29 Jan 2022 16:56:07 +0100 Subject: [PATCH 13/15] An add_on that allows adding extra arguments to a response. There to support https://datatracker.ietf.org/doc/draft-ietf-oauth-iss-auth-resp . --- src/oidcop/endpoint_context.py | 1 + src/oidcop/oauth2/add_on/extra_args.py | 31 +++++ tests/test_61_add_on.py | 169 +++++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 src/oidcop/oauth2/add_on/extra_args.py create mode 100644 tests/test_61_add_on.py diff --git a/src/oidcop/endpoint_context.py b/src/oidcop/endpoint_context.py index 00d9da6c..6e86a0b9 100755 --- a/src/oidcop/endpoint_context.py +++ b/src/oidcop/endpoint_context.py @@ -148,6 +148,7 @@ def __init__( # Default values, to be changed below depending on configuration # arguments for endpoints add-ons + self.add_on = {} self.args = {} self.authn_broker = None self.authz = None diff --git a/src/oidcop/oauth2/add_on/extra_args.py b/src/oidcop/oauth2/add_on/extra_args.py new file mode 100644 index 00000000..68a8a84a --- /dev/null +++ b/src/oidcop/oauth2/add_on/extra_args.py @@ -0,0 +1,31 @@ +def pre_construct(response_args, request, endpoint_context, **kwargs): + """ + Add extra arguments to the request. + + :param response_args: + :param request: + :param endpoint_context: + :param kwargs: + :return: + """ + + _extra = endpoint_context.add_on.get("extra_args") + if _extra: + for arg, _param in _extra.items(): + _val = endpoint_context.get(_param) + if _val: + request[arg] = _val + + return request + + +def add_support(endpoint, **kwargs): + # + _added = False + for endpoint_name in list(kwargs.keys()): + _endp = endpoint[endpoint_name] + _endp.pre_construct.append(pre_construct) + + if _added is False: + _endp.server_get("endpoint_context").add_on["extra_args"] = kwargs + _added = True diff --git a/tests/test_61_add_on.py b/tests/test_61_add_on.py new file mode 100644 index 00000000..e6c2b8ea --- /dev/null +++ b/tests/test_61_add_on.py @@ -0,0 +1,169 @@ +import os + +from cryptojwt.jwk.ec import ECKey +from cryptojwt.jwk.ec import new_ec_key +from cryptojwt.jws.jws import factory +from cryptojwt.key_jar import init_key_jar +from oidcmsg.oauth2 import AccessTokenRequest +from oidcmsg.oauth2 import AuthorizationRequest +from oidcmsg.time_util import utc_time_sans_frac + +from oidcop.authn_event import create_authn_event +import pytest + +from oidcop import user_info +from oidcop.client_authn import verify_client +from oidcop.configure import OPConfiguration +from oidcop.oauth2.authorization import Authorization +from oidcop.oidc.token import Token +from oidcop.server import Server +from oidcop.user_authn.authn_context import INTERNETPROTOCOLPASSWORD + + +KEYDEFS = [ + {"type": "RSA", "key": "", "use": ["sig"]}, + {"type": "EC", "crv": "P-256", "use": ["sig"]}, +] + +ISSUER = "https://example.com/" + +KEYJAR = init_key_jar(key_defs=KEYDEFS, issuer_id=ISSUER) +KEYJAR.import_jwks(KEYJAR.export_jwks(True, ISSUER), "") + +RESPONSE_TYPES_SUPPORTED = [ + ["code"], + ["token"], + ["id_token"], + ["code", "token"], + ["code", "id_token"], + ["id_token", "token"], + ["code", "token", "id_token"], + ["none"], +] + +CAPABILITIES = { + "response_types_supported": [" ".join(x) for x in RESPONSE_TYPES_SUPPORTED], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", + "private_key_jwt", + ], + "response_modes_supported": ["query", "fragment", "form_post"], + "subject_types_supported": ["public", "pairwise", "ephemeral"], + "grant_types_supported": [ + "authorization_code", + "implicit", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + ], + "claim_types_supported": ["normal", "aggregated", "distributed"], + "claims_parameter_supported": True, + "request_parameter_supported": True, + "request_uri_parameter_supported": True, +} + +AUTH_REQ = AuthorizationRequest( + client_id="client_1", + redirect_uri="https://example.com/cb", + scope=["openid"], + state="STATE", + response_type="code", +) + +BASEDIR = os.path.abspath(os.path.dirname(__file__)) + + +class TestEndpoint(object): + @pytest.fixture(autouse=True) + def create_endpoint(self): + conf = { + "issuer": ISSUER, + "httpc_params": {"verify": False, "timeout": 1}, + "capabilities": CAPABILITIES, + "add_on": { + "extra_args": { + "function": "oidcop.oauth2.add_on.extra_args.add_support", + "kwargs": { + "authorization": {"iss": "issuer"} + } + }, + }, + "keys": {"uri_path": "jwks.json", "key_defs": KEYDEFS}, + "token_handler_args": { + "jwks_file": "private/token_jwks.json", + "code": {"lifetime": 600}, + "token": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": { + "lifetime": 3600, + "base_claims": {"eduperson_scoped_affiliation": None}, + "add_claims_by_scope": True, + "aud": ["https://example.org/appl"], + }, + }, + "refresh": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": {"lifetime": 3600, "aud": ["https://example.org/appl"], }, + }, + "id_token": { + "class": "oidcop.token.id_token.IDToken", + "kwargs": { + "base_claims": { + "email": {"essential": True}, + "email_verified": {"essential": True}, + } + }, + }, + }, + "endpoint": { + "authorization": { + "path": "{}/authorization", + "class": Authorization, + "kwargs": {}, + }, + "token": { + "path": "{}/token", + "class": Token, + "kwargs": {}}, + }, + "client_authn": verify_client, + "authentication": { + "anon": { + "acr": INTERNETPROTOCOLPASSWORD, + "class": "oidcop.user_authn.user.NoAuthn", + "kwargs": {"user": "diana"}, + } + }, + "template_dir": "template", + "userinfo": { + "class": user_info.UserInfo, + "kwargs": {"db_file": "users.json"}, + }, + } + server = Server(OPConfiguration(conf, base_path=BASEDIR), keyjar=KEYJAR) + self.endpoint_context = server.endpoint_context + self.endpoint_context.cdb["client_1"] = { + "client_secret": "hemligt", + "redirect_uris": [("https://example.com/cb", None)], + "client_salt": "salted", + "token_endpoint_auth_method": "client_secret_post", + "response_types": ["code", "token", "code id_token", "id_token"], + } + self.endpoint = server.server_get("endpoint", "authorization") + + def test_process_request(self): + _context = self.endpoint.server_get("endpoint_context") + assert _context.add_on["extra_args"] == {'authorization': {'iss': 'issuer'}} + + _pr_resp = self.endpoint.parse_request(AUTH_REQ.to_dict()) + _resp = self.endpoint.process_request(_pr_resp) + assert set(_resp.keys()) == { + "response_args", + "fragment_enc", + "return_uri", + "cookie", + "session_id", + } + + assert 'iss' in _resp["response_args"] + assert _resp["response_args"]["iss"] == _context.issuer From f1a6a190274f8fe81739039eedf50d6291dce6ab Mon Sep 17 00:00:00 2001 From: roland Date: Tue, 1 Feb 2022 09:42:26 +0100 Subject: [PATCH 14/15] The configure parameter scopes_to_claims was not handled correctly. --- src/oidcop/__init__.py | 2 +- src/oidcop/endpoint_context.py | 17 ++-- tests/test_05_jwt_token.py | 155 +++++++++++++++++++++++++++++++++ tests/users.json | 3 +- 4 files changed, 166 insertions(+), 11 deletions(-) diff --git a/src/oidcop/__init__.py b/src/oidcop/__init__.py index 490b473d..a281f477 100644 --- a/src/oidcop/__init__.py +++ b/src/oidcop/__init__.py @@ -1,6 +1,6 @@ import secrets -__version__ = "2.3.4" +__version__ = "2.3.5" DEF_SIGN_ALG = { "id_token": "RS256", diff --git a/src/oidcop/endpoint_context.py b/src/oidcop/endpoint_context.py index 6e86a0b9..fd135395 100755 --- a/src/oidcop/endpoint_context.py +++ b/src/oidcop/endpoint_context.py @@ -119,13 +119,13 @@ class EndpointContext(OidcContext): } def __init__( - self, - conf: Union[dict, OPConfiguration], - server_get: Callable, - keyjar: Optional[KeyJar] = None, - cwd: Optional[str] = "", - cookie_handler: Optional[Any] = None, - httpc: Optional[Any] = None, + self, + conf: Union[dict, OPConfiguration], + server_get: Callable, + keyjar: Optional[KeyJar] = None, + cwd: Optional[str] = "", + cookie_handler: Optional[Any] = None, + httpc: Optional[Any] = None, ): OidcContext.__init__(self, conf, keyjar, entity_id=conf.get("issuer", "")) self.conf = conf @@ -162,7 +162,7 @@ def __init__( self.login_hint2acrs = None self.par_db = {} self.provider_info = {} - self.scope2claims = SCOPE2CLAIMS + self.scope2claims = conf.get("scopes_to_claims", SCOPE2CLAIMS) self.session_manager = None self.sso_ttl = 14400 # 4h self.symkey = rndstr(24) @@ -216,7 +216,6 @@ def __init__( "cookie_handler", "authentication", "id_token", - "scope2claims", ]: _func = getattr(self, "do_{}".format(item), None) if _func: diff --git a/tests/test_05_jwt_token.py b/tests/test_05_jwt_token.py index a1e07bf5..4d3eef3e 100644 --- a/tests/test_05_jwt_token.py +++ b/tests/test_05_jwt_token.py @@ -6,6 +6,7 @@ from oidcmsg.oidc import AccessTokenRequest from oidcmsg.oidc import AuthorizationRequest from oidcmsg.time_util import utc_time_sans_frac +from oidcop.scopes import SCOPE2CLAIMS from oidcop import user_info from oidcop.authn_event import create_authn_event @@ -275,3 +276,157 @@ def test_is_expired(self): assert access_token.is_active() # 4000 seconds in the future. Passed the lifetime. assert access_token.is_active(now=utc_time_sans_frac() + 4000) is False + + +class TestEndpointWebID(object): + @pytest.fixture(autouse=True) + def create_endpoint(self): + _scope2claims = SCOPE2CLAIMS.copy() + _scope2claims.update({"webid": ["webid"]}) + conf = { + "issuer": ISSUER, + "httpc_params": {"verify": False, "timeout": 1}, + "capabilities": CAPABILITIES, + "keys": {"uri_path": "jwks.json", "key_defs": KEYDEFS}, + "token_handler_args": { + "jwks_file": "private/token_jwks.json", + "code": {"lifetime": 600}, + "token": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": { + "lifetime": 3600, + "base_claims": {"eduperson_scoped_affiliation": None}, + "add_claims_by_scope": True, + "aud": ["https://example.org/appl"], + }, + }, + "refresh": { + "class": "oidcop.token.jwt_token.JWTToken", + "kwargs": {"lifetime": 3600, "aud": ["https://example.org/appl"], }, + }, + "id_token": { + "class": "oidcop.token.id_token.IDToken", + "kwargs": { + "base_claims": { + "email": {"essential": True}, + "email_verified": {"essential": True}, + } + }, + }, + }, + "endpoint": { + "provider_config": { + "path": "{}/.well-known/openid-configuration", + "class": ProviderConfiguration, + "kwargs": {}, + }, + "registration": {"path": "{}/registration", "class": Registration, "kwargs": {}, }, + "authorization": { + "path": "{}/authorization", + "class": Authorization, + "kwargs": {}, + }, + "token": {"path": "{}/token", "class": Token, "kwargs": {}}, + "session": {"path": "{}/end_session", "class": Session}, + "introspection": {"path": "{}/introspection", "class": Introspection}, + }, + "client_authn": verify_client, + "authentication": { + "anon": { + "acr": INTERNETPROTOCOLPASSWORD, + "class": "oidcop.user_authn.user.NoAuthn", + "kwargs": {"user": "diana"}, + } + }, + "template_dir": "template", + "userinfo": { + "class": user_info.UserInfo, + "kwargs": {"db_file": full_path("users.json")}, + }, + "authz": { + "class": AuthzHandling, + "kwargs": { + "grant_config": { + "usage_rules": { + "authorization_code": { + "supports_minting": ["access_token", "refresh_token", "id_token", ], + "max_usage": 1, + }, + "access_token": {}, + "refresh_token": { + "supports_minting": ["access_token", "refresh_token"], + }, + }, + "expires_in": 43200, + } + }, + }, + "claims_interface": {"class": "oidcop.session.claims.ClaimsInterface", "kwargs": {}}, + "scopes_to_claims": _scope2claims, + } + server = Server(conf, keyjar=KEYJAR) + self.endpoint_context = server.endpoint_context + self.endpoint_context.cdb["client_1"] = { + "client_secret": "hemligt", + "redirect_uris": [("https://example.com/cb", None)], + "client_salt": "salted", + "token_endpoint_auth_method": "client_secret_post", + "response_types": ["code", "token", "code id_token", "id_token"], + "add_claims": { + "always": {}, + "by_scope": {}, + }, + } + self.session_manager = self.endpoint_context.session_manager + self.user_id = "diana" + self.endpoint = server.server_get("endpoint", "session") + + def _create_session(self, auth_req, sub_type="public", sector_identifier=""): + if sector_identifier: + authz_req = auth_req.copy() + authz_req["sector_identifier_uri"] = sector_identifier + else: + authz_req = auth_req + client_id = authz_req["client_id"] + ae = create_authn_event(self.user_id) + return self.session_manager.create_session( + ae, authz_req, self.user_id, client_id=client_id, sub_type=sub_type + ) + + def _mint_token(self, token_class, grant, session_id, based_on=None, **kwargs): + # Constructing an authorization code is now done + return grant.mint_token( + session_id=session_id, + endpoint_context=self.endpoint_context, + token_class=token_class, + token_handler=self.session_manager.token_handler.handler[token_class], + expires_at=utc_time_sans_frac() + 300, # 5 minutes from now + based_on=based_on, + **kwargs + ) + + def test_parse(self): + _auth_req = AuthorizationRequest( + client_id="client_1", + redirect_uri="https://example.com/cb", + scope=["openid", "webid"], + state="STATE", + response_type="code", + ) + + session_id = self._create_session(_auth_req) + # apply consent + grant = self.endpoint_context.authz(session_id=session_id, request=_auth_req) + # grant = self.session_manager[session_id] + code = self._mint_token("authorization_code", grant, session_id) + access_token = self._mint_token( + "access_token", grant, session_id, code, resources=[_auth_req["client_id"]] + ) + + _verifier = JWT(self.endpoint_context.keyjar) + _info = _verifier.unpack(access_token.value) + + assert _info["token_class"] == "access_token" + # assert _info["eduperson_scoped_affiliation"] == ["staff@example.org"] + assert set(_info["aud"]) == {"client_1"} + assert "webid" in _info diff --git a/tests/users.json b/tests/users.json index 310ad98b..71aac3f9 100755 --- a/tests/users.json +++ b/tests/users.json @@ -15,7 +15,8 @@ }, "eduperson_scoped_affiliation": [ "staff@example.org" - ] + ], + "webid": "http://bblfish.net/#hjs" }, "babs": { "name": "Barbara J Jensen", From 7407cfab74def2886ab716f4ab38791538d2061b Mon Sep 17 00:00:00 2001 From: roland Date: Tue, 1 Feb 2022 14:35:10 +0100 Subject: [PATCH 15/15] Upgraded the version number. --- src/oidcop/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/oidcop/__init__.py b/src/oidcop/__init__.py index a281f477..728a9970 100644 --- a/src/oidcop/__init__.py +++ b/src/oidcop/__init__.py @@ -1,6 +1,6 @@ import secrets -__version__ = "2.3.5" +__version__ = "2.4.0" DEF_SIGN_ALG = { "id_token": "RS256",