Skip to content
This repository was archived by the owner on Jun 23, 2023. It is now read-only.

Commit 43f6256

Browse files
committed
Move token exchange to oauth2
1 parent 9e03268 commit 43f6256

File tree

7 files changed

+447
-488
lines changed

7 files changed

+447
-488
lines changed

docs/source/contents/conf.rst

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -670,14 +670,21 @@ There are two possible ways to configure Token Exchange in OIDC-OP, globally and
670670
For the first case the configuration is passed in the Token Exchange handler throught the
671671
`urn:ietf:params:oauth:grant-type:token-exchange` dictionary in token's `grant_types_supported`.
672672

673-
If present, the token exchange configuration may contain a `policy` object that describes a default
674-
policy `callable` and its `kwargs` through the `""` key. Different callables can be optionally
675-
defined for each token type supported.
673+
If present, the token exchange configuration may contain a `policy` dictionary
674+
that defines the behaviour for each subject token type. Each subject token type
675+
is mapped to a dictionary with the keys `callable` (mandatory), which must be a
676+
python callable or a string that represents the path to a python callable, and
677+
`kwargs` (optional), which must be a dict of key-value arguments that will be
678+
passed to the callable.
679+
680+
The key `""` represents a fallback policy that will be used if the subject token
681+
type can't be found. If a subject token type is defined in the `policy` but is
682+
not in the `subject_token_types_supported` list then it is ignored.
676683

