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

Source SmartSheets: fix oauth2 implementation #23237

Merged
merged 12 commits into from
Mar 3, 2023
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ COPY $CODE_PATH ./$CODE_PATH
ENV AIRBYTE_ENTRYPOINT "python /airbyte/integration_code/main.py"
ENTRYPOINT ["python", "/airbyte/integration_code/main.py"]

LABEL io.airbyte.version=0.1.14
LABEL io.airbyte.version=0.1.15
LABEL io.airbyte.name=airbyte/source-smartsheets
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ connector_image: airbyte/source-smartsheets:dev
tests:
spec:
- spec_path: "source_smartsheets/spec.json"
backward_compatibility_tests_config:
disable_for_version: "0.1.14"
evantahler marked this conversation as resolved.
Show resolved Hide resolved
connection:
- config_path: "secrets/config.json"
status: "succeed"
- config_path: "secrets/config_oauth.json"
status: "succeed"
- config_path: "integration_tests/invalid_config.json"
status: "failed"
discovery:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,41 @@
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
#

import hashlib
import logging
from functools import cached_property
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple

import smartsheet
from airbyte_cdk.sources.streams.http.requests_native_auth import SingleUseRefreshTokenOauth2Authenticator


class SmartSheetAPIWrapper:
def __init__(self, config: Mapping[str, Any]):
self._spreadsheet_id = config["spreadsheet_id"]
self._access_token = config["access_token"]
api_client = smartsheet.Smartsheet(self._access_token)
api_client.errors_as_exceptions(True)
self._config = config
self.api_client = smartsheet.Smartsheet(self.get_access_token(config))
self.api_client.errors_as_exceptions(True)
# each call to `Sheets` makes a new instance, so we save it here to make no more new objects
self._get_sheet = api_client.Sheets.get_sheet
self._get_sheet = self.api_client.Sheets.get_sheet
self._data = None

def get_token_hash(self, config: Mapping[str, Any]):
credentials = config.get("credentials")
return {"hash": hashlib.sha256(f"{credentials.get('client_secret')}|{credentials.get('refresh_token')}".encode()).hexdigest()}

def get_access_token(self, config: Mapping[str, Any]):
credentials = config.get("credentials")
if config.get("credentials", {}).get("auth_type") == "oauth2.0":
authenticator = SingleUseRefreshTokenOauth2Authenticator(
config, token_refresh_endpoint="https://api.smartsheet.com/2.0/token", refresh_request_body=self.get_token_hash(config)
)
return authenticator.get_access_token()

else:
access_token = credentials.get("access_token")
return access_token

