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

Commit 30f269e

Browse files
committed
Move token exchange to oauth2
1 parent 9e03268 commit 30f269e

File tree

6 files changed

+317
-303
lines changed

6 files changed

+317
-303
lines changed

docs/source/contents/conf.rst

+11-4
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

+297-2
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,28 @@
33
from typing import Union
44

55
from cryptojwt.jwe.exception import JWEException
6+
from cryptojwt.exception import JWKESTException
67
from oidcmsg.message import Message
78
from oidcmsg.oauth2 import AccessTokenResponse
89
from oidcmsg.oauth2 import ResponseMessage
10+
from oidcmsg.oauth2 import (TokenExchangeRequest, TokenExchangeResponse)
11+
from oidcmsg.exception import MissingRequiredValue, MissingRequiredAttribute
912
from oidcmsg.oidc import RefreshAccessTokenRequest
1013
from oidcmsg.oidc import TokenErrorResponse
1114
from oidcmsg.time_util import utc_time_sans_frac
1215

1316
from oidcop import sanitize
1417
from oidcop.constant import DEFAULT_TOKEN_LIFETIME
1518
from oidcop.endpoint import Endpoint
16-
from oidcop.exception import ProcessError
19+
from oidcop.exception import ProcessError, UnAuthorizedClientScope, ToOld
1720
from oidcop.session.grant import AuthorizationCode
1821
from oidcop.session.grant import Grant
1922
from oidcop.session.grant import RefreshToken
2023
from oidcop.session.token import MintingNotAllowed
2124
from oidcop.session.token import SessionToken
2225
from oidcop.token.exception import UnknownToken
26+
from oidcop.exception import ImproperlyConfigured
27+
from oidcop.oauth2.authorization import check_unknown_scopes_policy
2328
from oidcop.util import importer
2429

2530
logger = logging.getLogger(__name__)
@@ -248,11 +253,11 @@ def process_request(self, req: Union[Message, dict], **kwargs):
248253
_grant = _session_info["grant"]
249254

250255
token_type = "Bearer"
251-
252256
# Is DPOP supported
253257
if "dpop_signing_alg_values_supported" in _context.provider_info:
254258
_dpop_jkt = req.get("dpop_jkt")
255259
if _dpop_jkt:
260+
_grant["extra"] = {}
256261
_grant.extra["dpop_jkt"] = _dpop_jkt
257262
token_type = "DPoP"
258263

@@ -358,6 +363,296 @@ def post_parse_request(
358363

359364
return request
360365

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

362657
class Token(Endpoint):
363658
request_cls = Message

0 commit comments

Comments
 (0)