From a4b5fd9f0ee478e5a050fd3cada80d6ff1a5b450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Thu, 16 Jul 2020 02:13:23 +0200 Subject: [PATCH 1/7] Add Google Authorization for experimental API --- airflow/api/__init__.py | 4 +- airflow/api/client/__init__.py | 8 +- airflow/api/client/api_client.py | 7 +- airflow/api/client/json_client.py | 6 +- airflow/config_templates/config.yml | 16 ++ airflow/config_templates/default_airflow.cfg | 11 + .../google/common/auth_backend/__init__.py | 16 ++ .../common/auth_backend/google_openid.py | 138 +++++++++++ .../providers/google/common/utils/__init__.py | 16 ++ .../common/utils/id_token_credentials.py | 216 ++++++++++++++++++ .../refactor_backport_packages.py | 7 + docs/conf.py | 39 ++-- docs/security.rst | 34 +++ tests/api/auth/test_client.py | 19 +- .../google/common/auth_backend/__init__.py | 16 ++ .../common/auth_backend/test_google_openid.py | 134 +++++++++++ .../providers/google/common/utils/__init__.py | 16 ++ .../common/utils/test_id_token_credentials.py | 162 +++++++++++++ 18 files changed, 839 insertions(+), 26 deletions(-) create mode 100644 airflow/providers/google/common/auth_backend/__init__.py create mode 100644 airflow/providers/google/common/auth_backend/google_openid.py create mode 100644 airflow/providers/google/common/utils/__init__.py create mode 100644 airflow/providers/google/common/utils/id_token_credentials.py create mode 100644 tests/providers/google/common/auth_backend/__init__.py create mode 100644 tests/providers/google/common/auth_backend/test_google_openid.py create mode 100644 tests/providers/google/common/utils/__init__.py create mode 100644 tests/providers/google/common/utils/test_id_token_credentials.py diff --git a/airflow/api/__init__.py b/airflow/api/__init__.py index ac1050946ac55..63dbfcff93283 100644 --- a/airflow/api/__init__.py +++ b/airflow/api/__init__.py @@ -34,7 +34,9 @@ def load_auth(): pass try: - return import_module(auth_backend) + auth_backend = import_module(auth_backend) + log.info("Loaded API auth backend: %s", auth_backend) + return auth_backend except ImportError as err: log.critical( "Cannot import %s for API authentication due to: %s", diff --git a/airflow/api/client/__init__.py b/airflow/api/client/__init__.py index 3f2e79e0d234f..829af59f8962f 100644 --- a/airflow/api/client/__init__.py +++ b/airflow/api/client/__init__.py @@ -31,8 +31,14 @@ def get_current_api_client() -> Client: Return current API Client based on current Airflow configuration """ api_module = import_module(conf.get('cli', 'api_client')) # type: Any + auth_backend = api.load_auth() + session = None + session_factory = getattr(auth_backend, 'create_client_session', None) + if session_factory: + session = session_factory() api_client = api_module.Client( api_base_url=conf.get('cli', 'endpoint_url'), - auth=api.load_auth().CLIENT_AUTH + auth=getattr(auth_backend, 'CLIENT_AUTH', None), + session=session ) return api_client diff --git a/airflow/api/client/api_client.py b/airflow/api/client/api_client.py index 575ab5dc10c1a..ac4d0748906da 100644 --- a/airflow/api/client/api_client.py +++ b/airflow/api/client/api_client.py @@ -16,14 +16,17 @@ # specific language governing permissions and limitations # under the License. """Client for all the API clients.""" +import requests class Client: """Base API client for all API clients.""" - def __init__(self, api_base_url, auth): + def __init__(self, api_base_url, auth=None, session=None): self._api_base_url = api_base_url - self._auth = auth + self._session: requests.Session = session or requests.Session() + if auth: + self._session.auth = auth def trigger_dag(self, dag_id, run_id=None, conf=None, execution_date=None): """Create a dag run for the specified dag. diff --git a/airflow/api/client/json_client.py b/airflow/api/client/json_client.py index 0b0ff8b046ed9..356017b5e3bed 100644 --- a/airflow/api/client/json_client.py +++ b/airflow/api/client/json_client.py @@ -19,8 +19,6 @@ from urllib.parse import urljoin -import requests - from airflow.api.client import api_client @@ -30,12 +28,10 @@ class Client(api_client.Client): def _request(self, url, method='GET', json=None): params = { 'url': url, - 'auth': self._auth, } if json is not None: params['json'] = json - - resp = getattr(requests, method.lower())(**params) # pylint: disable=not-callable + resp = getattr(self._session, method.lower())(**params) # pylint: disable=not-callable if not resp.ok: # It is justified here because there might be many resp types. # noinspection PyBroadException diff --git a/airflow/config_templates/config.yml b/airflow/config_templates/config.yml index 243d60c188b7d..4c84f1f3cbffb 100644 --- a/airflow/config_templates/config.yml +++ b/airflow/config_templates/config.yml @@ -598,6 +598,22 @@ type: integer example: ~ default: "100" + - name: google_oauth2_audience + description: The intended audience for JWT token credentials used for authorization. + This value must match on the client and server sides. + If empty, audience will not be tested. + type: string + example: project-id-random-value.apps.googleusercontent.com + default: "" + - name: google_key_path + description: | + Path to GCP Credential JSON file. If ommited, authorization based on `the Application Default + Credentials + `__ will + be used. + type: string + example: /files/service-account-json + default: "" - name: lineage description: ~ options: diff --git a/airflow/config_templates/default_airflow.cfg b/airflow/config_templates/default_airflow.cfg index 5ffdd18c157e9..ad9b8934e906a 100644 --- a/airflow/config_templates/default_airflow.cfg +++ b/airflow/config_templates/default_airflow.cfg @@ -326,6 +326,17 @@ maximum_page_limit = 100 # If no limit is supplied, the OpenApi spec default is used. fallback_page_limit = 100 +# The intended audience for JWT token credentials used for authorization. This value must match on the client and server sides. If empty, audience will not be tested. +# Example: google_oauth2_audience = project-id-random-value.apps.googleusercontent.com +google_oauth2_audience = + +# Path to GCP Credential JSON file. If ommited, authorization based on `the Application Default +# Credentials +# `__ will +# be used. +# Example: google_key_path = /files/service-account-json +google_key_path = + [lineage] # what lineage backend to use backend = diff --git a/airflow/providers/google/common/auth_backend/__init__.py b/airflow/providers/google/common/auth_backend/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/airflow/providers/google/common/auth_backend/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/airflow/providers/google/common/auth_backend/google_openid.py b/airflow/providers/google/common/auth_backend/google_openid.py new file mode 100644 index 0000000000000..d35980b624ce4 --- /dev/null +++ b/airflow/providers/google/common/auth_backend/google_openid.py @@ -0,0 +1,138 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Authentication backend that use Google credentials for authorization.""" +import logging +from functools import wraps +from typing import Callable, TypeVar, cast + +import google +import google.auth.transport.requests +import google.oauth2.id_token +from flask import Response, _request_ctx_stack, current_app, request # type: ignore +from google.auth import exceptions +from google.auth.transport.requests import AuthorizedSession +from google.oauth2 import service_account + +from airflow.configuration import conf +from airflow.providers.google.common.utils.id_token_credentials import get_default_id_token_credentials + +log = logging.getLogger(__name__) + +_GOOGLE_ISSUERS = ["accounts.google.com", "https://accounts.google.com"] +AUDIENCE = conf.get("api", "google_oauth2_audience") + + +def create_client_session(): + """Create a HTTP authorized client.""" + service_account_path = conf.get("api", "google_key_path") + if service_account_path: + id_token_credentials = service_account.IDTokenCredentials.from_service_account_file( + service_account_path + ) + else: + id_token_credentials = get_default_id_token_credentials(target_audience=AUDIENCE) + return AuthorizedSession(credentials=id_token_credentials) + + +def init_app(_): + """Initializes authentication.""" + + +def _get_id_token_from_request(r): + authorization_header = r.headers.get("Authorization") + + if not authorization_header: + return None + + authorization_header_parts = authorization_header.split(" ", 2) + + if len(authorization_header_parts) != 2 or authorization_header_parts[0].lower() != "bearer": + return None + + id_token = authorization_header_parts[1] + return id_token + + +def _verify_id_token(id_token): + try: + request_adapter = google.auth.transport.requests.Request() + id_info = google.oauth2.id_token.verify_token(id_token, request_adapter, AUDIENCE) + except exceptions.GoogleAuthError: + return None + + # This check is part of 1.19.0 (2020-07-09), In order not to create strong version requirements + # to too new version, we check it in our code too. + # One day, we may delete this code and set minimum version in requirements. + if id_info.get("iss") not in _GOOGLE_ISSUERS: + return None + + if not id_info.get("email_verified", False): + return None + + return id_info.get("email") + + +def _lookup_user(user_id): + security_manager = current_app.appbuilder.sm + user = security_manager.find_user(email=user_id) + + if not user: + return None + + if not user.is_active: + return None + + return user + + +def _set_current_user(user): + ctx = _request_ctx_stack.top + ctx.user = user + + +T = TypeVar("T", bound=Callable) # pylint: disable=invalid-name + + +def requires_authentication(function: T): + """Decorator for functions that require authentication.""" + + @wraps(function) + def decorated(*args, **kwargs): + access_token = _get_id_token_from_request(request) + if not access_token: + log.debug("Missing ID Token") + return Response("Forbidden", 403) + + userid = _verify_id_token(access_token) + if not userid: + log.debug("Invalid ID Token") + return Response("Forbidden", 403) + + log.debug("Looking for user with e-mail: %s", userid) + + user = _lookup_user(userid) + if not user: + return Response("Forbidden", 403) + + log.debug("Found user: %s", user) + + _set_current_user(user) + + return function(*args, **kwargs) + + return cast(T, decorated) diff --git a/airflow/providers/google/common/utils/__init__.py b/airflow/providers/google/common/utils/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/airflow/providers/google/common/utils/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/airflow/providers/google/common/utils/id_token_credentials.py b/airflow/providers/google/common/utils/id_token_credentials.py new file mode 100644 index 0000000000000..0cc89bb691d03 --- /dev/null +++ b/airflow/providers/google/common/utils/id_token_credentials.py @@ -0,0 +1,216 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +You can execute this module to get ID Token. + + python -m airflow.providers.google.common.utils.id_token_credentials_provider + +To obtain info about this token, run the following commands: + + ID_TOKEN="$(python -m airflow.providers.google.common.utils.id_token_credentials_provider)" + curl "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=${ID_TOKEN}" -v +""" + +import json +import os +from typing import Optional + +import google.auth.transport +from google.auth import credentials as google_auth_credentials, environment_vars, exceptions +from google.auth._default import _AUTHORIZED_USER_TYPE, _HELP_MESSAGE, _SERVICE_ACCOUNT_TYPE, _VALID_TYPES +from google.oauth2 import credentials as oauth2_credentials + + +class IDTokenCredentialsAdapter(google_auth_credentials.Credentials): + """Convert Credentials with "openid" scope to IDTokenCredentials.""" + + def __init__(self, credentials: oauth2_credentials.Credentials): + super().__init__() + self.credentials = credentials + self.token = credentials.id_token + + @property + def expired(self): + return self.credentials.expired + + def refresh(self, request): + self.credentials.refresh(request) + self.token = self.credentials.id_token + + +def _load_credentials_from_file( + filename: str, target_audience: Optional[str] +) -> Optional[google_auth_credentials.Credentials]: + """ + Loads credentials from a file. + + The credentials file must be a service account key. The stored authorized user credential are + not supported. + + :param filename: The full path to the credentials file. + :type filename: str + :return Loaded credentials + :rtype google.auth.credentials.Credentials + :raise google.auth.exceptions.DefaultCredentialsError: if the file is in the wrong format or is missing. + """ + if not os.path.exists(filename): + raise exceptions.DefaultCredentialsError(f"File {filename} was not found.") + + with open(filename) as file_obj: + try: + info = json.load(file_obj) + except json.JSONDecodeError: + raise exceptions.DefaultCredentialsError(f"File {filename} is not a valid json file.") + + # The type key should indicate that the file is either a service account + # credentials file or an authorized user credentials file. + credential_type = info.get("type") + + if credential_type == _AUTHORIZED_USER_TYPE: + current_credentials = oauth2_credentials.Credentials.from_authorized_user_info( + info, scopes=["openid", "email"] + ) + current_credentials = IDTokenCredentialsAdapter(credentials=current_credentials) + + return current_credentials + + elif credential_type == _SERVICE_ACCOUNT_TYPE: + from google.oauth2 import service_account + + try: + return service_account.IDTokenCredentials.from_service_account_info( + info, target_audience=target_audience + ) + except ValueError: + raise exceptions.DefaultCredentialsError( + f"Failed to load service account credentials from {filename}" + ) + + raise exceptions.DefaultCredentialsError( + f"The file {filename} does not have a valid type. Type is {credential_type}, " + f"expected one of {_VALID_TYPES}." + ) + + +def _get_explicit_environ_credentials( + target_audience: Optional[str], +) -> Optional[google_auth_credentials.Credentials]: + """ + Gets credentials from the GOOGLE_APPLICATION_CREDENTIALS environment variable. + """ + explicit_file = os.environ.get(environment_vars.CREDENTIALS) + + if explicit_file is None: + return None + + current_credentials = _load_credentials_from_file( + os.environ[environment_vars.CREDENTIALS], target_audience=target_audience + ) + + return current_credentials + + +def _get_gcloud_sdk_credentials( + target_audience: Optional[str], +) -> Optional[google_auth_credentials.Credentials]: + """Gets the credentials and project ID from the Cloud SDK.""" + from google.auth import _cloud_sdk + + # Check if application default credentials exist. + credentials_filename = _cloud_sdk.get_application_default_credentials_path() + + if not os.path.isfile(credentials_filename): + return None + + current_credentials = _load_credentials_from_file(credentials_filename, target_audience) + + return current_credentials + + +def _get_gce_credentials( + target_audience: Optional[str], request: Optional[google.auth.transport.Request] = None +) -> Optional[google_auth_credentials.Credentials]: + """Gets credentials and project ID from the GCE Metadata Service.""" + # Ping requires a transport, but we want application default credentials + # to require no arguments. So, we'll use the _http_client transport which + # uses http.client. This is only acceptable because the metadata server + # doesn't do SSL and never requires proxies. + + # While this library is normally bundled with compute_engine, there are + # some cases where it's not available, so we tolerate ImportError. + try: + from google.auth import compute_engine + from google.auth.compute_engine import _metadata + except ImportError: + return None + from google.auth.transport import _http_client + + if request is None: + request = _http_client.Request() + + if _metadata.ping(request=request): + return compute_engine.IDTokenCredentials( + request, target_audience, use_metadata_identity_endpoint=True + ) + + return None + + +def get_default_id_token_credentials( + target_audience: Optional[str], request: google.auth.transport.Request = None +) -> google_auth_credentials.Credentials: + """Gets the default ID Token credentials for the current environment. + + `Application Default Credentials`_ provides an easy way to obtain credentials to call Google APIs for + server-to-server or local applications. + + .. _Application Default Credentials: https://developers.google.com\ + /identity/protocols/application-default-credentials + + :param target_audience: The intended audience for these credentials. + :type target_audience: Sequence[str] + :param request: An object used to make HTTP requests. This is used to detect whether the application + is running on Compute Engine. If not specified, then it will use the standard library http client + to make requests. + :type request: google.auth.transport.Request + :return the current environment's credentials. + :rtype google.auth.credentials.Credentials + :raises ~google.auth.exceptions.DefaultCredentialsError: + If no credentials were found, or if the credentials found were invalid. + """ + checkers = ( + lambda: _get_explicit_environ_credentials(target_audience), + lambda: _get_gcloud_sdk_credentials(target_audience), + lambda: _get_gce_credentials(target_audience, request), + ) + + for checker in checkers: + current_credentials = checker() + if current_credentials is not None: + return current_credentials + + raise exceptions.DefaultCredentialsError(_HELP_MESSAGE) + + +if __name__ == "__main__": + from google.auth.transport import requests + + request_adaapter = requests.Request() + + creds = get_default_id_token_credentials(target_audience=None) + creds.refresh(request=request_adaapter) + print(creds.token) diff --git a/backport_packages/refactor_backport_packages.py b/backport_packages/refactor_backport_packages.py index ea939a9c31c7a..63bf8fa53dd0e 100755 --- a/backport_packages/refactor_backport_packages.py +++ b/backport_packages/refactor_backport_packages.py @@ -54,9 +54,16 @@ def ignore_kubernetes_files(src: str, names: List[str]) -> List[str]: ignored_names.append(file_name) return ignored_names + def ignore_google_auth_backend(src: str, names: List[str]) -> List[str]: + del names + if src.endswith("google" + os.path.sep + "common"): + return ["auth_backend"] + return [] + def ignore_some_files(src: str, names: List[str]) -> List[str]: ignored_list = [] ignored_list.extend(ignore_kubernetes_files(src=src, names=names)) + ignored_list.extend(ignore_google_auth_backend(src=src, names=names)) return ignored_list rm_build_dir() diff --git a/docs/conf.py b/docs/conf.py index 2fefdac9bf24d..549b724b03a71 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,7 +34,6 @@ import os import sys from glob import glob -from itertools import chain from typing import Dict, List import airflow @@ -207,12 +206,6 @@ "_api/airflow/providers/cncf/index.rst", # Packages without operators "_api/airflow/providers/sendgrid", - # Utils - '_api/airflow/providers/google/cloud/utils', - # Internal client for Hashicorp Vault - '_api/airflow/providers/hashicorp/_internal_client', - # Internal client for GCP Secret Manager - '_api/airflow/providers/google/cloud/_internal_client', # Templates or partials 'autoapi_templates', 'howto/operator/google/_partials', @@ -220,8 +213,21 @@ ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) -# Generate top-level +def _get_rst_filepath_from_path(filepath: str): + if os.path.isdir(filepath): + result = f"{filepath}/index.rst" + elif os.path.isfile(filepath) and filepath.endswith('/__init__.py'): + result = filepath.rpartition("/")[0] + result += "/index.rst" + else: + result = filepath.replace(".py", ".rst") + + result = f"_api/{os.path.relpath(result, ROOT_DIR)}" + return result + + +# Exclude top-level packages # do not exclude these top-level modules from the doc build: allowed_top_level = ("exceptions.py",) @@ -233,11 +239,11 @@ if os.path.isdir(path) and name not in browsable_packages: exclude_patterns.append(f"_api/airflow/{name}") -# Generate list of package index +# Exclude package index providers_packages_roots = { name.rpartition("/")[0] for entity in ["hooks", "operators", "secrets", "sensors"] - for name in chain(glob(f"{ROOT_DIR}/airflow/providers/**/{entity}", recursive=True)) + for name in glob(f"{ROOT_DIR}/airflow/providers/**/{entity}", recursive=True) } providers_package_indexes = { @@ -247,12 +253,13 @@ exclude_patterns.extend(providers_package_indexes) -# Generate list of example_dags -excluded_example_dags = ( - f"_api/{os.path.relpath(name, ROOT_DIR)}" - for name in glob(f"{ROOT_DIR}/airflow/providers/**/example_dags", recursive=True) -) -exclude_patterns.extend(excluded_example_dags) +# Exclude auth_backend, utils, _internal_client, example_dags in providers paackages +excluded_packages_in_providers = { + _get_rst_filepath_from_path(name) + for entity in ['auth_backend', 'utils', '_internal_client', 'example_dags'] + for name in glob(f"{ROOT_DIR}/airflow/providers/**/{entity}/", recursive=True) +} +exclude_patterns.extend(excluded_packages_in_providers) # The reST default role (used for this markup: `text`) to use for all # documents. diff --git a/docs/security.rst b/docs/security.rst index 8f276765bac21..60f944d84eed1 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -99,6 +99,40 @@ To enable Kerberos authentication, set the following in the configuration: The Kerberos service is configured as ``airflow/fully.qualified.domainname@REALM``. Make sure this principal exists in the keytab file. +You can also configure +`Google OpenID `__ +for authorization. To enable it, set the following option in the configuration: + +.. code-block:: ini + + [api] + auth_backend = airflow.providers.google.common.auth_backend.google_openid + +It is also highly recommended to configure an OAuth2 audience so that the generated used tokens can only +be used by Airflow. + +.. code-block:: ini + + [api] + google_oauth2_audience = project-id-random-value.apps.googleusercontent.com + +You can also configure the CLI to send request to a remote API instead of making a query to a local database. + +.. code-block:: ini + + [cli] + api_client = airflow.api.client.json_client + endpoint_url = http://remote-host.example.org/ + +You can also set up a service account key. If ommited, authorization based on `the Application Default +Credentials `__ +will be used. + +.. code-block:: ini + + [cli] + google_key_path = + Kerberos -------- diff --git a/tests/api/auth/test_client.py b/tests/api/auth/test_client.py index 18cc254ee9ff5..0924e5ae0ed5a 100644 --- a/tests/api/auth/test_client.py +++ b/tests/api/auth/test_client.py @@ -35,6 +35,23 @@ def test_should_create_cllient(self, mock_client): result = get_current_api_client() mock_client.assert_called_once_with( - api_base_url='http://localhost:1234', auth='CLIENT_AUTH' + api_base_url='http://localhost:1234', auth='CLIENT_AUTH', session=None + ) + self.assertEqual(mock_client.return_value, result) + + @mock.patch("airflow.api.client.json_client.Client") + @mock.patch("airflow.providers.google.common.auth_backend.google_openid.create_client_session") + @conf_vars({ + ("api", 'auth_backend'): 'airflow.providers.google.common.auth_backend.google_openid', + ("cli", 'api_client'): 'airflow.api.client.json_client', + ("cli", 'endpoint_url'): 'http://localhost:1234', + }) + def test_should_create_google_open_id_client(self, mock_create_client_session, mock_client): + result = get_current_api_client() + + mock_client.assert_called_once_with( + api_base_url='http://localhost:1234', + auth=None, + session=mock_create_client_session.return_value ) self.assertEqual(mock_client.return_value, result) diff --git a/tests/providers/google/common/auth_backend/__init__.py b/tests/providers/google/common/auth_backend/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/tests/providers/google/common/auth_backend/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/providers/google/common/auth_backend/test_google_openid.py b/tests/providers/google/common/auth_backend/test_google_openid.py new file mode 100644 index 0000000000000..3d16b6e5d6b22 --- /dev/null +++ b/tests/providers/google/common/auth_backend/test_google_openid.py @@ -0,0 +1,134 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import unittest +from unittest import mock + +from flask_login import current_user +from google.auth.exceptions import GoogleAuthError +from parameterized import parameterized + +from airflow.www.app import create_app +from tests.test_utils.config import conf_vars +from tests.test_utils.db import clear_db_pools + + +class TestGoogleOpenID(unittest.TestCase): + def setUp(self) -> None: + with conf_vars( + {("api", "auth_backend"): "airflow.providers.google.common.auth_backend.google_openid"} + ): + self.app = create_app(testing=True) + + self.appbuilder = self.app.appbuilder # pylint: disable=no-member + role_admin = self.appbuilder.sm.find_role("Admin") + tester = self.appbuilder.sm.find_user(username="test") + if not tester: + self.appbuilder.sm.add_user( + username="test", + first_name="test", + last_name="test", + email="test@fab.org", + role=role_admin, + password="test", + ) + + @mock.patch("google.oauth2.id_token.verify_token") + def test_success(self, mock_verify_token): + clear_db_pools() + mock_verify_token.return_value = { + "iss": "accounts.google.com", + "email_verified": True, + "email": "test@fab.org", + } + + with self.app.test_client() as test_client: + response = test_client.get( + "/api/experimental/pools", headers={"Authorization": "bearer JWT_TOKEN"} + ) + self.assertEqual("test@fab.org", current_user.email) + + self.assertEqual(200, response.status_code) + self.assertEqual( + [{"description": "Default pool", "id": 1, "pool": "default_pool", "slots": 128}], response.json + ) + + @parameterized.expand([("bearer",), ("JWT_TOKEN",), ("bearer ",)]) + @mock.patch("google.oauth2.id_token.verify_token") + def test_malformed_headers(self, auth_header, mock_verify_token): + mock_verify_token.return_value = { + "iss": "accounts.google.com", + "email_verified": True, + "email": "test@fab.org", + } + + with self.app.test_client() as test_client: + response = test_client.get("/api/experimental/pools", headers={"Authorization": auth_header}) + + self.assertEqual(403, response.status_code) + self.assertEqual("Forbidden", response.data.decode()) + + @mock.patch("google.oauth2.id_token.verify_token") + def test_invalid_iss_in_jwt_token(self, mock_verify_token): + mock_verify_token.return_value = { + "iss": "INVALID", + "email_verified": True, + "email": "test@fab.org", + } + + with self.app.test_client() as test_client: + response = test_client.get( + "/api/experimental/pools", headers={"Authorization": "bearer JWT_TOKEN"} + ) + + self.assertEqual(403, response.status_code) + self.assertEqual("Forbidden", response.data.decode()) + + @mock.patch("google.oauth2.id_token.verify_token") + def test_user_not_exists(self, mock_verify_token): + mock_verify_token.return_value = { + "iss": "accounts.google.com", + "email_verified": True, + "email": "invalid@fab.org", + } + + with self.app.test_client() as test_client: + response = test_client.get( + "/api/experimental/pools", headers={"Authorization": "bearer JWT_TOKEN"} + ) + + self.assertEqual(403, response.status_code) + self.assertEqual("Forbidden", response.data.decode()) + + @conf_vars({("api", "auth_backend"): "airflow.providers.google.common.auth_backend.google_openid"}) + def test_missing_id_token(self): + with self.app.test_client() as test_client: + response = test_client.get("/api/experimental/pools") + + self.assertEqual(403, response.status_code) + self.assertEqual("Forbidden", response.data.decode()) + + @conf_vars({("api", "auth_backend"): "airflow.providers.google.common.auth_backend.google_openid"}) + @mock.patch("google.oauth2.id_token.verify_token") + def test_invalid_id_token(self, mock_verify_token): + mock_verify_token.side_effect = GoogleAuthError("Invalid token") + + with self.app.test_client() as test_client: + response = test_client.get("/api/experimental/pools", {"Authorization": "bearer JWT_TOKEN"}) + + self.assertEqual(403, response.status_code) + self.assertEqual("Forbidden", response.data.decode()) diff --git a/tests/providers/google/common/utils/__init__.py b/tests/providers/google/common/utils/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/tests/providers/google/common/utils/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/providers/google/common/utils/test_id_token_credentials.py b/tests/providers/google/common/utils/test_id_token_credentials.py new file mode 100644 index 0000000000000..f1eb242b1a365 --- /dev/null +++ b/tests/providers/google/common/utils/test_id_token_credentials.py @@ -0,0 +1,162 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import json +import os +import re +import unittest +from unittest import mock + +from google.auth import exceptions +from google.auth.environment_vars import CREDENTIALS + +from airflow.providers.google.common.utils.id_token_credentials import ( + IDTokenCredentialsAdapter, get_default_id_token_credentials, +) + + +class TestIDTokenCredentialsAdapter(unittest.TestCase): + def test_should_use_id_token_from_parent_credentials(self): + parent_credentials = mock.MagicMock() + type(parent_credentials).id_token = mock.PropertyMock(side_effect=["ID_TOKEN1", "ID_TOKEN2"]) + + creds = IDTokenCredentialsAdapter(credentials=parent_credentials) + self.assertEqual(creds.token, "ID_TOKEN1") + + request_adapter = mock.MagicMock() + creds.refresh(request_adapter) + + self.assertEqual(creds.token, "ID_TOKEN2") + + +class TestGetDefaultIdTokenCredentials(unittest.TestCase): + @mock.patch.dict("os.environ") + @mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", + return_value="/tmp/INVALID_PATH.json", + ) + @mock.patch( + "google.auth.compute_engine._metadata.ping", return_value=False, + ) + def test_should_raise_exception(self, mock_metadata_ping, mock_gcloud_sdk_path): + if CREDENTIALS in os.environ: + del os.environ[CREDENTIALS] + with self.assertRaisesRegex( + exceptions.DefaultCredentialsError, + re.escape( + "Could not automatically determine credentials. Please set GOOGLE_APPLICATION_CREDENTIALS " + "or explicitly create credentials and re-run the application. For more information, please " + "see https://cloud.google.com/docs/authentication/getting-started" + ), + ): + get_default_id_token_credentials(target_audience="example.org") + + @mock.patch.dict("os.environ") + @mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", + return_value="/tmp/INVALID_PATH.json", + ) + @mock.patch( + "google.auth.compute_engine._metadata.ping", return_value=True, + ) + @mock.patch("google.auth.compute_engine.IDTokenCredentials",) + def test_should_support_metadata_credentials(self, credentials, mock_metadata_ping, mock_gcloud_sdk_path): + if CREDENTIALS in os.environ: + del os.environ[CREDENTIALS] + + self.assertEqual( + credentials.return_value, get_default_id_token_credentials(target_audience="example.org") + ) + + @mock.patch.dict("os.environ") + @mock.patch( + "airflow.providers.google.common.utils.id_token_credentials.open", + mock.mock_open( + read_data=json.dumps( + { + "client_id": "CLIENT_ID", + "client_secret": "CLIENT_SECRET", + "refresh_token": "REFRESH_TOKEN", + "type": "authorized_user", + } + ) + ), + ) + @mock.patch("google.auth._cloud_sdk.get_application_default_credentials_path", return_value=__file__) + def test_should_support_user_credentials_from_gcloud(self, mock_gcloud_sdk_path): + if CREDENTIALS in os.environ: + del os.environ[CREDENTIALS] + + credentials = get_default_id_token_credentials(target_audience="example.org") + self.assertIsInstance(credentials, IDTokenCredentialsAdapter) + self.assertEqual(credentials.credentials.client_secret, "CLIENT_SECRET") + + @mock.patch.dict("os.environ") + @mock.patch( + "airflow.providers.google.common.utils.id_token_credentials.open", + mock.mock_open( + read_data=json.dumps( + { + "type": "service_account", + "project_id": "PROJECT_ID", + "private_key_id": "PRIVATE_KEY_ID", + "private_key": "PRIVATE_KEY", + "client_email": "CLIENT_EMAIL", + "client_id": "CLIENT_ID", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/CERT", + } + ) + ), + ) + @mock.patch("google.auth._service_account_info.from_dict", return_value="SIGNER") + @mock.patch("google.auth._cloud_sdk.get_application_default_credentials_path", return_value=__file__) + def test_should_support_service_account_from_gcloud(self, mock_gcloud_sdk_path, mock_from_dict): + if CREDENTIALS in os.environ: + del os.environ[CREDENTIALS] + + credentials = get_default_id_token_credentials(target_audience="example.org") + self.assertEqual(credentials.service_account_email, "CLIENT_EMAIL") + + @mock.patch.dict("os.environ") + @mock.patch( + "airflow.providers.google.common.utils.id_token_credentials.open", + mock.mock_open( + read_data=json.dumps( + { + "type": "service_account", + "project_id": "PROJECT_ID", + "private_key_id": "PRIVATE_KEY_ID", + "private_key": "PRIVATE_KEY", + "client_email": "CLIENT_EMAIL", + "client_id": "CLIENT_ID", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/CERT", + } + ) + ), + ) + @mock.patch("google.auth._service_account_info.from_dict", return_value="SIGNER") + def test_should_support_service_account_from_env(self, mock_gcloud_sdk_path): + os.environ[CREDENTIALS] = __file__ + + credentials = get_default_id_token_credentials(target_audience="example.org") + self.assertEqual(credentials.service_account_email, "CLIENT_EMAIL") From 31f8dfe4e05f1867b9511fe8c020886c417754cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Fri, 17 Jul 2020 22:35:10 +0200 Subject: [PATCH 2/7] fixup! Add Google Authorization for experimental API --- docs/conf.py | 16 +++++++++++++--- .../common/auth_backend/test_google_openid.py | 8 ++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 549b724b03a71..24abf00b721d5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -253,13 +253,23 @@ def _get_rst_filepath_from_path(filepath: str): exclude_patterns.extend(providers_package_indexes) -# Exclude auth_backend, utils, _internal_client, example_dags in providers paackages +# Exclude auth_backend, utils, _internal_client, example_dags in providers packages excluded_packages_in_providers = { - _get_rst_filepath_from_path(name) + name for entity in ['auth_backend', 'utils', '_internal_client', 'example_dags'] for name in glob(f"{ROOT_DIR}/airflow/providers/**/{entity}/", recursive=True) } -exclude_patterns.extend(excluded_packages_in_providers) +excluded_files_in_providers = { + _get_rst_filepath_from_path(path) + for p in excluded_packages_in_providers + for path in glob(f"{p}/**/*", recursive=True) +} +excluded_files_in_providers |= { + _get_rst_filepath_from_path(name) + for name in excluded_packages_in_providers +} + +exclude_patterns.extend(excluded_files_in_providers) # The reST default role (used for this markup: `text`) to use for all # documents. diff --git a/tests/providers/google/common/auth_backend/test_google_openid.py b/tests/providers/google/common/auth_backend/test_google_openid.py index 3d16b6e5d6b22..e5a22469d13a1 100644 --- a/tests/providers/google/common/auth_backend/test_google_openid.py +++ b/tests/providers/google/common/auth_backend/test_google_openid.py @@ -63,9 +63,7 @@ def test_success(self, mock_verify_token): self.assertEqual("test@fab.org", current_user.email) self.assertEqual(200, response.status_code) - self.assertEqual( - [{"description": "Default pool", "id": 1, "pool": "default_pool", "slots": 128}], response.json - ) + self.assertIn("Default pool", response.json) @parameterized.expand([("bearer",), ("JWT_TOKEN",), ("bearer ",)]) @mock.patch("google.oauth2.id_token.verify_token") @@ -128,7 +126,9 @@ def test_invalid_id_token(self, mock_verify_token): mock_verify_token.side_effect = GoogleAuthError("Invalid token") with self.app.test_client() as test_client: - response = test_client.get("/api/experimental/pools", {"Authorization": "bearer JWT_TOKEN"}) + response = test_client.get( + "/api/experimental/pools", headers={"Authorization": "bearer JWT_TOKEN"} + ) self.assertEqual(403, response.status_code) self.assertEqual("Forbidden", response.data.decode()) From 51e614a263ebee7a70e1e11edcb0a768bb604442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Fri, 17 Jul 2020 22:48:32 +0200 Subject: [PATCH 3/7] fixup! fixup! Add Google Authorization for experimental API --- docs/security.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/security.rst b/docs/security.rst index 60f944d84eed1..0f0121896f39c 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -133,6 +133,23 @@ will be used. [cli] google_key_path = +You can get the authorization token with the ``gcloud auth print-identity-token`` command. An example request +look like the following. + + .. code-block:: bash + + ENDPOINT_URL="http://locahost:8080/" + + AUDIENCE="project-id-random-value.apps.googleusercontent.com" + ID_TOKEN="$(gcloud auth print-identity-token "--audience=${AUDIENCE}")" + + curl -X GET \ + "${ENDPOINT_URL}/api/experimental/pools" \ + -H 'Content-Type: application/json' \ + -H 'Cache-Control: no-cache' \ + -H "Authorization: Bearer ${ID_TOKEN}" \ + --data '{"replace_microseconds":"false"}' + Kerberos -------- From e06e30ec7bdb013a6e1ced6fd44903f572ccfa07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Fri, 17 Jul 2020 23:44:01 +0200 Subject: [PATCH 4/7] fixup! fixup! fixup! Add Google Authorization for experimental API --- docs/conf.py | 6 +++--- .../google/common/auth_backend/test_google_openid.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 24abf00b721d5..b3cab9798c264 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -216,12 +216,12 @@ def _get_rst_filepath_from_path(filepath: str): if os.path.isdir(filepath): - result = f"{filepath}/index.rst" + result = filepath elif os.path.isfile(filepath) and filepath.endswith('/__init__.py'): result = filepath.rpartition("/")[0] - result += "/index.rst" else: - result = filepath.replace(".py", ".rst") + result = filepath.rpartition(".",)[0] + result += "/index.rst" result = f"_api/{os.path.relpath(result, ROOT_DIR)}" return result diff --git a/tests/providers/google/common/auth_backend/test_google_openid.py b/tests/providers/google/common/auth_backend/test_google_openid.py index e5a22469d13a1..8df83c8f30f22 100644 --- a/tests/providers/google/common/auth_backend/test_google_openid.py +++ b/tests/providers/google/common/auth_backend/test_google_openid.py @@ -63,7 +63,7 @@ def test_success(self, mock_verify_token): self.assertEqual("test@fab.org", current_user.email) self.assertEqual(200, response.status_code) - self.assertIn("Default pool", response.json) + self.assertIn("Default pool", str(response.json)) @parameterized.expand([("bearer",), ("JWT_TOKEN",), ("bearer ",)]) @mock.patch("google.oauth2.id_token.verify_token") From 02a1aa4f552300e18628098c491f7e9a42b5ecb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Sat, 18 Jul 2020 13:20:39 +0200 Subject: [PATCH 5/7] fixup! fixup! fixup! fixup! Add Google Authorization for experimental API --- airflow/providers/google/common/utils/id_token_credentials.py | 3 +-- docs/security.rst | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/airflow/providers/google/common/utils/id_token_credentials.py b/airflow/providers/google/common/utils/id_token_credentials.py index 0cc89bb691d03..d4f92d5af90bf 100644 --- a/airflow/providers/google/common/utils/id_token_credentials.py +++ b/airflow/providers/google/common/utils/id_token_credentials.py @@ -58,8 +58,7 @@ def _load_credentials_from_file( """ Loads credentials from a file. - The credentials file must be a service account key. The stored authorized user credential are - not supported. + The credentials file must be a service account key or a stored authorized user credential. :param filename: The full path to the credentials file. :type filename: str diff --git a/docs/security.rst b/docs/security.rst index 0f0121896f39c..910fccb7603e5 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -147,8 +147,7 @@ look like the following. "${ENDPOINT_URL}/api/experimental/pools" \ -H 'Content-Type: application/json' \ -H 'Cache-Control: no-cache' \ - -H "Authorization: Bearer ${ID_TOKEN}" \ - --data '{"replace_microseconds":"false"}' + -H "Authorization: Bearer ${ID_TOKEN}" Kerberos -------- From aa0e3eb54616e933b5b6c490086ebc6f4125dd86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Wed, 22 Jul 2020 02:19:51 +0200 Subject: [PATCH 6/7] fixup! fixup! fixup! fixup! fixup! Add Google Authorization for experimental API --- .../common/auth_backend/google_openid.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/airflow/providers/google/common/auth_backend/google_openid.py b/airflow/providers/google/common/auth_backend/google_openid.py index d35980b624ce4..8ce6ef5715433 100644 --- a/airflow/providers/google/common/auth_backend/google_openid.py +++ b/airflow/providers/google/common/auth_backend/google_openid.py @@ -18,12 +18,12 @@ """Authentication backend that use Google credentials for authorization.""" import logging from functools import wraps -from typing import Callable, TypeVar, cast +from typing import Callable, Optional, TypeVar, cast import google import google.auth.transport.requests import google.oauth2.id_token -from flask import Response, _request_ctx_stack, current_app, request # type: ignore +from flask import Response, _request_ctx_stack, current_app, request as flask_request # type: ignore from google.auth import exceptions from google.auth.transport.requests import AuthorizedSession from google.oauth2 import service_account @@ -33,7 +33,7 @@ log = logging.getLogger(__name__) -_GOOGLE_ISSUERS = ["accounts.google.com", "https://accounts.google.com"] +_GOOGLE_ISSUERS = ("accounts.google.com", "https://accounts.google.com") AUDIENCE = conf.get("api", "google_oauth2_audience") @@ -53,8 +53,8 @@ def init_app(_): """Initializes authentication.""" -def _get_id_token_from_request(r): - authorization_header = r.headers.get("Authorization") +def _get_id_token_from_request(request) -> Optional[str]: + authorization_header = request.headers.get("Authorization") if not authorization_header: return None @@ -68,15 +68,15 @@ def _get_id_token_from_request(r): return id_token -def _verify_id_token(id_token): +def _verify_id_token(id_token: str) -> Optional[str]: try: request_adapter = google.auth.transport.requests.Request() id_info = google.oauth2.id_token.verify_token(id_token, request_adapter, AUDIENCE) except exceptions.GoogleAuthError: return None - # This check is part of 1.19.0 (2020-07-09), In order not to create strong version requirements - # to too new version, we check it in our code too. + # This check is part of google-auth v1.19.0 (2020-07-09), In order not to create strong version + # requirements to too new version, we check it in our code too. # One day, we may delete this code and set minimum version in requirements. if id_info.get("iss") not in _GOOGLE_ISSUERS: return None @@ -87,9 +87,9 @@ def _verify_id_token(id_token): return id_info.get("email") -def _lookup_user(user_id): +def _lookup_user(user_email: str): security_manager = current_app.appbuilder.sm - user = security_manager.find_user(email=user_id) + user = security_manager.find_user(email=user_email) if not user: return None @@ -113,7 +113,7 @@ def requires_authentication(function: T): @wraps(function) def decorated(*args, **kwargs): - access_token = _get_id_token_from_request(request) + access_token = _get_id_token_from_request(flask_request) if not access_token: log.debug("Missing ID Token") return Response("Forbidden", 403) From 3522bb65aa7dd7cfef161924ce329d8241fd925e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Wed, 22 Jul 2020 14:08:55 +0200 Subject: [PATCH 7/7] fixup! fixup! fixup! fixup! fixup! fixup! Add Google Authorization for experimental API --- docs/security.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/security.rst b/docs/security.rst index 910fccb7603e5..5d83cb06876e4 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -108,8 +108,8 @@ for authorization. To enable it, set the following option in the configuration: [api] auth_backend = airflow.providers.google.common.auth_backend.google_openid -It is also highly recommended to configure an OAuth2 audience so that the generated used tokens can only -be used by Airflow. +It is also highly recommended to configure an OAuth2 audience so that the generated tokens are restricted to +use by Airflow only. .. code-block:: ini