Skip to content

Commit 39a0288

Browse files
authored
Add Google Authentication for experimental API (#9848)
1 parent 708197b commit 39a0288

File tree

18 files changed

+864
-26
lines changed

18 files changed

+864
-26
lines changed

airflow/api/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ def load_auth():
3434
pass
3535

3636
try:
37-
return import_module(auth_backend)
37+
auth_backend = import_module(auth_backend)
38+
log.info("Loaded API auth backend: %s", auth_backend)
39+
return auth_backend
3840
except ImportError as err:
3941
log.critical(
4042
"Cannot import %s for API authentication due to: %s",

airflow/api/client/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,14 @@ def get_current_api_client() -> Client:
3131
Return current API Client based on current Airflow configuration
3232
"""
3333
api_module = import_module(conf.get('cli', 'api_client')) # type: Any
34+
auth_backend = api.load_auth()
35+
session = None
36+
session_factory = getattr(auth_backend, 'create_client_session', None)
37+
if session_factory:
38+
session = session_factory()
3439
api_client = api_module.Client(
3540
api_base_url=conf.get('cli', 'endpoint_url'),
36-
auth=api.load_auth().CLIENT_AUTH
41+
auth=getattr(auth_backend, 'CLIENT_AUTH', None),
42+
session=session
3743
)
3844
return api_client

airflow/api/client/api_client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,17 @@
1616
# specific language governing permissions and limitations
1717
# under the License.
1818
"""Client for all the API clients."""
19+
import requests
1920

2021

2122
class Client:
2223
"""Base API client for all API clients."""
2324

24-
def __init__(self, api_base_url, auth):
25+
def __init__(self, api_base_url, auth=None, session=None):
2526
self._api_base_url = api_base_url
26-
self._auth = auth
27+
self._session: requests.Session = session or requests.Session()
28+
if auth:
29+
self._session.auth = auth
2730

2831
def trigger_dag(self, dag_id, run_id=None, conf=None, execution_date=None):
2932
"""Create a dag run for the specified dag.

airflow/api/client/json_client.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@
1919

2020
from urllib.parse import urljoin
2121

22-
import requests
23-
2422
from airflow.api.client import api_client
2523

2624

@@ -30,12 +28,10 @@ class Client(api_client.Client):
3028
def _request(self, url, method='GET', json=None):
3129
params = {
3230
'url': url,
33-
'auth': self._auth,
3431
}
3532
if json is not None:
3633
params['json'] = json
37-
38-
resp = getattr(requests, method.lower())(**params) # pylint: disable=not-callable
34+
resp = getattr(self._session, method.lower())(**params) # pylint: disable=not-callable
3935
if not resp.ok:
4036
# It is justified here because there might be many resp types.
4137
# noinspection PyBroadException

airflow/config_templates/config.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,22 @@
614614
type: integer
615615
example: ~
616616
default: "100"
617+
- name: google_oauth2_audience
618+
description: The intended audience for JWT token credentials used for authorization.
619+
This value must match on the client and server sides.
620+
If empty, audience will not be tested.
621+
type: string
622+
example: project-id-random-value.apps.googleusercontent.com
623+
default: ""
624+
- name: google_key_path
625+
description: |
626+
Path to GCP Credential JSON file. If ommited, authorization based on `the Application Default
627+
Credentials
628+
<https://cloud.google.com/docs/authentication/production#finding_credentials_automatically>`__ will
629+
be used.
630+
type: string
631+
example: /files/service-account-json
632+
default: ""
617633
- name: lineage
618634
description: ~
619635
options:

airflow/config_templates/default_airflow.cfg

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,17 @@ maximum_page_limit = 100
335335
# If no limit is supplied, the OpenApi spec default is used.
336336
fallback_page_limit = 100
337337

338+
# 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.
339+
# Example: google_oauth2_audience = project-id-random-value.apps.googleusercontent.com
340+
google_oauth2_audience =
341+
342+
# Path to GCP Credential JSON file. If ommited, authorization based on `the Application Default
343+
# Credentials
344+
# <https://cloud.google.com/docs/authentication/production#finding_credentials_automatically>`__ will
345+
# be used.
346+
# Example: google_key_path = /files/service-account-json
347+
google_key_path =
348+
338349
[lineage]
339350
# what lineage backend to use
340351
backend =
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
"""Authentication backend that use Google credentials for authorization."""
19+
import logging
20+
from functools import wraps
21+
from typing import Callable, Optional, TypeVar, cast
22+
23+
import google
24+
import google.auth.transport.requests
25+
import google.oauth2.id_token
26+
from flask import Response, _request_ctx_stack, current_app, request as flask_request # type: ignore
27+
from google.auth import exceptions
28+
from google.auth.transport.requests import AuthorizedSession
29+
from google.oauth2 import service_account
30+
31+
from airflow.configuration import conf
32+
from airflow.providers.google.common.utils.id_token_credentials import get_default_id_token_credentials
33+
34+
log = logging.getLogger(__name__)
35+
36+
_GOOGLE_ISSUERS = ("accounts.google.com", "https://accounts.google.com")
37+
AUDIENCE = conf.get("api", "google_oauth2_audience")
38+
39+
40+
def create_client_session():
41+
"""Create a HTTP authorized client."""
42+
service_account_path = conf.get("api", "google_key_path")
43+
if service_account_path:
44+
id_token_credentials = service_account.IDTokenCredentials.from_service_account_file(
45+
service_account_path
46+
)
47+
else:
48+
id_token_credentials = get_default_id_token_credentials(target_audience=AUDIENCE)
49+
return AuthorizedSession(credentials=id_token_credentials)
50+
51+
52+
def init_app(_):
53+
"""Initializes authentication."""
54+
55+
56+
def _get_id_token_from_request(request) -> Optional[str]:
57+
authorization_header = request.headers.get("Authorization")
58+
59+
if not authorization_header:
60+
return None
61+
62+
authorization_header_parts = authorization_header.split(" ", 2)
63+
64+
if len(authorization_header_parts) != 2 or authorization_header_parts[0].lower() != "bearer":
65+
return None
66+
67+
id_token = authorization_header_parts[1]
68+
return id_token
69+
70+
71+
def _verify_id_token(id_token: str) -> Optional[str]:
72+
try:
73+
request_adapter = google.auth.transport.requests.Request()
74+
id_info = google.oauth2.id_token.verify_token(id_token, request_adapter, AUDIENCE)
75+
except exceptions.GoogleAuthError:
76+
return None
77+
78+
# This check is part of google-auth v1.19.0 (2020-07-09), In order not to create strong version
79+
# requirements to too new version, we check it in our code too.
80+
# One day, we may delete this code and set minimum version in requirements.
81+
if id_info.get("iss") not in _GOOGLE_ISSUERS:
82+
return None
83+
84+
if not id_info.get("email_verified", False):
85+
return None
86+
87+
return id_info.get("email")
88+
89+
90+
def _lookup_user(user_email: str):
91+
security_manager = current_app.appbuilder.sm
92+
user = security_manager.find_user(email=user_email)
93+
94+
if not user:
95+
return None
96+
97+
if not user.is_active:
98+
return None
99+
100+
return user
101+
102+
103+
def _set_current_user(user):
104+
ctx = _request_ctx_stack.top
105+
ctx.user = user
106+
107+
108+
T = TypeVar("T", bound=Callable) # pylint: disable=invalid-name
109+
110+
111+
def requires_authentication(function: T):
112+
"""Decorator for functions that require authentication."""
113+
114+
@wraps(function)
115+
def decorated(*args, **kwargs):
116+
access_token = _get_id_token_from_request(flask_request)
117+
if not access_token:
118+
log.debug("Missing ID Token")
119+
return Response("Forbidden", 403)
120+
121+
userid = _verify_id_token(access_token)
122+
if not userid:
123+
log.debug("Invalid ID Token")
124+
return Response("Forbidden", 403)
125+
126+
log.debug("Looking for user with e-mail: %s", userid)
127+
128+
user = _lookup_user(userid)
129+
if not user:
130+
return Response("Forbidden", 403)
131+
132+
log.debug("Found user: %s", user)
133+
134+
_set_current_user(user)
135+
136+
return function(*args, **kwargs)
137+
138+
return cast(T, decorated)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.

0 commit comments

Comments
 (0)