Skip to content

Commit 0fbd797

Browse files
authored
Merge pull request #40 from ctriant/resource-indicators
Introduce resource indicators
2 parents f682fc9 + 4bd3f32 commit 0fbd797

30 files changed

+1092
-92
lines changed

doc/intro.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ IdpyOIDC implements the following standards:
2121
* `OpenID Connect Front-Channel Logout 1.0 <https://openid.net/specs/openid-connect-frontchannel-1_0.html>`_
2222
* `OAuth2 Token introspection <https://tools.ietf.org/html/rfc7662>`_
2323
* `OAuth2 Token exchange <https://datatracker.ietf.org/doc/html/rfc8693>`_
24+
* `OAuth2 Resource Indicators <https://datatracker.ietf.org/doc/rfc8707/>`_
2425
* `The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR) <https://datatracker.ietf.org/doc/html/rfc9101>`_
2526

2627
It also comes with the following `add_on` modules.

doc/server/contents/conf.rst

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,3 +884,67 @@ idpyoidc\.server\.configure module
884884
:undoc-members:
885885
:show-inheritance:
886886

887+
888+
==============
889+
Resource Indicators
890+
==============
891+
There are two possible ways to configure Resource Indicators in OIDC-OP, globally and per-client.
892+
For the first case the configuration is passed in the Authorization or Access Token endpoint arguments throught the
893+
`resource_indicators` dictionary.
894+
895+
If present, the resource indicators configuration should contain a `policy` dictionary
896+
that defines the behaviour of the specific endpoint. The policy
897+
is mapped to a dictionary with the keys `callable` (mandatory), which must be a
898+
python callable or a string that represents the path to a python callable, and
899+
`kwargs` (optional), which must be a dict of key-value arguments that will be
900+
passed to the callable.
901+
902+
The resource indicators configuration may also contain a `resource_servers_per_client`
903+
dictionary that defines a mapping between oidc-op registered clients with key the equivalent `client id` and resources to whom this client
904+
is eligible to request access.
905+
906+
"resource_indicators":{
907+
"policy": {
908+
"callable": validate_authorization_resource_indicators_policy,
909+
"kwargs": {
910+
"resource_servers_per_client": {
911+
"CLIENT_1": ["RESOURCE_1"],
912+
"CLIENT_2": ["RESOURCE_1", "RESOURCE_2"]
913+
},
914+
},
915+
},
916+
},
917+
}
918+
919+
For the per-client configuration a similar configuration scheme should be present in the client's
920+
metadata under the `resource_indicators` key with slight difference. The `policy` mapping should be set a value for a
921+
key `authorization_code` or `access_token` in order to indicate the endpoint that this resource indicators policy is reffered to.
922+
In addition, the `resource_servers_per_client` value is a list of the permitted resources.
923+
924+
For example::
925+
926+
"resource_indicators":{
927+
"authorization_code": {
928+
"policy": {
929+
"callable": validate_authorization_resource_indicators_policy,
930+
"kwargs": {
931+
"resource_servers_per_client": ["RESOURCE_1"],
932+
},
933+
},
934+
},
935+
},
936+
}
937+
938+
The policy callable accepts a specific argument list and must return the altered token
939+
request or raise an exception.
940+
941+
For example::
942+
943+
def validate_resource_indicators_policy(request, context, **kwargs):
944+
if some_condition in request:
945+
return TokenErrorResponse(
946+
error="invalid_request", error_description="Some error occured"
947+
)
948+
949+
return request
950+

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ pytest-isort>=1.3.0
55
pytest-localserver>=0.5.0
66
flake8
77
bandit
8+
urllib3<1.27

src/idpyoidc/server/oauth2/authorization.py

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from cryptojwt.utils import as_bytes
1515
from cryptojwt.utils import b64e
1616

17+
from idpyoidc.exception import ImproperlyConfigured
1718
from idpyoidc.exception import ParameterError
1819
from idpyoidc.exception import URIError
1920
from idpyoidc.message import Message
@@ -39,6 +40,7 @@
3940
from idpyoidc.time_util import utc_time_sans_frac
4041
from idpyoidc.util import rndstr
4142
from idpyoidc.util import split_uri
43+
from idpyoidc.util import importer
4244

4345
logger = logging.getLogger(__name__)
4446

@@ -277,6 +279,53 @@ def check_unknown_scopes_policy(request_info, client_id, endpoint_context):
277279
raise UnAuthorizedClientScope()
278280

279281

