Skip to content

Commit 3ae0341

Browse files
committed
Introduce resource indicators
Signed-off-by: Kostis Triantafyllakis <ctriant@admin.grnet.gr>
1 parent e42abb8 commit 3ae0341

31 files changed

Lines changed: 1110 additions & 99 deletions

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/clients.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ See https://openid.net/specs/openid-connect-registration-1_0-29.html#ClientMetad
109109
scopes_to_claims
110110
----------------
111111

112-
A dict defining the scopes that are allowed to be used per client and the claims
112+
A dict defining the scopes that are allowed to be used and the claims
113113
they map to (defaults to the scopes mapping described in the spec). If we want
114114
to define a scope that doesn't map to claims (e.g. offline_access) then we
115115
simply map it to an empty list. E.g.::

doc/server/contents/conf.rst

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,3 +837,80 @@ For example::
837837

838838
return request
839839

840+
841+
==============
842+
Resource Indicators
843+
==============
844+
There are two possible ways to configure Resource Indicators in OIDC-OP, globally and per-client.
845+
For the first case the configuration is passed in the Authorization or Access Token endpoint arguments throught the
846+
`resource_indicators` dictionary.
847+
848+
If present, the resource indicators configuration should contain a `policy` dictionary
849+
that defines the behaviour of the specific endpoint. The policy
850+
is mapped to a dictionary with the keys `callable` (mandatory), which must be a
851+
python callable or a string that represents the path to a python callable, and
852+
`kwargs` (optional), which must be a dict of key-value arguments that will be
853+
passed to the callable.
854+
855+
The resource indicators configuration may also contain a `resource_servers_per_client`
856+
dictionary that defines a mapping between oidc-op registered clients with key the equivalent `client id` and resources to whom this client
857+
is eligible to request access. The legitimate resource that a client can access is represented by a dictionary with key the `resource id` and value
858+
a dictionary that respresents a mapping of the key `scopes` with a list of scopes that the specific client may request from the respective resource:
859+
860+
"resource_indicators": {
861+
"policy": {
862+
"callable": validate_authorization_resource_indicators_policy,
863+
"kwargs": {
864+
"resource_servers_per_client": {
865+
"CLIENT_1": {
866+
"RESOURCE_1": {
867+
"scopes": ["openid"]
868+
},
869+
"RESOURCE_2": {
870+
"scopes": ["openid", "profile"]
871+
},
872+
},
873+
},
874+
},
875+
}
876+
}
877+
878+
For the per-client configuration a similar configuration scheme should be present in the client's
879+
metadata under the `resource_indicators` key with slight difference. The `policy` mapping should be set a value for a
880+
key `authorization_code` or `access_token` in order to indicate the endpoint that this resource indicators policy is reffered to.
881+
882+
For example::
883+
884+
"resource_indicators":{
885+
"authorization_code": {
886+
"policy": {
887+
"callable": validate_authorization_resource_indicators_policy,
888+
"kwargs": {
889+
"resource_servers_per_client": {
890+
"CLIENT_1": {
891+
"RESOURCE_1": {
892+
"scopes": ["openid"]
893+
},
894+
"RESOURCE_2": {
895+
"scopes": ["openid", "profile"]
896+
},
897+
},
898+
},
899+
},
900+
},
901+
},
902+
}
903+
904+
The policy callable accepts a specific argument list and must return the altered
905+
request or raise an exception.
906+
907+
For example::
908+
909+
def validate_resource_indicators_policy(request, context, **kwargs):
910+
if some_condition in request:
911+
return TokenErrorResponse(
912+
error="invalid_request", error_description="Some error occured"
913+
)
914+
915+
return request
916+

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
@@ -101,6 +101,54 @@ def _mint_token(
101101

102102
return token
103103

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

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

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

155221
issue_refresh = kwargs.get("issue_refresh", False)
222+
223+
if resource_indicators_config is not None:
224+
scope = req["scope"]
225+
else:
226+
scope = grant.scope
227+
156228
_response = {
157229
"token_type": "Bearer",
158-
"scope": grant.scope,
230+
"scope": scope,
159231
}
160232

161233
if "access_token" in _supports_minting:
234+
235+
resources = req.get("resource", None)
236+
if resources:
237+
token_args = {"resources": resources}
238+
else:
239+
token_args = None
240+
162241
try:
163242
token = self._mint_token(
164243
token_class="access_token",
165244
grant=grant,
166245
session_id=_session_info["session_id"],
167246
client_id=_session_info["client_id"],
168247
based_on=_based_on,
248+
token_args=token_args
169249
)
170250
except MintingNotAllowed as err:
171251
logger.warning(err)
@@ -199,6 +279,26 @@ def process_request(self, req: Union[Message, dict], **kwargs):
199279

200280
return _response
201281

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

0 commit comments

Comments
 (0)