|  | 
| 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