282+
def validate_resource_indicators_policy(request, context, **kwargs):
283+
if "resource" not in request:
284+
return oauth2.AuthorizationErrorResponse(
285+
error="invalid_target",
286+
error_description="Missing resource parameter",
287+
)
288+
289+
resource_servers_per_client = kwargs["resource_servers_per_client"]
290+
client_id = request["client_id"]
291+
292+
if isinstance(resource_servers_per_client, dict) and client_id not in resource_servers_per_client:
293+
return oauth2.AuthorizationErrorResponse(
294+
error="invalid_target",
295+
error_description=f"Resources for client {client_id} not found",
296+
)
297+
298+
if isinstance(resource_servers_per_client, dict):
299+
permitted_resources = [res for res in resource_servers_per_client[client_id]]
300+
else:
301+
permitted_resources = [res for res in resource_servers_per_client]
302+
303+
common_resources = list(set(request["resource"]).intersection(set(permitted_resources)))
304+
if not common_resources:
305+
return oauth2.AuthorizationErrorResponse(
306+
error="invalid_target",
307+
error_description=f"Invalid resource requested by client {client_id}",
308+
)
309+
310+
common_resources = [r for r in common_resources if r in context.cdb.keys()]
311+
if not common_resources:
312+
return oauth2.AuthorizationErrorResponse(
313+
error="invalid_target",
314+
error_description=f"Invalid resource requested by client {client_id}",
315+
)
316+
317+
if client_id not in common_resources:
318+
common_resources.append(client_id)
319+
320+
request["resource"] = common_resources
321+
322+
permitted_scopes = [context.cdb[r]["allowed_scopes"] for r in common_resources]
323+
permitted_scopes = [r for res in permitted_scopes for r in res]
324+
scopes = list(set(request.get("scope", [])).intersection(set(permitted_scopes)))
325+
request["scope"] = scopes
326+
return request
327+
328+
280329
class Authorization(Endpoint):
281330
request_cls = oauth2.AuthorizationRequest
282331
response_cls = oauth2.AuthorizationResponse
@@ -304,6 +353,8 @@ class Authorization(Endpoint):
304353

305354
def __init__(self, server_get, **kwargs):
306355
Endpoint.__init__(self, server_get, **kwargs)
356+
357+
self.resource_indicators_config = kwargs.get("resource_indicators", None)
307358
self.post_parse_request.append(self._do_request_uri)
308359
self.post_parse_request.append(self._post_parse_request)
309360
self.allowed_request_algorithms = AllowedAlgorithms(ALG_PARAMS)
@@ -461,8 +512,45 @@ def _post_parse_request(self, request, client_id, endpoint_context, **kwargs):
461512
else:
462513
request["redirect_uri"] = redirect_uri
463514

515+
if ("resource_indicators" in _cinfo
516+
and "authorization_code" in _cinfo["resource_indicators"]):
517+
resource_indicators_config = _cinfo["resource_indicators"]["authorization_code"]
518+
else:
519+
resource_indicators_config = self.resource_indicators_config
520+
521+
if resource_indicators_config is not None:
522+
if "policy" not in resource_indicators_config:
523+
policy = {"policy": {"callable": validate_resource_indicators_policy}}
524+
resource_indicators_config.update(policy)
525+
request = self._enforce_resource_indicators_policy(request, resource_indicators_config)
526+
464527
return request
465528

529+
def _enforce_resource_indicators_policy(self, request, config):
530+
_context = self.server_get("endpoint_context")
531+
532+
policy = config["policy"]
533+
callable = policy["callable"]
534+
kwargs = policy.get("kwargs", {})
535+
536+
if kwargs.get("resource_servers_per_client", None) is None:
537+
kwargs["resource_servers_per_client"] = {
538+
request["client_id"]: request["client_id"]
539+
}
540+
541+
if isinstance(callable, str):
542+
try:
543+
fn = importer(callable)
544+
except Exception:
545+
raise ImproperlyConfigured(f"Error importing {callable} policy callable")
546+
else:
547+
fn = callable
548+
try:
549+
return fn(request, context=_context, **kwargs)
550+
except Exception as e:
551+
logger.error(f"Error while executing the {fn} policy callable: {e}")
552+
return self.error_cls(error="server_error", error_description="Internal server error")
553+
466554
def pick_authn_method(self, request, redirect_uri, acr=None, **kwargs):
467555
_context = self.server_get("endpoint_context")
468556
auth_id = kwargs.get("auth_method_id")
@@ -750,10 +838,17 @@ def create_authn_response(self, request: Union[dict, Message], sid: str) -> dict
750838
_mngr = _context.session_manager
751839
_sinfo = _mngr.get_session_info(sid, grant=True)
752840

841+
scope = []
842+
resource_scopes = []
753843
if request.get("scope"):
754-
aresp["scope"] = _context.scopes_handler.filter_scopes(
755-
request["scope"], _sinfo["client_id"]
756-
)
844+
scope = request.get("scope")
845+
if request.get("resource"):
846+
resource_scopes = [_context.cdb[s]["scope"] for s in request.get("resource") if s in _context.cdb.keys() and _context.cdb[s].get("scope")]
847+
resource_scopes = [item for sublist in resource_scopes for item in sublist]
848+
849+
aresp["scope"] = _context.scopes_handler.filter_scopes(
850+
list(set(scope+resource_scopes)), _sinfo["client_id"]
851+
)
757852

758853
rtype = set(request["response_type"][:])
759854
handled_response_type = []

