Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[low-code CDK] Enable runtime string interpolation in authenticators #14914

Merged
merged 13 commits into from
Jul 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions airbyte-cdk/python/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 0.1.67
- Add support declarative token authenticator.

## 0.1.66
- Call init_uncaught_exception_handler from AirbyteEntrypoint.__init__ and Destination.run_cmd
- Add the ability to remove & add records in YAML-based sources
Expand Down
98 changes: 98 additions & 0 deletions airbyte-cdk/python/airbyte_cdk/sources/declarative/auth/token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#
# Copyright (c) 2022 Airbyte, Inc., all rights reserved.
#

import base64
from typing import Union

from airbyte_cdk.sources.declarative.interpolation.interpolated_string import InterpolatedString
from airbyte_cdk.sources.declarative.types import Config
from airbyte_cdk.sources.streams.http.requests_native_auth.abtract_token import AbstractHeaderAuthenticator


class ApiKeyAuthenticator(AbstractHeaderAuthenticator):
"""
ApiKeyAuth sets a request header on the HTTP requests sent.

The header is of the form:
`"<header>": "<token>"`

For example,
`ApiKeyAuthenticator("Authorization", "Bearer hello")`
will result in the following header set on the HTTP request
`"Authorization": "Bearer hello"`

"""

def __init__(self, header: Union[InterpolatedString, str], token: Union[InterpolatedString, str], config: Config):
"""
:param header: Header key to set on the HTTP requests
:param token: Header value to set on the HTTP requests
:param config: The user-provided configuration as specified by the source's spec
"""
self._header = InterpolatedString.create(header)
self._token = InterpolatedString.create(token)
self._config = config

@property
def auth_header(self) -> str:
return self._header.eval(self._config)

@property
def token(self) -> str:
return self._token.eval(self._config)


class BearerAuthenticator(AbstractHeaderAuthenticator):
"""
Authenticator that sets the Authorization header on the HTTP requests sent.

The header is of the form:
`"Authorization": "Bearer <token>"`
"""

def __init__(self, token: Union[InterpolatedString, str], config: Config):
"""
:param token: The bearer token
:param config: The user-provided configuration as specified by the source's spec
"""
self._token = InterpolatedString.create(token)
self._config = config

@property
def auth_header(self) -> str:
return "Authorization"

@property
def token(self) -> str:
return f"Bearer {self._token.eval(self._config)}"


class BasicHttpAuthenticator(AbstractHeaderAuthenticator):
"""
Builds auth based off the basic authentication scheme as defined by RFC 7617, which transmits credentials as USER ID/password pairs, encoded using bas64
https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#basic_authentication_scheme

The header is of the form
`"Authorization": "Basic <encoded_credentials>"`
"""

def __init__(self, username: Union[InterpolatedString, str], config: Config, password: Union[InterpolatedString, str] = ""):
"""
:param username: The username
:param config: The user-provided configuration as specified by the source's spec
:param password: The password
"""
self._username = InterpolatedString.create(username)
self._password = InterpolatedString.create(password)
self._config = config

@property
def auth_header(self) -> str:
return "Authorization"