def _fetch_sheet(self, from_dt: Optional[str] = None) -> None:
kwargs = {"rows_modified_since": from_dt}
if not from_dt:
Expand All @@ -43,6 +61,7 @@ def _construct_record(self, row: smartsheet.models.Row) -> Dict[str, str]:
@property
def data(self) -> smartsheet.models.Row:
if not self._data:
self.api_client._access_token = self.get_access_token(self._config)
self._fetch_sheet()
return self._data

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,68 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Smartsheets Source Spec",
"type": "object",
"required": ["access_token", "spreadsheet_id"],
"required": ["credentials", "spreadsheet_id"],
"additionalProperties": true,
"properties": {
"access_token": {
"title": "Access Token",
"description": "The access token to use for accessing your data from Smartsheets. This access token must be generated by a user with at least read access to the data you'd like to replicate. Generate an access token in the Smartsheets main menu by clicking Account > Apps & Integrations > API Access. See the <a href=\"https://docs.airbyte.com/integrations/sources/amplitude#setup-guide\">setup guide</a> for information on how to obtain this token.",
"type": "string",
"credentials": {
"title": "Authorization Method",
"type": "object",
"order": 0,
"airbyte_secret": true
"oneOf": [
{
"type": "object",
"title": "OAuth2.0",
"required": ["client_id", "client_secret", "refresh_token", "access_token", "token_expiry_date"],
"properties": {
"auth_type": {
"type": "string",
"const": "oauth2.0"
},
"client_id": {
"type": "string",
"description": "The API ID of the Gitlab developer application.",
pedroslopez marked this conversation as resolved.
Show resolved Hide resolved
"airbyte_secret": true
},
"client_secret": {
"type": "string",
"description": "The API Secret the Gitlab developer application.",
"airbyte_secret": true
},
"access_token": {
"type": "string",
"description": "Access Token for making authenticated requests.",
"airbyte_secret": true
},
"token_expiry_date": {
"type": "string",
"description": "The date-time when the access token should be refreshed.",
"format": "date-time"
},
"refresh_token": {
"type": "string",
"description": "The key to refresh the expired access_token.",
"airbyte_secret": true
}
}
},
{
"title": "Private Token",
pedroslopez marked this conversation as resolved.
Show resolved Hide resolved
"type": "object",
"required": ["access_token"],
"properties": {
"auth_type": {
"type": "string",
"const": "access_token"
},
"access_token": {
"type": "string",
"title": "Private Token",
"description": "The access token to use for accessing your data from Smartsheets. This access token must be generated by a user with at least read access to the data you'd like to replicate. Generate an access token in the Smartsheets main menu by clicking Account > Apps & Integrations > API Access. See the <a href=\"https://docs.airbyte.com/integrations/sources/amplitude#setup-guide\">setup guide</a> for information on how to obtain this token.",
pedroslopez marked this conversation as resolved.
Show resolved Hide resolved
"airbyte_secret": true
}
}
}
]
},
"spreadsheet_id": {
"title": "Sheet ID",
Expand All @@ -34,16 +87,24 @@
},
"advanced_auth": {
"auth_flow_type": "oauth2.0",
"predicate_key": [],
"predicate_value": "",
"predicate_key": ["credentials", "auth_type"],
"predicate_value": "oauth2.0",
"oauth_config_specification": {
"complete_oauth_output_specification": {
"type": "object",
"additionalProperties": false,
"properties": {
"access_token": {
"type": "string",
"path_in_connector_config": ["access_token"]
"path_in_connector_config": ["credentials", "access_token"]
},
"refresh_token": {
"type": "string",
"path_in_connector_config": ["credentials", "refresh_token"]
},
"token_expiry_date": {
"type": "string",
"format": "date-time",
"path_in_connector_config": ["credentials", "token_expiry_date"]
}
}
},
Expand All @@ -61,8 +122,16 @@
},
"complete_oauth_server_output_specification": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"client_id": {
"type": "string",
"path_in_connector_config": ["credentials", "client_id"]
},
"client_secret": {
"type": "string",
"path_in_connector_config": ["credentials", "client_secret"]
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def response_mock():

@pytest.fixture
def config():
return {"spreadsheet_id": "id", "access_token": "token"}
return {"spreadsheet_id": "id", "credentials": {"access_token": "token"}}


@pytest.fixture
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@

import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import io.airbyte.config.persistence.ConfigRepository;
import io.airbyte.oauth.BaseOAuth2Flow;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.util.List;
import java.time.Clock;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Supplier;
Expand All @@ -23,14 +24,17 @@ public class SmartsheetsOAuthFlow extends BaseOAuth2Flow {

private static final String AUTHORIZE_URL = "https://app.smartsheet.com/b/authorize";
private static final String ACCESS_TOKEN_URL = "https://api.smartsheet.com/2.0/token";
private final Clock clock;

public SmartsheetsOAuthFlow(final ConfigRepository configRepository, final HttpClient httpClient) {
super(configRepository, httpClient);
this.clock = Clock.systemUTC();
}

@VisibleForTesting
public SmartsheetsOAuthFlow(final ConfigRepository configRepository, final HttpClient httpClient, final Supplier<String> stateSupplier) {
pedroslopez marked this conversation as resolved.
Show resolved Hide resolved
super(configRepository, httpClient, stateSupplier);
this.clock = Clock.systemUTC();
}

@Override
Expand All @@ -57,14 +61,25 @@ protected String getAccessTokenUrl(final JsonNode inputOAuthConfiguration) {
}

@Override
protected Map<String, Object> extractOAuthOutput(final JsonNode data, final String accessTokenUrl) {
Preconditions.checkArgument(data.has("access_token"), "Missing 'access_token' in query params from %s", ACCESS_TOKEN_URL);
return Map.of("access_token", data.get("access_token").asText());
}

@Override
public List<String> getDefaultOAuthOutputPath() {
return List.of();
protected Map<String, Object> extractOAuthOutput(final JsonNode data, final String accessTokenUrl) throws IOException {
final Map<String, Object> result = new HashMap<>();
if (data.has("refresh_token")) {
result.put("refresh_token", data.get("refresh_token").asText());
} else {
throw new IOException(String.format("Missing 'refresh_token' in query params from %s", accessTokenUrl));
}
if (data.has("access_token")) {
result.put("access_token", data.get("access_token").asText());
} else {
throw new IOException(String.format("Missing 'access_token' in query params from %s", accessTokenUrl));
}
if (data.has("expires_in")) {
Instant expires_in = Instant.now(this.clock).plusSeconds(data.get("expires_in").asInt());
result.put("token_expiry_date", expires_in.toString());
} else {
throw new IOException(String.format("Missing 'expires_in' in query params from %s", accessTokenUrl));
}
return result;
}

@Override
Expand Down
1 change: 1 addition & 0 deletions docs/integrations/sources/smartsheets.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ The remaining column datatypes supported by Smartsheets are more complex types (

| Version | Date | Pull Request | Subject |
|:--------|:-----------|:---------------------------------------------------------|:----------------------------------------------------------|
| 0.1.15 | 2023-02-19 | [23237](https://github.com/airbytehq/airbyte/pull/23237) | Fix OAuth2.0 token refresh |
| 0.1.14 | 2023-02-07 | [22419](https://github.com/airbytehq/airbyte/pull/22419) | OAuth2.0 support - enabled; add allowed hosts |
| 0.1.13 | 2022-12-02 | [20017](https://github.com/airbytehq/airbyte/pull/20017) | OAuth2.0 support - disabled |
| 0.1.12 | 2022-04-30 | [12500](https://github.com/airbytehq/airbyte/pull/12500) | Improve input configuration copy |
Expand Down