src/idpyoidc/server/oauth2/token_helper.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,54 @@ def _mint_token(
102102

103103
return token
104104

105+
def validate_resource_indicators_policy(request, context, **kwargs):
106+
if "resource" not in request:
107+
return TokenErrorResponse(
108+
error="invalid_target",
109+
error_description="Missing resource parameter",
110+
)
111+
112+
resource_servers_per_client = kwargs["resource_servers_per_client"]
113+
client_id = request["client_id"]
114+
115+
resource_servers_per_client = kwargs.get("resource_servers_per_client", None)
116+
117+
if isinstance(resource_servers_per_client, dict) and client_id not in resource_servers_per_client:
118+
return TokenErrorResponse(
119+
error="invalid_target",
120+
error_description=f"Resources for client {client_id} not found",
121+
)
122+
123+
if isinstance(resource_servers_per_client, dict):
124+
permitted_resources = [res for res in resource_servers_per_client[client_id]]
125+
else:
126+
permitted_resources = [res for res in resource_servers_per_client]
127+
128+
common_resources = list(set(request["resource"]).intersection(set(permitted_resources)))
129+
if not common_resources:
130+
return TokenErrorResponse(
131+
error="invalid_target",
132+
error_description=f"Invalid resource requested by client {client_id}",
133+
)
134+
135+
common_resources = [r for r in common_resources if r in context.cdb.keys()]
136+
if not common_resources:
137+
return TokenErrorResponse(
138+
error="invalid_target",
139+
error_description=f"Invalid resource requested by client {client_id}",
140+
)
141+
142+
if client_id not in common_resources:
143+
common_resources.append(client_id)
144+
145+
request["resource"] = common_resources
146+
147+
permitted_scopes = [context.cdb[r]["allowed_scopes"] for r in common_resources]
148+
permitted_scopes = [r for res in permitted_scopes for r in res]
149+
scopes = list(set(request.get("scope", [])).intersection(set(permitted_scopes)))
150+
request["scope"] = scopes
151+
return request
152+
105153

106154
class AccessTokenHelper(TokenEndpointHelper):
107155
def process_request(self, req: Union[Message, dict], **kwargs):
@@ -132,6 +180,24 @@ def process_request(self, req: Union[Message, dict], **kwargs):
132180
logger.warning("Client using token it was not given")
133181
return self.error_cls(error="invalid_grant", error_description="Wrong client")
134182

183+
_cinfo = self.endpoint.server_get("endpoint_context").cdb.get(client_id)
184+
185+
if ("resource_indicators" in _cinfo
186+
and "access_token" in _cinfo["resource_indicators"]):
187+
resource_indicators_config = _cinfo["resource_indicators"]["access_token"]
188+
else:
189+
resource_indicators_config = self.endpoint.kwargs.get("resource_indicators", None)
190+
191+
if resource_indicators_config is not None:
192+
if "policy" not in resource_indicators_config:
193+
policy = {"policy": {"callable": validate_resource_indicators_policy}}
194+
resource_indicators_config.update(policy)
195+
196+
req = self._enforce_resource_indicators_policy(req, resource_indicators_config)
197+
198+
if isinstance(req, TokenErrorResponse):
199+
return req
200+
135201
if "grant_types_supported" in _context.cdb[client_id]:
136202
grant_types_supported = _context.cdb[client_id].get("grant_types_supported")
137203
else:
@@ -154,19 +220,33 @@ def process_request(self, req: Union[Message, dict], **kwargs):
154220
logger.debug("All checks OK")
155221

156222
issue_refresh = kwargs.get("issue_refresh", False)
223+
224+
if resource_indicators_config is not None:
225+
scope = req["scope"]
226+
else:
227+
scope = grant.scope
228+
157229
_response = {
158230
"token_type": "Bearer",
159-
"scope": grant.scope,
231+
"scope": scope,
160232
}
161233

162234
if "access_token" in _supports_minting:
235+
236+
resources = req.get("resource", None)
237+
if resources:
238+
token_args = {"resources": resources}
239+
else:
240+
token_args = None
241+
163242
try:
164243
token = self._mint_token(
165244
token_class="access_token",
166245
grant=grant,
167246
session_id=_session_info["branch_id"],
168247
client_id=_session_info["client_id"],
169248
based_on=_based_on,
249+
token_args=token_args
170250
)
171251
except MintingNotAllowed as err:
172252
logger.warning(err)
@@ -200,6 +280,26 @@ def process_request(self, req: Union[Message, dict], **kwargs):
200280

201281
return _response
202282

283+
def _enforce_resource_indicators_policy(self, request, config):
284+
_context = self.endpoint.server_get("endpoint_context")
285+
286+
policy = config["policy"]
287+
callable = policy["callable"]
288+
kwargs = policy.get("kwargs", {})
289+
290+
if isinstance(callable, str):
291+
try:
292+
fn = importer(callable)
293+
except Exception:
294+
raise ImproperlyConfigured(f"Error importing {callable} policy callable")
295+
else:
296+
fn = callable
297+
try:
298+
return fn(request, context=_context, **kwargs)
299+
except Exception as e:
300+
logger.error(f"Error while executing the {fn} policy callable: {e}")
301+
return self.error_cls(error="server_error", error_description="Internal server error")
302+
203303
def post_parse_request(
204304
self, request: Union[Message, dict], client_id: Optional[str] = "", **kwargs
205305
):

0 commit comments

Comments
 (0)