677684
```
678685
"grant_types_supported":{
679686
"urn:ietf:params:oauth:grant-type:token-exchange": {
680-
"class": "oidcop.oidc.token.TokenExchangeHelper",
687+
"class": "oidcop.oauth2.token.TokenExchangeHelper",
681688
"kwargs": {
682689
"subject_token_types_supported": [
683690
"urn:ietf:params:oauth:token-type:access_token",

src/oidcop/oauth2/token.py

Lines changed: 275 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,27 @@
22
from typing import Optional
33
from typing import Union
44

5+
from cryptojwt.exception import JWKESTException
56
from cryptojwt.jwe.exception import JWEException
7+
from oidcmsg.exception import MissingRequiredAttribute
8+
from oidcmsg.exception import MissingRequiredValue
69
from oidcmsg.message import Message
710
from oidcmsg.oauth2 import AccessTokenResponse
811
from oidcmsg.oauth2 import ResponseMessage
12+
from oidcmsg.oauth2 import TokenExchangeRequest
13+
from oidcmsg.oauth2 import TokenExchangeResponse
914
from oidcmsg.oidc import RefreshAccessTokenRequest
1015
from oidcmsg.oidc import TokenErrorResponse
1116
from oidcmsg.time_util import utc_time_sans_frac
1217

1318
from oidcop import sanitize
1419
from oidcop.constant import DEFAULT_TOKEN_LIFETIME
1520
from oidcop.endpoint import Endpoint
21+
from oidcop.exception import ImproperlyConfigured
1622
from oidcop.exception import ProcessError
23+
from oidcop.exception import ToOld
24+
from oidcop.exception import UnAuthorizedClientScope
25+
from oidcop.oauth2.authorization import check_unknown_scopes_policy
1726
from oidcop.session.grant import AuthorizationCode
1827
from oidcop.session.grant import Grant
1928
from oidcop.session.grant import RefreshToken
@@ -248,7 +257,6 @@ def process_request(self, req: Union[Message, dict], **kwargs):
248257
_grant = _session_info["grant"]
249258

250259
token_type = "Bearer"
251-
252260
# Is DPOP supported
253261
if "dpop_signing_alg_values_supported" in _context.provider_info:
254262
_dpop_jkt = req.get("dpop_jkt")
@@ -359,6 +367,272 @@ def post_parse_request(
359367
return request
360368

361369

370+
class TokenExchangeHelper(TokenEndpointHelper):
371+
"""Implements Token Exchange a.k.a. RFC8693"""
372+
373+
token_types_mapping = {
374+
"urn:ietf:params:oauth:token-type:access_token": "access_token",
375+
"urn:ietf:params:oauth:token-type:refresh_token": "refresh_token",
376+
}
377+
378+
def __init__(self, endpoint, config=None):
379+
TokenEndpointHelper.__init__(self, endpoint=endpoint, config=config)
380+
if config is None:
381+
self.config = {
382+
"subject_token_types_supported": [
383+
"urn:ietf:params:oauth:token-type:access_token",
384+
"urn:ietf:params:oauth:token-type:refresh_token",
385+
],
386+
"requested_token_types_supported": [
387+
"urn:ietf:params:oauth:token-type:access_token",
388+
"urn:ietf:params:oauth:token-type:refresh_token",
389+
],
390+
"policy": {
391+
"": {"callable": default_token_exchange_policy, "kwargs": {"scope": ["openid"]}}
392+
},
393+
}
394+
else:
395+
self.config = config
396+
397+
def post_parse_request(self, request, client_id="", **kwargs):
398+
request = TokenExchangeRequest(**request.to_dict())
399+
400+
_context = self.endpoint.server_get("endpoint_context")
401+
if "token_exchange" in _context.cdb[request["client_id"]]:
402+
config = _context.cdb[request["client_id"]]["token_exchange"]
403+
else:
404+
config = self.config
405+
406+
try:
407+
keyjar = _context.keyjar
408+
except AttributeError:
409+
keyjar = ""
410+
411+
try:
412+
request.verify(keyjar=keyjar, opponent_id=client_id)
413+
except (
414+
MissingRequiredAttribute,
415+
ValueError,
416+
MissingRequiredValue,
417+
JWKESTException,
418+
) as err:
419+
return self.endpoint.error_cls(error="invalid_request", error_description="%s" % err)
420+
421+
_mngr = _context.session_manager
422+
try:
423+
_session_info = _mngr.get_session_info_by_token(request["subject_token"], grant=True)
424+
except (KeyError, UnknownToken):
425+
logger.error("Subject token invalid.")
426+
return self.error_cls(
427+
error="invalid_request", error_description="Subject token invalid"
428+
)
429+
430+
token = _mngr.find_token(_session_info["session_id"], request["subject_token"])
431+
if token.is_active() is False:
432+
return self.error_cls(
433+
error="invalid_request", error_description="Subject token inactive"
434+
)
435+
436+
resp = self._enforce_policy(request, token, config)
437+
438+
return resp
439+
440+
def _enforce_policy(self, request, token, config):
441+
_context = self.endpoint.server_get("endpoint_context")
442+
subject_token_types_supported = config.get(
443+
"subject_token_types_supported", self.token_types_mapping.keys()
444+
)
445+
subject_token_type = request["subject_token_type"]
446+
if subject_token_type not in subject_token_types_supported:
447+
return TokenErrorResponse(
448+
error="invalid_request",
449+
error_description="Unsupported subject token type",
450+
)
451+
if self.token_types_mapping[subject_token_type] != token.token_class:
452+
return TokenErrorResponse(
453+
error="invalid_request",
454+
error_description="Wrong token type",
455+
)
456+
457+
if (
458+
"requested_token_type" in request
459+
and request["requested_token_type"] not in config["requested_token_types_supported"]
460+
):
461+
return TokenErrorResponse(
462+
error="invalid_request",
463+
error_description="Unsupported requested token type",
464+
)
465+
466+
request_info = dict(scope=request.get("scope", []))
467+
try:
468+
check_unknown_scopes_policy(request_info, request["client_id"], _context)
469+
except UnAuthorizedClientScope:
470+
return self.error_cls(
471+
error="invalid_grant",
472+
error_description="Unauthorized scope requested",
473+
)
474+
475+
subject_token_type = request["subject_token_type"]
476+
if subject_token_type not in config["policy"]:
477+
if "" not in config["policy"]:
478+
raise ImproperlyConfigured(
479+
"subject_token_type {subject_token_type} missing from "
480+
"policy and no default is defined"
481+
)
482+
subject_token_type = ""
483+
484+
policy = config["policy"][subject_token_type]
485+
callable = policy["callable"]
486+
kwargs = policy["kwargs"]
487+
488+
if isinstance(callable, str):
489+
try:
490+
fn = importer(callable)
491+
except Exception:
492+
raise ImproperlyConfigured(f"Error importing {callable} policy callable")
493+
else:
494+
fn = callable
495+
496+
try:
497+
return fn(request, context=_context, subject_token=token, **kwargs)
498+
except Exception as e:
499+
logger.error(f"Error while executing the {fn} policy callable: {e}")
500+
return self.error_cls(error="server_error", error_description="Internal server error")
501+
502+
def token_exchange_response(self, token):
503+
response_args = {}
504+
response_args["access_token"] = token.value
505+
response_args["scope"] = token.scope
506+
response_args["issued_token_type"] = token.token_class
507+
response_args["expires_in"] = token.usage_rules.get("expires_in", 0)
508+
if hasattr(token, "token_type"):
509+
response_args["token_type"] = token.token_type
510+
else:
511+
response_args["token_type"] = "N_A"
512+
513+
return TokenExchangeResponse(**response_args)
514+
515+
def process_request(self, request, **kwargs):
516+
_context = self.endpoint.server_get("endpoint_context")
517+
_mngr = _context.session_manager
518+
try:
519+
_session_info = _mngr.get_session_info_by_token(request["subject_token"], grant=True)
520+
except ToOld:
521+
logger.error("Subject token has expired.")
522+
return self.error_cls(
523+
error="invalid_request", error_description="Subject token has expired"
524+
)
525+
except (KeyError, UnknownToken):
526+
logger.error("Subject token invalid.")
527+
return self.error_cls(
528+
error="invalid_request", error_description="Subject token invalid"
529+
)
530+
531+
token = _mngr.find_token(_session_info["session_id"], request["subject_token"])
532+
_requested_token_type = request.get(
533+
"requested_token_type", "urn:ietf:params:oauth:token-type:access_token"
534+
)
535+
536+
_token_class = self.token_types_mapping[_requested_token_type]
537+
538+
sid = _session_info["session_id"]
539+
540+
_token_type = "Bearer"
541+
# Is DPOP supported
542+
if "dpop_signing_alg_values_supported" in _context.provider_info:
543+
if request.get("dpop_jkt"):
544+
_token_type = "DPoP"
545+
546+
if request["client_id"] != _session_info["client_id"]:
547+
_token_usage_rules = _context.authz.usage_rules(request["client_id"])
548+
549+
sid = _mngr.create_exchange_session(
550+
exchange_request=request,
551+
original_session_id=sid,
552+
user_id=_session_info["user_id"],
553+
client_id=request["client_id"],
554+
token_usage_rules=_token_usage_rules,
555+
)
556+
557+
try:
558+
_session_info = _mngr.get_session_info(session_id=sid, grant=True)
559+
except Exception:
560+
logger.error("Error retrieving token exchange session information")
561+
return self.error_cls(
562+
error="server_error", error_description="Internal server error"
563+
)
564+
565+
resources = request.get("resource")
566+
if resources and request.get("audience"):
567+
resources = list(set(resources + request.get("audience")))
568+
else:
569+
resources = request.get("audience")
570+
571+
try:
572+
new_token = self._mint_token(
573+
token_class=_token_class,
574+
grant=_session_info["grant"],
575+
session_id=sid,
576+
client_id=request["client_id"],
577+
based_on=token,
578+
scope=request.get("scope"),
579+
token_args={
580+
"resources": resources,
581+
},
582+
token_type=_token_type,
583+
)
584+
except MintingNotAllowed:
585+
logger.error(f"Minting not allowed for {_token_class}")
586+
return self.error_cls(
587+
error="invalid_grant",
588+
error_description="Token Exchange not allowed with that token",
589+
)
590+
591+
return self.token_exchange_response(token=new_token)
592+
593+
594+
def default_token_exchange_policy(request, context, subject_token, **kwargs):
595+
if "resource" in request:
596+
resource = kwargs.get("resource", [])
597+
if not len(set(request["resource"]).intersection(set(resource))):
598+
return TokenErrorResponse(error="invalid_target", error_description="Unknown resource")
599+
600+
if "audience" in request:
601+
if request["subject_token_type"] == "urn:ietf:params:oauth:token-type:refresh_token":
602+
return TokenErrorResponse(
603+
error="invalid_target", error_description="Refresh token has single owner"
604+
)
605+
audience = kwargs.get("audience", [])
606+
if audience and not len(set(request["audience"]).intersection(set(audience))):
607+
return TokenErrorResponse(error="invalid_target", error_description="Unknown audience")
608+
609+
if "actor_token" in request or "actor_token_type" in request:
610+
return TokenErrorResponse(
611+
error="invalid_request", error_description="Actor token not supported"
612+
)
613+
614+
if (
615+
"requested_token_type" in request
616+
and request["requested_token_type"] == "urn:ietf:params:oauth:token-type:refresh_token"
617+
):
618+
if "offline_access" not in subject_token.scope:
619+
return TokenErrorResponse(
620+
error="invalid_request",
621+
error_description=f"Exchange {request['subject_token_type']} to refresh token forbbiden",
622+
)
623+
624+
if "scope" in request:
625+
scopes = list(set(request.get("scope")).intersection(kwargs.get("scope")))
626+
if scopes:
627+
request["scope"] = scopes
628+
else:
629+
return TokenErrorResponse(
630+
error="invalid_request",
631+
error_description="No supported scope requested",
632+
)
633+
634+
return request
635+
362636
class Token(Endpoint):
363637
request_cls = Message
364638
response_cls = AccessTokenResponse

0 commit comments

Comments
 (0)