| 
2 | 2 | from typing import Optional  | 
3 | 3 | from typing import Union  | 
4 | 4 | 
 
  | 
 | 5 | +from cryptojwt.exception import JWKESTException  | 
5 | 6 | from cryptojwt.jwe.exception import JWEException  | 
 | 7 | +from oidcmsg.exception import MissingRequiredAttribute  | 
 | 8 | +from oidcmsg.exception import MissingRequiredValue  | 
6 | 9 | from oidcmsg.message import Message  | 
7 | 10 | from oidcmsg.oauth2 import AccessTokenResponse  | 
8 | 11 | from oidcmsg.oauth2 import ResponseMessage  | 
 | 12 | +from oidcmsg.oauth2 import TokenExchangeRequest  | 
 | 13 | +from oidcmsg.oauth2 import TokenExchangeResponse  | 
9 | 14 | from oidcmsg.oidc import RefreshAccessTokenRequest  | 
10 | 15 | from oidcmsg.oidc import TokenErrorResponse  | 
11 | 16 | from oidcmsg.time_util import utc_time_sans_frac  | 
12 | 17 | 
 
  | 
13 | 18 | from oidcop import sanitize  | 
14 | 19 | from oidcop.constant import DEFAULT_TOKEN_LIFETIME  | 
15 | 20 | from oidcop.endpoint import Endpoint  | 
 | 21 | +from oidcop.exception import ImproperlyConfigured  | 
16 | 22 | 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  | 
17 | 26 | from oidcop.session.grant import AuthorizationCode  | 
18 | 27 | from oidcop.session.grant import Grant  | 
19 | 28 | from oidcop.session.grant import RefreshToken  | 
@@ -248,7 +257,6 @@ def process_request(self, req: Union[Message, dict], **kwargs):  | 
248 | 257 |         _grant = _session_info["grant"]  | 
249 | 258 | 
 
  | 
250 | 259 |         token_type = "Bearer"  | 
251 |  | - | 
252 | 260 |         # Is DPOP supported  | 
253 | 261 |         if "dpop_signing_alg_values_supported" in _context.provider_info:  | 
254 | 262 |             _dpop_jkt = req.get("dpop_jkt")  | 
@@ -359,6 +367,270 @@ def post_parse_request(  | 
359 | 367 |         return request  | 
360 | 368 | 
 
  | 
361 | 369 | 
 
  | 
 | 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 | + | 
362 | 634 | class Token(Endpoint):  | 
363 | 635 |     request_cls = Message  | 
364 | 636 |     response_cls = AccessTokenResponse  | 
 | 
0 commit comments