Skip to content

Commit 056bd4c

Browse files
committed
source-zendesk-support-native: switch to RotatingOAuth2Credentials for OAuth authentication
Zendesk will start expiring access tokens on 30SEP2025, so the connector needs to support rotating access & refresh tokens with the `RotatingOAuth2Credentials` class instead of `LongLivedClientCredentialsOAuth2Credentials` that's currently used. We have existing users that have authenticated with OAuth already, so the connector needs to support both type of OAuth credentials internally but only show the `RotatingOAuth2Credentials` option in the UI. This will let existing users' tasks to continue working until they can re-authenticate with the new OAuth option. Once all existing users have re-authenticated & migrated off of the deprecated OAuth option, we can remove support for it from the connector.
1 parent 50cacec commit 056bd4c

File tree

3 files changed

+116
-10
lines changed

3 files changed

+116
-10
lines changed

source-zendesk-support-native/source_zendesk_support_native/models.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
BasicAuth,
88
BaseDocument,
99
LongLivedClientCredentialsOAuth2Credentials,
10-
OAuth2Spec,
10+
RotatingOAuth2Credentials,
11+
OAuth2RotatingTokenSpec,
1112
ResourceConfig,
1213
ResourceState,
1314
)
@@ -28,6 +29,11 @@
2829
# lowercase letter, and only contain lowercase letters, digits, or dashes.
2930
SUBDOMAIN_REGEX = r"^[a-z][a-z0-9-]{2,62}$"
3031

32+
# Zendesk supports variable access token durations, between 5 minutes to 2 days.
33+
# To reduce how frequently we need to perform token rotations, we use the
34+
# largest possible access token duration.
35+
MAX_VALID_ACCESS_TOKEN_DURATION = int(timedelta(days=2).total_seconds())
36+
3137
def urlencode_field(field: str):
3238
return "{{#urlencode}}{{{ " + field + " }}}{{/urlencode}}"
3339

@@ -37,11 +43,11 @@ def urlencode_field(field: str):
3743
"client_id": "{{{ client_id }}}",
3844
"client_secret": "{{{ client_secret }}}",
3945
"redirect_uri": "{{{ redirect_uri }}}",
40-
"scope": "read"
46+
"scope": "read",
47+
"expires_in": MAX_VALID_ACCESS_TOKEN_DURATION,
4148
}
4249

