def auth_state(MockAuthClient, config, monkeypatch): # Mock the introspection first because that gets called as soon as we create # a TokenChecker client = MockAuthClient.return_value client.oauth2_token_introspect.return_value = ( canned_responses.introspect_response()() ) # Mock the dependent_tokens and list_groups functions bc they get used when # creating a GroupsClient client.oauth2_get_dependent_tokens.return_value = ( canned_responses.dependent_token_response()() ) monkeypatch.setattr(GroupsClient, "list_groups", canned_responses.groups_response()) # Create a TokenChecker to be used to create a mocked auth_state object checker = TokenChecker( client_id=config["client_id"], client_secret=config["client_secret"], expected_scopes=config["expected_scopes"], expected_audience=config["expected_audience"], ) auth_state = checker.check_token("NOT_A_TOKEN") # Reset the call count because check_token implicitly calls oauth2_token_introspect client.oauth2_token_introspect.call_count = 0 # Mock out this AuthState instance's GroupClient # auth_state._groups_client = GroupsClient(authorizer=None) # auth_state._groups_client.list_groups = canned_responses.groups_response() return auth_state
def test_token_checker_bad_credentials(): with pytest.raises(ConfigurationError): TokenChecker( client_id="bogus", client_secret="bogus", expected_scopes=("fakescope", ), )
def check_token(request: Request, checker: TokenChecker) -> AuthState: """ Parses a Flask request to extract its bearer token. """ access_token = request.headers.get("Authorization", "").strip().lstrip("Bearer ") auth_state = checker.check_token(access_token) return auth_state
def _create_token_checker(self, setup_state: blueprints.BlueprintSetupState): app = setup_state.app provider_prefix = self.name.upper() + "_" client_id = app.config.get(provider_prefix + "CLIENT_ID") client_secret = app.config.get(provider_prefix + "CLIENT_SECRET") if not client_id or not client_secret: client_id = app.config.get("CLIENT_ID") client_secret = app.config.get("CLIENT_SECRET") scopes = [self.provider_description.globus_auth_scope] scopes.extend(self.additional_scopes) self.checker = TokenChecker( client_id=client_id, client_secret=client_secret, expected_scopes=scopes, expected_audience=self.globus_auth_client_name, )
# Flask setup app = Flask(__name__) app.config.from_mapping(**CONFIG) app.url_map.strict_slashes = False # Logging setup logging.config.dictConfig(CONFIG["LOGGING"]) logger = logging.getLogger(__name__) logger.info("\n\n==========CFDE Action Provider started==========\n") # Globals specific to this instance TBL = CONFIG["DYNAMO_TABLE"] ROOT = "/" # Segregate different APs by root path? TOKEN_CHECKER = TokenChecker(CONFIG["GLOBUS_CC_APP"], CONFIG["GLOBUS_SECRET"], [CONFIG["GLOBUS_SCOPE"]], CONFIG["GLOBUS_AUD"]) # Clean up environment utils.clean_environment() utils.initialize_dmo_table(CONFIG["DYNAMO_TABLE"]) ####################################### # Flask helpers ####################################### @app.errorhandler(err.ApiError) def handle_invalid_usage(error): response = jsonify(error.to_dict()) response.status_code = error.status return response
def add_action_routes_to_blueprint( blueprint: flask.Blueprint, client_id: str, client_secret: str, client_name: Optional[str], provider_description: ActionProviderDescription, action_run_callback: ActionRunType, action_status_callback: ActionStatusType, action_cancel_callback: ActionCancelType, action_release_callback: ActionReleaseType, action_log_callback: Optional[ActionLogType] = None, additional_scopes: Optional[List[str]] = None, action_enumeration_callback: ActionEnumerationType = None, ) -> None: """Add routes to a Flask Blueprint to implement the required operations of the Action Provider Interface: Introspect, Run, Status, Cancel and Release. The route handlers added to the blueprint perform basic functionality such as input validation and authorization checks where appropriate, and use the provided callbacks to implement the action provider specific functionality. See description of each callback below for a description of functionality performed prior to invoking the callback. **Parameters** ``blueprint`` (*Flask.Blueprint*) A flask blueprint to which routes for the URL paths '/', '/run', '/status', '/cancel', and '/release' will be added. Optionally, (see below) '/log' will be added as well. The blueprint should define a ``url_prefix`` to define a root to the paths where these new paths will be added. In addition to the new URL paths, the blueprint will also have a custom JSONEncoder associated with it to aid in the serialization of data-types associated with these operations. ``client_id`` (*string*) A Globus Auth registered ``client_id`` which will be used when validating input request tokens. ``client_secret`` (*string*) A Globus Auth generated ``client_secret`` which will be used when validating input request tokens. ``client_name`` (*string*) Most commonly, this will be a None value. In the rare, legacy case where a name has been associated with a client_id, it can be provided here. If you are not aware of a name associated with your client_id, it most likely doesn't have one and the value should be None. This will be passed to the (:class:`TokenChecker<globus_action_provider_tools.authentication>`) as the `expected_audience`. ``provider_description`` (:class:`ActionProviderDescription\ <globus_action_provider_tools.data_types>`) A structure describing the provider to be returned by the provider introspection operation (`GET /`). Some fields are also used in processing requests: the `input_schema` field is used to validate the `body` of incoming action requests on the `/run` operation. The `globus_auth_scope` value is used to validate the incoming tokens on all requests. The `visible_to` and `runnable_by` lists are used to authorization operations on the introspect (GET '/') and run (POST '/run') operations respectively. The `log_supported` field should be `True` only if the `action_log_callback` parameter is provided a value. ``action_run_callback`` (* Callable[[ActionRequest, AuthState], Union[ActionStatus, Tuple[ActionStatus, int]]] *) A function which will be called when an action /run invocation is called. Prior to invoking the callback, the handler will validate the input conforms to the Action Provider defined request format *and* that the input `body` matches the `input_schema` defined in the `provider_description`. It will also authorize the caller against the `runnable_by` property of the `provider_description`. In the case of any validation or authorization errors, the corresponding werkzeug defined exception will be raised. When validation and authorization succeed, the callback will be invoked providing the `ActionRequest` structure corresponding to the request and the authorization state (`AuthState`) of the caller. The callback should return an `ActionStatus` value to be returned on the invocation. Optionally, a status integer can be added to the return (making the return a (ActionStatus, int) tuple) which defines the HTTP status code to be returned. This is useful in the case where an existing request with the same id and body are seen which should return a 200 HTTP status rather than the normal 201 HTTP status (which is the default when the status code is not returned). """ if additional_scopes: all_accepted_scopes = additional_scopes + [ provider_description.globus_auth_scope ] else: all_accepted_scopes = [provider_description.globus_auth_scope] checker = TokenChecker( client_id=client_id, client_secret=client_secret, expected_scopes=all_accepted_scopes, expected_audience=client_name, ) blueprint.json_encoder = ActionProviderJsonEncoder input_body_validator = get_input_body_validator(provider_description) blueprint.register_error_handler(Exception, blueprint_error_handler) @blueprint.route("/", methods=["GET"], strict_slashes=False) def action_introspect() -> ViewReturn: auth_state = check_token(request, checker) if not auth_state.check_authorization( provider_description.visible_to, allow_public=True, allow_all_authenticated_users=True, ): raise ActionNotFound return jsonify(provider_description), 200 @blueprint.route("/actions", methods=["POST"]) @blueprint.route("/run", methods=["POST"]) def action_run() -> ViewReturn: auth_state = check_token(request, checker) if not auth_state.check_authorization( provider_description.runnable_by, allow_all_authenticated_users=True ): log.info(f"Unauthorized call to action run, errors: {auth_state.errors}") raise UnauthorizedRequest if blueprint.url_prefix: request.path = request.path.lstrip(blueprint.url_prefix) if request.url_rule is not None: request.url_rule.rule = request.url_rule.rule.lstrip( blueprint.url_prefix ) action_request = validate_input( request.get_json(force=True), input_body_validator ) # It's possible the user will attempt to make a malformed ActionStatus - # pydantic won't like that. So log and handle the error with a 500 try: status = action_run_callback(action_request, auth_state) except ValidationError as ve: log.error( f"ActionProvider attempted to create a non-conformant ActionStatus" f" in {action_run_callback.__name__}: {ve.errors()}" ) raise ActionProviderError return action_status_return_to_view_return(status, 202) @blueprint.route("/<string:action_id>/status", methods=["GET"]) @blueprint.route("/actions/<string:action_id>", methods=["GET"]) def action_status(action_id: str) -> ViewReturn: auth_state = check_token(request, checker) try: status = action_status_callback(action_id, auth_state) except ValidationError as ve: log.error( f"ActionProvider attempted to create a non-conformant ActionStatus" f" in {action_status_callback.__name__}: {ve.errors()}" ) raise ActionProviderError return action_status_return_to_view_return(status, 200) @blueprint.route("/<string:action_id>/cancel", methods=["POST"]) @blueprint.route("/actions/<string:action_id>/cancel", methods=["POST"]) def action_cancel(action_id: str) -> ViewReturn: auth_state = check_token(request, checker) try: status = action_cancel_callback(action_id, auth_state) except ValidationError as ve: log.error( f"ActionProvider attempted to create a non-conformant ActionStatus" f" in {action_cancel_callback.__name__}: {ve.errors()}" ) raise ActionProviderError return action_status_return_to_view_return(status, 200) @blueprint.route("/<string:action_id>/release", methods=["POST"]) @blueprint.route("/actions/<string:action_id>", methods=["DELETE"]) def action_release(action_id: str) -> ViewReturn: auth_state = check_token(request, checker) try: status = action_release_callback(action_id, auth_state) except ValidationError as ve: log.error( f"ActionProvider attempted to create a non-conformant ActionStatus" f" in {action_release_callback.__name__}: {ve.errors()}" ) raise ActionProviderError return action_status_return_to_view_return(status, 200) if action_log_callback is not None: @blueprint.route("/actions/<string:action_id>/log", methods=["GET"]) @blueprint.route("/<string:action_id>/log", methods=["GET"]) def action_log(action_id: str) -> ViewReturn: auth_state = check_token(request, checker) return jsonify({"log": "message"}), 200 if action_enumeration_callback is not None: @blueprint.route("/actions", methods=["GET"]) def action_enumeration(): auth_state = check_token(request, checker) valid_statuses = set(e.name.casefold() for e in ActionStatusValue) statuses = parse_query_args( request, arg_name="status", default_value="active", valid_vals=valid_statuses, ) statuses = query_args_to_enum(statuses, ActionStatusValue) roles = parse_query_args( request, arg_name="roles", default_value="creator_id", valid_vals={"creator_id", "monitor_by", "manage_by"}, ) query_params = {"statuses": statuses, "roles": roles} return jsonify(action_enumeration_callback(auth_state, query_params)), 200
from globus_action_provider_tools.authentication import TokenChecker from globus_action_provider_tools.authorization import ( authorize_action_access_or_404, authorize_action_management_or_404, ) from globus_action_provider_tools.data_types import ( ActionProviderDescription, ActionProviderJsonEncoder, ActionStatus, ActionStatusValue, ) from globus_action_provider_tools.flask import flask_validate_request app = Flask(__name__) token_checker = TokenChecker(config.client_id, config.client_secret, [config.our_scope], config.token_audience) COMPLETE_STATES = (ActionStatusValue.SUCCEEDED, ActionStatusValue.FAILED) INCOMPLETE_STATES = (ActionStatusValue.ACTIVE, ActionStatusValue.INACTIVE) with open( os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.json")) as f: schema = json.load(f) app.json_encoder = ActionProviderJsonEncoder @app.errorhandler(err.ApiError) def handle_invalid_usage(error) -> Response: response = jsonify(error.to_dict())