@drawbridge/drawbridge-utils 0.0.111 → 0.0.114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connections/index.cjs +2286 -578
- package/dist/connections/index.d.cts +2012 -571
- package/dist/connections/index.d.ts +2012 -571
- package/dist/connections/index.js +2265 -572
- package/dist/connections/oauth.cjs +49 -50
- package/dist/connections/oauth.d.cts +83 -70
- package/dist/connections/oauth.d.ts +83 -70
- package/dist/connections/oauth.js +47 -47
- package/dist/safe-http.d.cts +1 -1
- package/dist/safe-http.d.ts +1 -1
- package/dist/sendgrid.cjs +168 -0
- package/dist/sendgrid.d.cts +185 -0
- package/dist/sendgrid.d.ts +185 -0
- package/dist/sendgrid.js +140 -0
- package/dist/twilio.cjs +112 -0
- package/dist/twilio.d.cts +64 -0
- package/dist/twilio.d.ts +64 -0
- package/dist/twilio.js +86 -0
- package/package.json +11 -1
|
@@ -19,19 +19,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
// lib/connections/oauth.js
|
|
20
20
|
var oauth_exports = {};
|
|
21
21
|
__export(oauth_exports, {
|
|
22
|
+
authToken: () => authToken,
|
|
22
23
|
consentUrl: () => consentUrl,
|
|
23
|
-
|
|
24
|
-
pkcePair: () => pkcePair,
|
|
25
|
-
refresh: () => refresh
|
|
24
|
+
pkcePair: () => pkcePair
|
|
26
25
|
});
|
|
27
26
|
module.exports = __toCommonJS(oauth_exports);
|
|
28
27
|
var import_node_crypto = require("crypto");
|
|
29
|
-
var credentials = ({ clientId, clientSecret
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
var credentials = ({ basic, clientId, clientSecret }) => basic ? {
|
|
29
|
+
body: {},
|
|
30
|
+
headers: { authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64") }
|
|
32
31
|
} : {
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
body: { client_id: clientId, client_secret: clientSecret },
|
|
33
|
+
headers: {}
|
|
35
34
|
};
|
|
36
35
|
var pkcePair = () => {
|
|
37
36
|
const verifier = (0, import_node_crypto.randomBytes)(32).toString("base64url");
|
|
@@ -42,10 +41,11 @@ var pkcePair = () => {
|
|
|
42
41
|
};
|
|
43
42
|
};
|
|
44
43
|
var consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) => {
|
|
44
|
+
var _a;
|
|
45
45
|
if (!clientId) throw new Error("This deployment has no OAuth client configured, so there is nothing to consent through");
|
|
46
|
-
if (!(descriptor == null ? void 0 : descriptor.authorize)) throw new Error("This connection declares no authorize url");
|
|
46
|
+
if (!((_a = descriptor == null ? void 0 : descriptor.urls) == null ? void 0 : _a.authorize)) throw new Error("This connection declares no authorize url");
|
|
47
47
|
if (descriptor.pkce && !challenge) throw new Error("This connection requires PKCE, so a code challenge is not optional");
|
|
48
|
-
return descriptor.authorize + "?" + new URLSearchParams({
|
|
48
|
+
return descriptor.urls.authorize + "?" + new URLSearchParams({
|
|
49
49
|
// The descriptor's own params go FIRST, so a vendor quirk cannot quietly
|
|
50
50
|
// overwrite one of the fields below that every consent carries.
|
|
51
51
|
...descriptor.params || {},
|
|
@@ -60,17 +60,33 @@ var consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) =>
|
|
|
60
60
|
state
|
|
61
61
|
});
|
|
62
62
|
};
|
|
63
|
-
var
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
var authToken = async ({
|
|
64
|
+
basic,
|
|
65
|
+
clientId,
|
|
66
|
+
clientSecret,
|
|
67
|
+
code,
|
|
68
|
+
descriptor,
|
|
69
|
+
fetcher = fetch,
|
|
70
|
+
redirect,
|
|
71
|
+
refreshToken,
|
|
72
|
+
verifier
|
|
73
|
+
} = {}) => {
|
|
74
|
+
var _a;
|
|
75
|
+
if (!clientId || !clientSecret) throw new Error("This deployment has no OAuth client configured, so no token can be minted");
|
|
76
|
+
if (!((_a = descriptor == null ? void 0 : descriptor.urls) == null ? void 0 : _a.token)) throw new Error("This connection declares no token url");
|
|
77
|
+
const renewing = !code;
|
|
78
|
+
if (renewing && !refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
|
|
79
|
+
if (!renewing && descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
|
|
80
|
+
const client = credentials({ basic, clientId, clientSecret });
|
|
81
|
+
const response = await fetcher(descriptor.urls.token, {
|
|
68
82
|
body: new URLSearchParams({
|
|
69
83
|
...client.body,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
84
|
+
...renewing ? { grant_type: "refresh_token", refresh_token: refreshToken } : {
|
|
85
|
+
code: decodeURIComponent(String(code || "").trim()),
|
|
86
|
+
grant_type: "authorization_code",
|
|
87
|
+
redirect_uri: redirect,
|
|
88
|
+
...descriptor.pkce && { code_verifier: verifier }
|
|
89
|
+
}
|
|
74
90
|
}),
|
|
75
91
|
headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
|
|
76
92
|
method: "POST",
|
|
@@ -78,46 +94,29 @@ var exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetc
|
|
|
78
94
|
});
|
|
79
95
|
const body = await response.json().catch(() => ({}));
|
|
80
96
|
if (!response.ok) {
|
|
81
|
-
throw new Error(
|
|
97
|
+
throw new Error(
|
|
98
|
+
renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
|
|
99
|
+
);
|
|
82
100
|
}
|
|
83
|
-
if (!body.access_token) throw new Error("The vendor returned no access token");
|
|
101
|
+
if (!renewing && !body.access_token) throw new Error("The vendor returned no access token");
|
|
84
102
|
return {
|
|
85
103
|
accessToken: body.access_token,
|
|
86
104
|
expiresIn: body.expires_in || null,
|
|
105
|
+
// A REFRESH TOKEN IS NOT UNIVERSAL. Google returns one only with
|
|
106
|
+
// access_type=offline; Mailchimp's tokens do not expire and it returns none
|
|
107
|
+
// at all, so its absence cannot be an error here.
|
|
108
|
+
//
|
|
109
|
+
// On a RENEWAL the same null matters for the opposite reason: a vendor that
|
|
110
|
+
// rotates returns a new one, and dropping it silently invalidates the stored
|
|
111
|
+
// grant on the NEXT refresh — a failure a day late and nowhere near its
|
|
112
|
+
// cause. tokenSettings() keeps the existing one when this is null.
|
|
87
113
|
refreshToken: body.refresh_token || null,
|
|
88
114
|
scope: body.scope || null
|
|
89
115
|
};
|
|
90
116
|
};
|
|
91
|
-
var refresh = async ({ clientId, clientSecret, descriptor, fetcher = fetch, refreshToken } = {}) => {
|
|
92
|
-
if (!clientId || !clientSecret) throw new Error("This deployment has no OAuth client configured, so no token can be minted");
|
|
93
|
-
if (!refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
|
|
94
|
-
if (!(descriptor == null ? void 0 : descriptor.token)) throw new Error("This connection declares no token url");
|
|
95
|
-
const client = credentials({ clientId, clientSecret, descriptor });
|
|
96
|
-
const response = await fetcher(descriptor.token, {
|
|
97
|
-
body: new URLSearchParams({
|
|
98
|
-
...client.body,
|
|
99
|
-
grant_type: "refresh_token",
|
|
100
|
-
refresh_token: refreshToken
|
|
101
|
-
}),
|
|
102
|
-
headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
|
|
103
|
-
method: "POST",
|
|
104
|
-
signal: AbortSignal.timeout(15e3)
|
|
105
|
-
});
|
|
106
|
-
if (!response.ok) throw new Error("The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection");
|
|
107
|
-
const body = await response.json();
|
|
108
|
-
return {
|
|
109
|
-
accessToken: body.access_token,
|
|
110
|
-
expiresIn: body.expires_in || null,
|
|
111
|
-
// A vendor that rotates its refresh token returns a new one, and dropping
|
|
112
|
-
// it silently invalidates the stored grant on the NEXT refresh rather
|
|
113
|
-
// than this one — a failure a day late and nowhere near its cause.
|
|
114
|
-
refreshToken: body.refresh_token || null
|
|
115
|
-
};
|
|
116
|
-
};
|
|
117
117
|
// Annotate the CommonJS export names for ESM import in node:
|
|
118
118
|
0 && (module.exports = {
|
|
119
|
+
authToken,
|
|
119
120
|
consentUrl,
|
|
120
|
-
|
|
121
|
-
pkcePair,
|
|
122
|
-
refresh
|
|
121
|
+
pkcePair
|
|
123
122
|
});
|
|
@@ -2,19 +2,26 @@ import { randomBytes, createHash } from 'node:crypto';
|
|
|
2
2
|
|
|
3
3
|
// HOW A VENDOR WANTS TO BE TOLD WHO WE ARE. Two conventions, both standard, and
|
|
4
4
|
// vendors genuinely differ: Google takes client_id and client_secret as form
|
|
5
|
-
// fields; Klaviyo requires HTTP Basic and rejects the body form.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
5
|
+
// fields; Klaviyo requires HTTP Basic and rejects the body form.
|
|
6
|
+
//
|
|
7
|
+
// AN ARGUMENT, not a declared manifest key. It used to be
|
|
8
|
+
// auth.oauth.client.headers, justified on the grounds that no hook calls a token
|
|
9
|
+
// endpoint — which described a boundary rather than defending one. The manifest
|
|
10
|
+
// already imported accessToken, so it was already reaching into this machinery,
|
|
11
|
+
// and the flag existed only to configure the one request that was not a hook.
|
|
12
|
+
//
|
|
13
|
+
// Now the vendor passes it, in its own file, where the rest of what makes it
|
|
14
|
+
// unusual already lives.
|
|
15
|
+
const credentials = ({ basic, clientId, clientSecret }) => (
|
|
9
16
|
|
|
10
|
-
|
|
17
|
+
basic
|
|
11
18
|
? {
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
body : {},
|
|
20
|
+
headers : { authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ) }
|
|
14
21
|
}
|
|
15
22
|
: {
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
body : { client_id : clientId, client_secret : clientSecret },
|
|
24
|
+
headers : {}
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
);
|
|
@@ -29,9 +36,9 @@ const credentials = ({ clientId, clientSecret, descriptor }) => (
|
|
|
29
36
|
// auth : {
|
|
30
37
|
// type : 'oauth',
|
|
31
38
|
// oauth : {
|
|
32
|
-
// authorize : 'https://login.mailchimp.com/oauth2/authorize',
|
|
39
|
+
// urls : { authorize : 'https://login.mailchimp.com/oauth2/authorize',
|
|
33
40
|
// client : { id : 'MAILCHIMP_OAUTH_CLIENT_ID', secret : 'MAILCHIMP_OAUTH_CLIENT_SECRET' },
|
|
34
|
-
//
|
|
41
|
+
// redirect : '/api/connection/mailchimp/callback',
|
|
35
42
|
// token : 'https://login.mailchimp.com/oauth2/token',
|
|
36
43
|
// params : { ... }, optional vendor quirks
|
|
37
44
|
// pkce : true, Klaviyo's OAuth 2.1 requires it
|
|
@@ -76,11 +83,11 @@ const consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) =
|
|
|
76
83
|
// fault nobody can fix from a connection page.
|
|
77
84
|
if( ! clientId ) throw new Error( 'This deployment has no OAuth client configured, so there is nothing to consent through' );
|
|
78
85
|
|
|
79
|
-
if( ! descriptor?.authorize ) throw new Error( 'This connection declares no authorize url' );
|
|
86
|
+
if( ! descriptor?.urls?.authorize ) throw new Error( 'This connection declares no authorize url' );
|
|
80
87
|
|
|
81
88
|
if( descriptor.pkce && ! challenge ) throw new Error( 'This connection requires PKCE, so a code challenge is not optional' );
|
|
82
89
|
|
|
83
|
-
return descriptor.authorize + '?' + new URLSearchParams({
|
|
90
|
+
return descriptor.urls.authorize + '?' + new URLSearchParams({
|
|
84
91
|
// The descriptor's own params go FIRST, so a vendor quirk cannot quietly
|
|
85
92
|
// overwrite one of the fields below that every consent carries.
|
|
86
93
|
...( descriptor.params || {} ),
|
|
@@ -97,26 +104,62 @@ const consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) =
|
|
|
97
104
|
|
|
98
105
|
};
|
|
99
106
|
|
|
100
|
-
//
|
|
107
|
+
// THE DEFAULT auth.token — the standard OAuth token request, which is every
|
|
108
|
+
// vendor's until one differs.
|
|
109
|
+
//
|
|
110
|
+
// A manifest declares hooks.auth.token like any other hook. Most point straight
|
|
111
|
+
// here; Klaviyo wraps it to pass `basic`; Mailchimp will wrap it to follow with
|
|
112
|
+
// the /oauth2/metadata call that tells it which data centre the account lives
|
|
113
|
+
// behind. There is no dispatcher and no fallback, because the uniform surface
|
|
114
|
+
// already forbids a manifest from staying silent — hooks.auth.token is always
|
|
115
|
+
// answered, so there is never an absent case to branch on.
|
|
116
|
+
//
|
|
117
|
+
// Minting from a code and minting from a refresh token were two functions doing
|
|
118
|
+
// the same POST to the same url with the same headers and the same response
|
|
119
|
+
// mapping, differing only in body params and error copy — so they are one, and a
|
|
120
|
+
// vendor overriding the call overrides both rather than remembering there were
|
|
121
|
+
// two.
|
|
101
122
|
//
|
|
102
123
|
// The redirect must byte-match what the consent carried — it is registered in
|
|
103
124
|
// the vendor's console — so the caller passes the declared callback and it is
|
|
104
125
|
// used verbatim, never rebuilt from a slug.
|
|
105
|
-
|
|
126
|
+
//
|
|
127
|
+
// `basic` is the vendor's to pass. `fetcher` is injected on every call so tests
|
|
128
|
+
// never open a socket; production passes nothing and gets global fetch.
|
|
129
|
+
const authToken = async ({
|
|
130
|
+
basic,
|
|
131
|
+
clientId,
|
|
132
|
+
clientSecret,
|
|
133
|
+
code,
|
|
134
|
+
descriptor,
|
|
135
|
+
fetcher = fetch,
|
|
136
|
+
redirect,
|
|
137
|
+
refreshToken,
|
|
138
|
+
verifier
|
|
139
|
+
} = {}) => {
|
|
140
|
+
|
|
141
|
+
if( ! clientId || ! clientSecret ) throw new Error( 'This deployment has no OAuth client configured, so no token can be minted' );
|
|
142
|
+
if( ! descriptor?.urls?.token ) throw new Error( 'This connection declares no token url' );
|
|
106
143
|
|
|
107
|
-
|
|
144
|
+
const renewing = ! code;
|
|
108
145
|
|
|
109
|
-
if(
|
|
146
|
+
if( renewing && ! refreshToken ) throw new Error( 'Nothing has been consented to yet, so there is no refresh token to spend' );
|
|
147
|
+
if( ! renewing && descriptor.pkce && ! verifier ) throw new Error( 'This connection requires PKCE, so the code verifier is not optional' );
|
|
110
148
|
|
|
111
|
-
const client = credentials({ clientId, clientSecret
|
|
149
|
+
const client = credentials({ basic, clientId, clientSecret });
|
|
112
150
|
|
|
113
|
-
const response = await fetcher( descriptor.token, {
|
|
151
|
+
const response = await fetcher( descriptor.urls.token, {
|
|
114
152
|
body : new URLSearchParams({
|
|
115
153
|
...client.body,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
154
|
+
...( renewing
|
|
155
|
+
? { grant_type : 'refresh_token', refresh_token : refreshToken }
|
|
156
|
+
: {
|
|
157
|
+
code : decodeURIComponent( String( code || '' ).trim() ),
|
|
158
|
+
grant_type : 'authorization_code',
|
|
159
|
+
redirect_uri : redirect,
|
|
160
|
+
...( descriptor.pkce && { code_verifier : verifier })
|
|
161
|
+
}
|
|
162
|
+
)
|
|
120
163
|
}),
|
|
121
164
|
headers : { 'content-type' : 'application/x-www-form-urlencoded', ...client.headers },
|
|
122
165
|
method : 'POST',
|
|
@@ -127,63 +170,33 @@ const exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fe
|
|
|
127
170
|
|
|
128
171
|
if( ! response.ok ){
|
|
129
172
|
|
|
130
|
-
|
|
173
|
+
// The two paths fail differently for the merchant: a refused exchange is
|
|
174
|
+
// "try connecting again", a refused refresh is "reconnect this connection",
|
|
175
|
+
// and collapsing them sends somebody to the wrong screen.
|
|
176
|
+
throw new Error( renewing
|
|
177
|
+
? 'The vendor refused the refresh token (' + response.status + ') — reconnect the connection'
|
|
178
|
+
: 'The vendor refused the exchange (' + response.status + ')' + ( body?.error ? ': ' + body.error : '' )
|
|
179
|
+
);
|
|
131
180
|
|
|
132
181
|
}
|
|
133
182
|
|
|
134
|
-
if( ! body.access_token ) throw new Error( 'The vendor returned no access token' );
|
|
183
|
+
if( ! renewing && ! body.access_token ) throw new Error( 'The vendor returned no access token' );
|
|
135
184
|
|
|
136
|
-
// A REFRESH TOKEN IS NOT UNIVERSAL. Google returns one only with
|
|
137
|
-
// access_type=offline; Mailchimp's tokens do not expire and it returns none
|
|
138
|
-
// at all. So its absence cannot be an error here the way it is in a
|
|
139
|
-
// refresh-only world — the manifest says whether the vendor issues one by
|
|
140
|
-
// whether it declares the params that ask for it.
|
|
141
185
|
return {
|
|
142
186
|
accessToken : body.access_token,
|
|
143
187
|
expiresIn : body.expires_in || null,
|
|
188
|
+
// A REFRESH TOKEN IS NOT UNIVERSAL. Google returns one only with
|
|
189
|
+
// access_type=offline; Mailchimp's tokens do not expire and it returns none
|
|
190
|
+
// at all, so its absence cannot be an error here.
|
|
191
|
+
//
|
|
192
|
+
// On a RENEWAL the same null matters for the opposite reason: a vendor that
|
|
193
|
+
// rotates returns a new one, and dropping it silently invalidates the stored
|
|
194
|
+
// grant on the NEXT refresh — a failure a day late and nowhere near its
|
|
195
|
+
// cause. tokenSettings() keeps the existing one when this is null.
|
|
144
196
|
refreshToken : body.refresh_token || null,
|
|
145
197
|
scope : body.scope || null
|
|
146
198
|
};
|
|
147
199
|
|
|
148
200
|
};
|
|
149
201
|
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
// THE MINT IS THE PROBE. A revoked, rotated or wrong-account grant fails here in
|
|
153
|
-
// the vendor's own words, rather than as an empty result three steps later that
|
|
154
|
-
// reads like "this account has no data".
|
|
155
|
-
const refresh = async ({ clientId, clientSecret, descriptor, fetcher = fetch, refreshToken } = {}) => {
|
|
156
|
-
|
|
157
|
-
if( ! clientId || ! clientSecret ) throw new Error( 'This deployment has no OAuth client configured, so no token can be minted' );
|
|
158
|
-
if( ! refreshToken ) throw new Error( 'Nothing has been consented to yet, so there is no refresh token to spend' );
|
|
159
|
-
if( ! descriptor?.token ) throw new Error( 'This connection declares no token url' );
|
|
160
|
-
|
|
161
|
-
const client = credentials({ clientId, clientSecret, descriptor });
|
|
162
|
-
|
|
163
|
-
const response = await fetcher( descriptor.token, {
|
|
164
|
-
body : new URLSearchParams({
|
|
165
|
-
...client.body,
|
|
166
|
-
grant_type : 'refresh_token',
|
|
167
|
-
refresh_token : refreshToken
|
|
168
|
-
}),
|
|
169
|
-
headers : { 'content-type' : 'application/x-www-form-urlencoded', ...client.headers },
|
|
170
|
-
method : 'POST',
|
|
171
|
-
signal : AbortSignal.timeout( 15000 )
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
if( ! response.ok ) throw new Error( 'The vendor refused the refresh token (' + response.status + ') — reconnect the connection' );
|
|
175
|
-
|
|
176
|
-
const body = await response.json();
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
accessToken : body.access_token,
|
|
180
|
-
expiresIn : body.expires_in || null,
|
|
181
|
-
// A vendor that rotates its refresh token returns a new one, and dropping
|
|
182
|
-
// it silently invalidates the stored grant on the NEXT refresh rather
|
|
183
|
-
// than this one — a failure a day late and nowhere near its cause.
|
|
184
|
-
refreshToken : body.refresh_token || null
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
export { consentUrl, exchange, pkcePair, refresh };
|
|
202
|
+
export { authToken, consentUrl, pkcePair };
|
|
@@ -2,19 +2,26 @@ import { randomBytes, createHash } from 'node:crypto';
|
|
|
2
2
|
|
|
3
3
|
// HOW A VENDOR WANTS TO BE TOLD WHO WE ARE. Two conventions, both standard, and
|
|
4
4
|
// vendors genuinely differ: Google takes client_id and client_secret as form
|
|
5
|
-
// fields; Klaviyo requires HTTP Basic and rejects the body form.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
5
|
+
// fields; Klaviyo requires HTTP Basic and rejects the body form.
|
|
6
|
+
//
|
|
7
|
+
// AN ARGUMENT, not a declared manifest key. It used to be
|
|
8
|
+
// auth.oauth.client.headers, justified on the grounds that no hook calls a token
|
|
9
|
+
// endpoint — which described a boundary rather than defending one. The manifest
|
|
10
|
+
// already imported accessToken, so it was already reaching into this machinery,
|
|
11
|
+
// and the flag existed only to configure the one request that was not a hook.
|
|
12
|
+
//
|
|
13
|
+
// Now the vendor passes it, in its own file, where the rest of what makes it
|
|
14
|
+
// unusual already lives.
|
|
15
|
+
const credentials = ({ basic, clientId, clientSecret }) => (
|
|
9
16
|
|
|
10
|
-
|
|
17
|
+
basic
|
|
11
18
|
? {
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
body : {},
|
|
20
|
+
headers : { authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ) }
|
|
14
21
|
}
|
|
15
22
|
: {
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
body : { client_id : clientId, client_secret : clientSecret },
|
|
24
|
+
headers : {}
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
);
|
|
@@ -29,9 +36,9 @@ const credentials = ({ clientId, clientSecret, descriptor }) => (
|
|
|
29
36
|
// auth : {
|
|
30
37
|
// type : 'oauth',
|
|
31
38
|
// oauth : {
|
|
32
|
-
// authorize : 'https://login.mailchimp.com/oauth2/authorize',
|
|
39
|
+
// urls : { authorize : 'https://login.mailchimp.com/oauth2/authorize',
|
|
33
40
|
// client : { id : 'MAILCHIMP_OAUTH_CLIENT_ID', secret : 'MAILCHIMP_OAUTH_CLIENT_SECRET' },
|
|
34
|
-
//
|
|
41
|
+
// redirect : '/api/connection/mailchimp/callback',
|
|
35
42
|
// token : 'https://login.mailchimp.com/oauth2/token',
|
|
36
43
|
// params : { ... }, optional vendor quirks
|
|
37
44
|
// pkce : true, Klaviyo's OAuth 2.1 requires it
|
|
@@ -76,11 +83,11 @@ const consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) =
|
|
|
76
83
|
// fault nobody can fix from a connection page.
|
|
77
84
|
if( ! clientId ) throw new Error( 'This deployment has no OAuth client configured, so there is nothing to consent through' );
|
|
78
85
|
|
|
79
|
-
if( ! descriptor?.authorize ) throw new Error( 'This connection declares no authorize url' );
|
|
86
|
+
if( ! descriptor?.urls?.authorize ) throw new Error( 'This connection declares no authorize url' );
|
|
80
87
|
|
|
81
88
|
if( descriptor.pkce && ! challenge ) throw new Error( 'This connection requires PKCE, so a code challenge is not optional' );
|
|
82
89
|
|
|
83
|
-
return descriptor.authorize + '?' + new URLSearchParams({
|
|
90
|
+
return descriptor.urls.authorize + '?' + new URLSearchParams({
|
|
84
91
|
// The descriptor's own params go FIRST, so a vendor quirk cannot quietly
|
|
85
92
|
// overwrite one of the fields below that every consent carries.
|
|
86
93
|
...( descriptor.params || {} ),
|
|
@@ -97,26 +104,62 @@ const consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) =
|
|
|
97
104
|
|
|
98
105
|
};
|
|
99
106
|
|
|
100
|
-
//
|
|
107
|
+
// THE DEFAULT auth.token — the standard OAuth token request, which is every
|
|
108
|
+
// vendor's until one differs.
|
|
109
|
+
//
|
|
110
|
+
// A manifest declares hooks.auth.token like any other hook. Most point straight
|
|
111
|
+
// here; Klaviyo wraps it to pass `basic`; Mailchimp will wrap it to follow with
|
|
112
|
+
// the /oauth2/metadata call that tells it which data centre the account lives
|
|
113
|
+
// behind. There is no dispatcher and no fallback, because the uniform surface
|
|
114
|
+
// already forbids a manifest from staying silent — hooks.auth.token is always
|
|
115
|
+
// answered, so there is never an absent case to branch on.
|
|
116
|
+
//
|
|
117
|
+
// Minting from a code and minting from a refresh token were two functions doing
|
|
118
|
+
// the same POST to the same url with the same headers and the same response
|
|
119
|
+
// mapping, differing only in body params and error copy — so they are one, and a
|
|
120
|
+
// vendor overriding the call overrides both rather than remembering there were
|
|
121
|
+
// two.
|
|
101
122
|
//
|
|
102
123
|
// The redirect must byte-match what the consent carried — it is registered in
|
|
103
124
|
// the vendor's console — so the caller passes the declared callback and it is
|
|
104
125
|
// used verbatim, never rebuilt from a slug.
|
|
105
|
-
|
|
126
|
+
//
|
|
127
|
+
// `basic` is the vendor's to pass. `fetcher` is injected on every call so tests
|
|
128
|
+
// never open a socket; production passes nothing and gets global fetch.
|
|
129
|
+
const authToken = async ({
|
|
130
|
+
basic,
|
|
131
|
+
clientId,
|
|
132
|
+
clientSecret,
|
|
133
|
+
code,
|
|
134
|
+
descriptor,
|
|
135
|
+
fetcher = fetch,
|
|
136
|
+
redirect,
|
|
137
|
+
refreshToken,
|
|
138
|
+
verifier
|
|
139
|
+
} = {}) => {
|
|
140
|
+
|
|
141
|
+
if( ! clientId || ! clientSecret ) throw new Error( 'This deployment has no OAuth client configured, so no token can be minted' );
|
|
142
|
+
if( ! descriptor?.urls?.token ) throw new Error( 'This connection declares no token url' );
|
|
106
143
|
|
|
107
|
-
|
|
144
|
+
const renewing = ! code;
|
|
108
145
|
|
|
109
|
-
if(
|
|
146
|
+
if( renewing && ! refreshToken ) throw new Error( 'Nothing has been consented to yet, so there is no refresh token to spend' );
|
|
147
|
+
if( ! renewing && descriptor.pkce && ! verifier ) throw new Error( 'This connection requires PKCE, so the code verifier is not optional' );
|
|
110
148
|
|
|
111
|
-
const client = credentials({ clientId, clientSecret
|
|
149
|
+
const client = credentials({ basic, clientId, clientSecret });
|
|
112
150
|
|
|
113
|
-
const response = await fetcher( descriptor.token, {
|
|
151
|
+
const response = await fetcher( descriptor.urls.token, {
|
|
114
152
|
body : new URLSearchParams({
|
|
115
153
|
...client.body,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
154
|
+
...( renewing
|
|
155
|
+
? { grant_type : 'refresh_token', refresh_token : refreshToken }
|
|
156
|
+
: {
|
|
157
|
+
code : decodeURIComponent( String( code || '' ).trim() ),
|
|
158
|
+
grant_type : 'authorization_code',
|
|
159
|
+
redirect_uri : redirect,
|
|
160
|
+
...( descriptor.pkce && { code_verifier : verifier })
|
|
161
|
+
}
|
|
162
|
+
)
|
|
120
163
|
}),
|
|
121
164
|
headers : { 'content-type' : 'application/x-www-form-urlencoded', ...client.headers },
|
|
122
165
|
method : 'POST',
|
|
@@ -127,63 +170,33 @@ const exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fe
|
|
|
127
170
|
|
|
128
171
|
if( ! response.ok ){
|
|
129
172
|
|
|
130
|
-
|
|
173
|
+
// The two paths fail differently for the merchant: a refused exchange is
|
|
174
|
+
// "try connecting again", a refused refresh is "reconnect this connection",
|
|
175
|
+
// and collapsing them sends somebody to the wrong screen.
|
|
176
|
+
throw new Error( renewing
|
|
177
|
+
? 'The vendor refused the refresh token (' + response.status + ') — reconnect the connection'
|
|
178
|
+
: 'The vendor refused the exchange (' + response.status + ')' + ( body?.error ? ': ' + body.error : '' )
|
|
179
|
+
);
|
|
131
180
|
|
|
132
181
|
}
|
|
133
182
|
|
|
134
|
-
if( ! body.access_token ) throw new Error( 'The vendor returned no access token' );
|
|
183
|
+
if( ! renewing && ! body.access_token ) throw new Error( 'The vendor returned no access token' );
|
|
135
184
|
|
|
136
|
-
// A REFRESH TOKEN IS NOT UNIVERSAL. Google returns one only with
|
|
137
|
-
// access_type=offline; Mailchimp's tokens do not expire and it returns none
|
|
138
|
-
// at all. So its absence cannot be an error here the way it is in a
|
|
139
|
-
// refresh-only world — the manifest says whether the vendor issues one by
|
|
140
|
-
// whether it declares the params that ask for it.
|
|
141
185
|
return {
|
|
142
186
|
accessToken : body.access_token,
|
|
143
187
|
expiresIn : body.expires_in || null,
|
|
188
|
+
// A REFRESH TOKEN IS NOT UNIVERSAL. Google returns one only with
|
|
189
|
+
// access_type=offline; Mailchimp's tokens do not expire and it returns none
|
|
190
|
+
// at all, so its absence cannot be an error here.
|
|
191
|
+
//
|
|
192
|
+
// On a RENEWAL the same null matters for the opposite reason: a vendor that
|
|
193
|
+
// rotates returns a new one, and dropping it silently invalidates the stored
|
|
194
|
+
// grant on the NEXT refresh — a failure a day late and nowhere near its
|
|
195
|
+
// cause. tokenSettings() keeps the existing one when this is null.
|
|
144
196
|
refreshToken : body.refresh_token || null,
|
|
145
197
|
scope : body.scope || null
|
|
146
198
|
};
|
|
147
199
|
|
|
148
200
|
};
|
|
149
201
|
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
// THE MINT IS THE PROBE. A revoked, rotated or wrong-account grant fails here in
|
|
153
|
-
// the vendor's own words, rather than as an empty result three steps later that
|
|
154
|
-
// reads like "this account has no data".
|
|
155
|
-
const refresh = async ({ clientId, clientSecret, descriptor, fetcher = fetch, refreshToken } = {}) => {
|
|
156
|
-
|
|
157
|
-
if( ! clientId || ! clientSecret ) throw new Error( 'This deployment has no OAuth client configured, so no token can be minted' );
|
|
158
|
-
if( ! refreshToken ) throw new Error( 'Nothing has been consented to yet, so there is no refresh token to spend' );
|
|
159
|
-
if( ! descriptor?.token ) throw new Error( 'This connection declares no token url' );
|
|
160
|
-
|
|
161
|
-
const client = credentials({ clientId, clientSecret, descriptor });
|
|
162
|
-
|
|
163
|
-
const response = await fetcher( descriptor.token, {
|
|
164
|
-
body : new URLSearchParams({
|
|
165
|
-
...client.body,
|
|
166
|
-
grant_type : 'refresh_token',
|
|
167
|
-
refresh_token : refreshToken
|
|
168
|
-
}),
|
|
169
|
-
headers : { 'content-type' : 'application/x-www-form-urlencoded', ...client.headers },
|
|
170
|
-
method : 'POST',
|
|
171
|
-
signal : AbortSignal.timeout( 15000 )
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
if( ! response.ok ) throw new Error( 'The vendor refused the refresh token (' + response.status + ') — reconnect the connection' );
|
|
175
|
-
|
|
176
|
-
const body = await response.json();
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
accessToken : body.access_token,
|
|
180
|
-
expiresIn : body.expires_in || null,
|
|
181
|
-
// A vendor that rotates its refresh token returns a new one, and dropping
|
|
182
|
-
// it silently invalidates the stored grant on the NEXT refresh rather
|
|
183
|
-
// than this one — a failure a day late and nowhere near its cause.
|
|
184
|
-
refreshToken : body.refresh_token || null
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
export { consentUrl, exchange, pkcePair, refresh };
|
|
202
|
+
export { authToken, consentUrl, pkcePair };
|