@property
def token(self) -> str:
auth_string = f"{self._username.eval(self._config)}:{self._password.eval(self._config)}".encode("utf8")
b64_encoded = base64.b64encode(auth_string).decode("utf8")
return f"Basic {b64_encoded}"
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ def create(
"""
Helper function to obtain an InterpolatedString from either a raw string or an InterpolatedString.

:param string_or_interpolated: Either a raw string or an InterpolatedString.
:param default: The default value to return if the evaluation returns an empty string
:param string_or_interpolated: either a raw string or an InterpolatedString.
:param options: options parameters propagated from parent component
:return: InterpolatedString representing the input string.
"""
if isinstance(string_or_interpolated, str):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

from typing import Mapping, Type

from airbyte_cdk.sources.declarative.auth.token import ApiKeyAuthenticator, BasicHttpAuthenticator, BearerAuthenticator
from airbyte_cdk.sources.declarative.datetime.min_max_datetime import MinMaxDatetime
from airbyte_cdk.sources.declarative.declarative_stream import DeclarativeStream
from airbyte_cdk.sources.declarative.extractors.jello import JelloExtractor
from airbyte_cdk.sources.declarative.extractors.record_selector import RecordSelector
from airbyte_cdk.sources.declarative.interpolation.interpolated_boolean import InterpolatedBoolean
from airbyte_cdk.sources.declarative.interpolation.interpolated_string import InterpolatedString
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies.constant_backoff_strategy import ConstantBackoffStrategy
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies.exponential_backoff_strategy import (
ExponentialBackoffStrategy,
Expand All @@ -26,13 +29,15 @@
from airbyte_cdk.sources.declarative.stream_slicers.list_stream_slicer import ListStreamSlicer
from airbyte_cdk.sources.declarative.transformations import RemoveFields
from airbyte_cdk.sources.declarative.transformations.add_fields import AddFields
from airbyte_cdk.sources.streams.http.requests_native_auth.token import TokenAuthenticator

"""
CLASS_TYPES_REGISTRY contains a mapping of developer-friendly string -> class to abstract the specific class referred to
"""
CLASS_TYPES_REGISTRY: Mapping[str, Type] = {
"AddFields": AddFields,
"ApiKeyAuthenticator": ApiKeyAuthenticator,
"BasicHttpAuthenticator": BasicHttpAuthenticator,
"BearerAuthenticator": BearerAuthenticator,
"CartesianProductStreamSlicer": CartesianProductStreamSlicer,
"CompositeErrorHandler": CompositeErrorHandler,
"ConstantBackoffStrategy": ConstantBackoffStrategy,
Expand All @@ -42,6 +47,8 @@
"DefaultErrorHandler": DefaultErrorHandler,
"ExponentialBackoffStrategy": ExponentialBackoffStrategy,
"HttpRequester": HttpRequester,
"InterpolatedBoolean": InterpolatedBoolean,
"InterpolatedString": InterpolatedString,
"JelloExtractor": JelloExtractor,
"JsonSchema": JsonSchema,
"LimitPaginator": LimitPaginator,
Expand All @@ -52,5 +59,4 @@
"RecordSelector": RecordSelector,
"RemoveFields": RemoveFields,
"SimpleRetriever": SimpleRetriever,
"TokenAuthenticator": TokenAuthenticator,
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
#
# Copyright (c) 2022 Airbyte, Inc., all rights reserved.
#

from .oauth import Oauth2Authenticator
from .token import MultipleTokenAuthenticator, TokenAuthenticator
from .token import BasicHttpAuthenticator, MultipleTokenAuthenticator, TokenAuthenticator

__all__ = [
"BasicHttpAuthenticator",
"Oauth2Authenticator",
"TokenAuthenticator",
"MultipleTokenAuthenticator",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#
# Copyright (c) 2022 Airbyte, Inc., all rights reserved.
#

from abc import abstractmethod
from typing import Any, Mapping

from requests.auth import AuthBase


class AbstractHeaderAuthenticator(AuthBase):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we get a docstring

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

"""
Abstract class for header-based authenticators that set a key-value pair in outgoing HTTP headers
"""

def __call__(self, request):
"""Attach the HTTP headers required to authenticate on the HTTP request"""
request.headers.update(self.get_auth_header())
return request

def get_auth_header(self) -> Mapping[str, Any]:
"""HTTP header to set on the requests"""
return {self.auth_header: self.token}

@property
@abstractmethod
def auth_header(self) -> str:
"""HTTP header to set on the requests"""

@property
@abstractmethod
def token(self) -> str:
"""Value of the HTTP header to set on the requests"""
Original file line number Diff line number Diff line change
Expand Up @@ -4,49 +4,70 @@

import base64
from itertools import cycle
from typing import Any, List, Mapping
from typing import List

from requests.auth import AuthBase
from airbyte_cdk.sources.streams.http.requests_native_auth.abtract_token import AbstractHeaderAuthenticator


class MultipleTokenAuthenticator(AuthBase):
class MultipleTokenAuthenticator(AbstractHeaderAuthenticator):
"""
Builds auth header, based on the list of tokens provided.
Auth header is changed per each `get_auth_header` call, using each token in cycle.
The token is attached to each request via the `auth_header` header.
"""

@property
def auth_header(self) -> str:
return self._auth_header

@property
def token(self) -> str:
return f"{self._auth_method} {next(self._tokens_iter)}"

def __init__(self, tokens: List[str], auth_method: str = "Bearer", auth_header: str = "Authorization"):
self.auth_method = auth_method
self.auth_header = auth_header
self._auth_method = auth_method
self._auth_header = auth_header
self._tokens = tokens
self._tokens_iter = cycle(self._tokens)

def __call__(self, request):
request.headers.update(self.get_auth_header())
return request

def get_auth_header(self) -> Mapping[str, Any]:
return {self.auth_header: f"{self.auth_method} {next(self._tokens_iter)}"}


class TokenAuthenticator(MultipleTokenAuthenticator):
class TokenAuthenticator(AbstractHeaderAuthenticator):
"""
Builds auth header, based on the token provided.
The token is attached to each request via the `auth_header` header.
"""

@property
def auth_header(self) -> str:
return self._auth_header

@property
def token(self) -> str:
return f"{self._auth_method} {self._token}"

def __init__(self, token: str, auth_method: str = "Bearer", auth_header: str = "Authorization"):
super().__init__([token], auth_method, auth_header)
self._auth_header = auth_header
self._auth_method = auth_method
self._token = token


class BasicHttpAuthenticator(TokenAuthenticator):
class BasicHttpAuthenticator(AbstractHeaderAuthenticator):
"""
Builds auth based off the basic authentication scheme as defined by RFC 7617, which transmits credentials as USER ID/password pairs, encoded using bas64
https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#basic_authentication_scheme
"""

@property
def auth_header(self) -> str:
return self._auth_header

@property
def token(self) -> str:
return f"{self._auth_method} {self._token}"

def __init__(self, username: str, password: str = "", auth_method: str = "Basic", auth_header: str = "Authorization"):
auth_string = f"{username}:{password}".encode("utf8")
b64_encoded = base64.b64encode(auth_string).decode("utf8")
super().__init__(b64_encoded, auth_method, auth_header)
self._auth_header = auth_header
self._auth_method = auth_method
self._token = b64_encoded
2 changes: 1 addition & 1 deletion airbyte-cdk/python/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

setup(
name="airbyte-cdk",
version="0.1.66",
version="0.1.67",
description="A framework for writing Airbyte Connectors.",
long_description=README,
long_description_content_type="text/markdown",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#
# Copyright (c) 2022 Airbyte, Inc., all rights reserved.
#

import logging

import pytest
import requests
from airbyte_cdk.sources.declarative.auth.token import ApiKeyAuthenticator, BasicHttpAuthenticator, BearerAuthenticator
from requests import Response

LOGGER = logging.getLogger(__name__)

resp = Response()
config = {"username": "user", "password": "password", "header": "header"}


@pytest.mark.parametrize(
"test_name, token, expected_header_value",
[
("test_static_token", "test-token", "Bearer test-token"),
("test_token_from_config", "{{ config.username }}", "Bearer user"),
],
)
def test_bearer_token_authenticator(test_name, token, expected_header_value):
"""
Should match passed in token, no matter how many times token is retrieved.
"""
token_auth = BearerAuthenticator(token, config)
header1 = token_auth.get_auth_header()
header2 = token_auth.get_auth_header()

prepared_request = requests.PreparedRequest()
prepared_request.headers = {}
token_auth(prepared_request)

assert {"Authorization": expected_header_value} == prepared_request.headers
assert {"Authorization": expected_header_value} == header1
assert {"Authorization": expected_header_value} == header2


@pytest.mark.parametrize(
"test_name, username, password, expected_header_value",
[
("test_static_creds", "user", "password", "Basic dXNlcjpwYXNzd29yZA=="),
("test_creds_from_config", "{{ config.username }}", "{{ config.password }}", "Basic dXNlcjpwYXNzd29yZA=="),
],
)
def test_basic_authenticator(test_name, username, password, expected_header_value):
"""
Should match passed in token, no matter how many times token is retrieved.
"""
token_auth = BasicHttpAuthenticator(username=username, password=password, config=config)
header1 = token_auth.get_auth_header()
header2 = token_auth.get_auth_header()

prepared_request = requests.PreparedRequest()
prepared_request.headers = {}
token_auth(prepared_request)

assert {"Authorization": expected_header_value} == prepared_request.headers
assert {"Authorization": expected_header_value} == header1
assert {"Authorization": expected_header_value} == header2


@pytest.mark.parametrize(
"test_name, header, token, expected_header, expected_header_value",
[
("test_static_token", "Authorization", "test-token", "Authorization", "test-token"),
("test_token_from_config", "{{ config.header }}", "{{ config.username }}", "header", "user"),
],
)
def test_api_key_authenticator(test_name, header, token, expected_header, expected_header_value):
"""
Should match passed in token, no matter how many times token is retrieved.
"""
token_auth = ApiKeyAuthenticator(header, token, config)
header1 = token_auth.get_auth_header()
header2 = token_auth.get_auth_header()

prepared_request = requests.PreparedRequest()
prepared_request.headers = {}
token_auth(prepared_request)

assert {expected_header: expected_header_value} == prepared_request.headers
assert {expected_header: expected_header_value} == header1
assert {expected_header: expected_header_value} == header2
Loading