43-
44-
OAUTH2_SPEC = OAuth2Spec(
50+
OAUTH2_SPEC = OAuth2RotatingTokenSpec(
4551
provider="zendesk",
4652
accessTokenBody=json.dumps(accessTokenBody),
4753
authUrlTemplate=(
@@ -55,17 +61,35 @@ def urlencode_field(field: str):
5561
accessTokenUrlTemplate=("https://{{{ config.subdomain }}}.zendesk.com/oauth/tokens"),
5662
accessTokenResponseMap={
5763
"access_token": "/access_token",
64+
"refresh_token": "/refresh_token",
65+
"access_token_expires_at": r"{{#now_plus}}{{ expires_in }}{{/now_plus}}",
5866
},
5967
accessTokenHeaders={
6068
"Content-Type": "application/json",
6169
},
70+
additionalTokenExchangeBody={
71+
"expires_in": MAX_VALID_ACCESS_TOKEN_DURATION,
72+
},
6273
)
6374

75+
# Mustache templates in accessTokenUrlTemplate are not interpolated within the connector, so update_oauth_spec reassigns it for use within the connector.
76+
def update_oauth_spec(subdomain: str):
77+
OAUTH2_SPEC.accessTokenUrlTemplate = f"https://{subdomain}.zendesk.com/oauth/tokens"
78+
6479

6580
if TYPE_CHECKING:
66-
OAuth2Credentials = LongLivedClientCredentialsOAuth2Credentials
81+
OAuth2Credentials = RotatingOAuth2Credentials
82+
DeprecatedOAuthCredentials = LongLivedClientCredentialsOAuth2Credentials
6783
else:
68-
OAuth2Credentials = LongLivedClientCredentialsOAuth2Credentials.for_provider(OAUTH2_SPEC.provider)
84+
OAuth2Credentials = RotatingOAuth2Credentials.for_provider(OAUTH2_SPEC.provider)
85+
86+
class DeprecatedOAuthCredentials(
87+
LongLivedClientCredentialsOAuth2Credentials.for_provider(OAUTH2_SPEC.provider) # type: ignore
88+
):
89+
credentials_title: Literal["Deprecated OAuth Credentials"] = Field(
90+
default="Deprecated OAuth Credentials",
91+
json_schema_extra={"type": "string"},
92+
)
6993

7094

7195
class ApiToken(BasicAuth):
@@ -99,7 +123,7 @@ class EndpointConfig(BaseModel):
99123
default_factory=default_start_date,
100124
ge=EPOCH,
101125
)
102-
credentials: OAuth2Credentials | ApiToken = Field(
126+
credentials: OAuth2Credentials | ApiToken | DeprecatedOAuthCredentials = Field(
103127
discriminator="credentials_title",
104128
title="Authentication",
105129
)
@@ -119,6 +143,38 @@ class Advanced(BaseModel):
119143
json_schema_extra={"advanced": True},
120144
)
121145

146+
# Zendesk is updating their OAuth to expire access tokens, meaning this connector
147+
# will need to use RotatingOAuth2Credentials instead of DeprecatedOAuthCredentials.
148+
# During the migration period where both types of OAuth credentials are valid, we'll
149+
# show the RotatingOAuth2Credentials option as the _only_ OAuth option in the UI &
150+
# we'll hide the DeprecatedOAuthCredentials option. This lets existing
151+
# captures continue working until users re-authenticate their tasks with the
152+
# RotatingOAuth2Credentials option.
153+
#
154+
# Patching model_json_schema to remove DeprecatedOAuthCredentials makes
155+
# RotatingOAuth2Credentials the only OAuth authentication option in the UI, and effectively
156+
# "hides" the deprecated OAuth option. This is a bit gross, but it should only be needed for
157+
# a little bit until all users that authenticated with OAuth have re-authenticated.
158+
@classmethod
159+
def model_json_schema(cls, *args, **kwargs) -> dict:
160+
schema = super().model_json_schema(*args, **kwargs)
161+
162+
creds = schema.get("properties", {}).get("credentials", {})
163+
one_of = creds.get("oneOf", [])
164+
filtered_one_of = []
165+
166+
for option in one_of:
167+
if "$ref" in option:
168+
ref = option["$ref"]
169+
def_name = ref.split("/")[-1]
170+
# Do not include DeprecatedOAuthCredentials as an authentication option.
171+
if "DeprecatedOAuthCredentials" in def_name:
172+
continue
173+
174+
filtered_one_of.append(option)
175+
176+
creds["oneOf"] = filtered_one_of
177+
return schema
122178

123179
ConnectorState = GenericConnectorState[ResourceState]
124180

source-zendesk-support-native/source_zendesk_support_native/resources.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
OAUTH2_SPEC,
3737
TICKET_CHILD_RESOURCES,
3838
POST_CHILD_RESOURCES,
39+
update_oauth_spec,
3940
)
4041
from .api import (
4142
backfill_audit_logs,
@@ -846,6 +847,7 @@ def open(
846847
async def all_resources(
847848
log: Logger, http: HTTPMixin, config: EndpointConfig
848849
) -> list[common.Resource]:
850+
update_oauth_spec(config.subdomain)
849851
http.token_source = TokenSource(oauth_spec=OAUTH2_SPEC, credentials=config.credentials)
850852

851853
resources = [

source-zendesk-support-native/tests/snapshots/snapshots__spec__capture.stdout.json

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,39 @@
4242
"title": "ApiToken",
4343
"type": "object"
4444
},
45+
"DeprecatedOAuthCredentials": {
46+
"properties": {
47+
"credentials_title": {
48+
"const": "Deprecated OAuth Credentials",
49+
"default": "Deprecated OAuth Credentials",
50+
"title": "Credentials Title",
51+
"type": "string"
52+
},
53+
"client_id": {
54+
"secret": true,
55+
"title": "Client Id",
56+
"type": "string"
57+
},
58+
"client_secret": {
59+
"secret": true,
60+
"title": "Client Secret",
61+
"type": "string"
62+
},
63+
"access_token": {
64+
"secret": true,
65+
"title": "Access Token",
66+
"type": "string"
67+
}
68+
},
69+
"required": [
70+
"client_id",
71+
"client_secret",
72+
"access_token"
73+
],
74+
"title": "OAuth",
75+
"type": "object",
76+
"x-oauth2-provider": "zendesk"
77+
},
4578
"_OAuth2Credentials": {
4679
"properties": {
4780
"credentials_title": {
@@ -60,16 +93,28 @@
6093
"title": "Client Secret",
6194
"type": "string"
6295
},
96+
"refresh_token": {
97+
"secret": true,
98+
"title": "Refresh Token",
99+
"type": "string"
100+
},
63101
"access_token": {
64102
"secret": true,
65103
"title": "Access Token",
66104
"type": "string"
105+
},
106+
"access_token_expires_at": {
107+
"format": "date-time",
108+
"title": "Access token expiration time.",
109+
"type": "string"
67110
}
68111
},
69112
"required": [
70113
"client_id",
71114
"client_secret",
72-
"access_token"
115+
"refresh_token",
116+
"access_token",
117+
"access_token_expires_at"
73118
],
74119
"title": "OAuth",
75120
"type": "object",
@@ -92,6 +137,7 @@
92137
"credentials": {
93138
"discriminator": {
94139
"mapping": {
140+
"Deprecated OAuth Credentials": "#/$defs/DeprecatedOAuthCredentials",
95141
"Email & API Token": "#/$defs/ApiToken",
96142
"OAuth Credentials": "#/$defs/_OAuth2Credentials"
97143
},
@@ -161,12 +207,14 @@
161207
"provider": "zendesk",
162208
"authUrlTemplate": "https://{{{ config.subdomain }}}.zendesk.com/oauth/authorizations/new?response_type=code&client_id={{#urlencode}}{{{ client_id }}}{{/urlencode}}&redirect_uri={{#urlencode}}{{{ redirect_uri }}}{{/urlencode}}&scope=read&state={{#urlencode}}{{{ state }}}{{/urlencode}}",
163209
"accessTokenUrlTemplate": "https://{{{ config.subdomain }}}.zendesk.com/oauth/tokens",
164-
"accessTokenBody": "{\"grant_type\": \"authorization_code\", \"code\": \"{{{ code }}}\", \"client_id\": \"{{{ client_id }}}\", \"client_secret\": \"{{{ client_secret }}}\", \"redirect_uri\": \"{{{ redirect_uri }}}\", \"scope\": \"read\"}",
210+
"accessTokenBody": "{\"grant_type\": \"authorization_code\", \"code\": \"{{{ code }}}\", \"client_id\": \"{{{ client_id }}}\", \"client_secret\": \"{{{ client_secret }}}\", \"redirect_uri\": \"{{{ redirect_uri }}}\", \"scope\": \"read\", \"expires_in\": 172800}",
165211
"accessTokenHeaders": {
166212
"Content-Type": "application/json"
167213
},
168214
"accessTokenResponseMap": {
169-
"access_token": "/access_token"
215+
"access_token": "/access_token",
216+
"access_token_expires_at": "{{#now_plus}}{{ expires_in }}{{/now_plus}}",
217+
"refresh_token": "/refresh_token"
170218
}
171219
},
172220
"resourcePathPointers": [

0 commit comments

Comments
 (0)