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

Commit 168e67a

Browse files
committed
Move token exchange to oauth2
1 parent 9e03268 commit 168e67a

File tree

7 files changed

+446
-489
lines changed

7 files changed

+446
-489
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

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

0 commit comments

Comments
 (0)