@drawbridge/drawbridge-utils 0.0.106 → 0.0.107
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 +983 -0
- package/dist/connections/index.d.cts +1228 -0
- package/dist/connections/index.d.ts +1228 -0
- package/dist/connections/index.js +932 -0
- package/dist/connections/oauth.cjs +123 -0
- package/dist/connections/oauth.d.cts +189 -0
- package/dist/connections/oauth.d.ts +189 -0
- package/dist/connections/oauth.js +96 -0
- package/package.json +11 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
|
|
19
|
+
// lib/connections/oauth.js
|
|
20
|
+
var oauth_exports = {};
|
|
21
|
+
__export(oauth_exports, {
|
|
22
|
+
consentUrl: () => consentUrl,
|
|
23
|
+
exchange: () => exchange,
|
|
24
|
+
pkcePair: () => pkcePair,
|
|
25
|
+
refresh: () => refresh
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(oauth_exports);
|
|
28
|
+
var import_node_crypto = require("crypto");
|
|
29
|
+
var credentials = ({ clientId, clientSecret, descriptor }) => (descriptor == null ? void 0 : descriptor.clientAuth) === "basic" ? {
|
|
30
|
+
headers: { authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64") },
|
|
31
|
+
body: {}
|
|
32
|
+
} : {
|
|
33
|
+
headers: {},
|
|
34
|
+
body: { client_id: clientId, client_secret: clientSecret }
|
|
35
|
+
};
|
|
36
|
+
var pkcePair = () => {
|
|
37
|
+
const verifier = (0, import_node_crypto.randomBytes)(32).toString("base64url");
|
|
38
|
+
return {
|
|
39
|
+
challenge: (0, import_node_crypto.createHash)("sha256").update(verifier).digest("base64url"),
|
|
40
|
+
method: "S256",
|
|
41
|
+
verifier
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
var consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) => {
|
|
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");
|
|
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({
|
|
49
|
+
// The descriptor's own params go FIRST, so a vendor quirk cannot quietly
|
|
50
|
+
// overwrite one of the fields below that every consent carries.
|
|
51
|
+
...descriptor.params || {},
|
|
52
|
+
client_id: clientId,
|
|
53
|
+
redirect_uri: redirect,
|
|
54
|
+
response_type: "code",
|
|
55
|
+
...descriptor.scopes && { scope: descriptor.scopes },
|
|
56
|
+
...descriptor.pkce && {
|
|
57
|
+
code_challenge: challenge,
|
|
58
|
+
code_challenge_method: "S256"
|
|
59
|
+
},
|
|
60
|
+
state
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
var exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetch, redirect, verifier } = {}) => {
|
|
64
|
+
if (!(descriptor == null ? void 0 : descriptor.token)) throw new Error("This connection declares no token url");
|
|
65
|
+
if (descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
|
|
66
|
+
const client = credentials({ clientId, clientSecret, descriptor });
|
|
67
|
+
const response = await fetcher(descriptor.token, {
|
|
68
|
+
body: new URLSearchParams({
|
|
69
|
+
...client.body,
|
|
70
|
+
code: decodeURIComponent(String(code || "").trim()),
|
|
71
|
+
grant_type: "authorization_code",
|
|
72
|
+
redirect_uri: redirect,
|
|
73
|
+
...descriptor.pkce && { code_verifier: verifier }
|
|
74
|
+
}),
|
|
75
|
+
headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
|
|
76
|
+
method: "POST",
|
|
77
|
+
signal: AbortSignal.timeout(15e3)
|
|
78
|
+
});
|
|
79
|
+
const body = await response.json().catch(() => ({}));
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new Error("The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : ""));
|
|
82
|
+
}
|
|
83
|
+
if (!body.access_token) throw new Error("The vendor returned no access token");
|
|
84
|
+
return {
|
|
85
|
+
accessToken: body.access_token,
|
|
86
|
+
expiresIn: body.expires_in || null,
|
|
87
|
+
refreshToken: body.refresh_token || null,
|
|
88
|
+
scope: body.scope || null
|
|
89
|
+
};
|
|
90
|
+
};
|
|
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
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
118
|
+
0 && (module.exports = {
|
|
119
|
+
consentUrl,
|
|
120
|
+
exchange,
|
|
121
|
+
pkcePair,
|
|
122
|
+
refresh
|
|
123
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// HOW A VENDOR WANTS TO BE TOLD WHO WE ARE. Two conventions, both standard, and
|
|
4
|
+
// vendors genuinely differ: Google takes client_id and client_secret as form
|
|
5
|
+
// fields; Klaviyo requires HTTP Basic and rejects the body form. Declared per
|
|
6
|
+
// vendor rather than branched on a slug, because the second Basic vendor would
|
|
7
|
+
// otherwise add a second branch.
|
|
8
|
+
const credentials = ({ clientId, clientSecret, descriptor }) => (
|
|
9
|
+
|
|
10
|
+
descriptor?.clientAuth === 'basic'
|
|
11
|
+
? {
|
|
12
|
+
headers : { authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ) },
|
|
13
|
+
body : {}
|
|
14
|
+
}
|
|
15
|
+
: {
|
|
16
|
+
headers : {},
|
|
17
|
+
body : { client_id : clientId, client_secret : clientSecret }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// The OAuth flow, once, for every vendor that uses one.
|
|
23
|
+
//
|
|
24
|
+
// A connection declares what it needs and this runs it. Adding an OAuth vendor
|
|
25
|
+
// is a manifest, not a module — which is the whole point: the alternative is
|
|
26
|
+
// what Shopify already is, a vendor whose consent, exchange and callback are
|
|
27
|
+
// hand-written, so a second one copies every line.
|
|
28
|
+
//
|
|
29
|
+
// auth : {
|
|
30
|
+
// type : 'oauth',
|
|
31
|
+
// oauth : {
|
|
32
|
+
// authorize : 'https://login.mailchimp.com/oauth2/authorize',
|
|
33
|
+
// client : { id : 'MAILCHIMP_OAUTH_CLIENT_ID', secret : 'MAILCHIMP_OAUTH_CLIENT_SECRET' },
|
|
34
|
+
// redirect : '/api/connection/mailchimp/callback',
|
|
35
|
+
// token : 'https://login.mailchimp.com/oauth2/token',
|
|
36
|
+
// params : { ... }, optional vendor quirks
|
|
37
|
+
// pkce : true, Klaviyo's OAuth 2.1 requires it
|
|
38
|
+
// scopes : '...' optional; Mailchimp has none
|
|
39
|
+
// }
|
|
40
|
+
// }
|
|
41
|
+
//
|
|
42
|
+
// NO CREDENTIAL LIVES HERE OR IN A MANIFEST. `client` NAMES the environment
|
|
43
|
+
// variables holding OUR application's client — one identity, every merchant,
|
|
44
|
+
// which is what an OAuth client is. The token belongs to the merchant and
|
|
45
|
+
// arrives from their own consent, and that distinction is what stops one
|
|
46
|
+
// organization reading another's data.
|
|
47
|
+
//
|
|
48
|
+
// `fetcher` is injected on every call so tests never open a socket. Production
|
|
49
|
+
// passes nothing and gets global fetch.
|
|
50
|
+
|
|
51
|
+
// PKCE, for the vendors that demand it. Klaviyo's OAuth 2.1 refuses an exchange
|
|
52
|
+
// without it: a random verifier is held by us, its SHA-256 challenge travels
|
|
53
|
+
// with the consent, and the vendor checks they match — which is what stops an
|
|
54
|
+
// intercepted code being redeemed by somebody else.
|
|
55
|
+
//
|
|
56
|
+
// 43-128 characters of unreserved alphabet, per RFC 7636. base64url of 32 bytes
|
|
57
|
+
// lands at 43 and needs no padding stripped beyond the two substitutions.
|
|
58
|
+
const pkcePair = () => {
|
|
59
|
+
|
|
60
|
+
const verifier = randomBytes( 32 ).toString( 'base64url' );
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
challenge : createHash( 'sha256' ).update( verifier ).digest( 'base64url' ),
|
|
64
|
+
method : 'S256',
|
|
65
|
+
verifier
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// The url a merchant follows. Side-effect free by construction — building it
|
|
71
|
+
// writes nothing, which is what lets a card render it as a link rather than a
|
|
72
|
+
// button that mints a consent on click.
|
|
73
|
+
const consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) => {
|
|
74
|
+
|
|
75
|
+
// A url naming no application is not a consent, and it reports a deployment
|
|
76
|
+
// fault nobody can fix from a connection page.
|
|
77
|
+
if( ! clientId ) throw new Error( 'This deployment has no OAuth client configured, so there is nothing to consent through' );
|
|
78
|
+
|
|
79
|
+
if( ! descriptor?.authorize ) throw new Error( 'This connection declares no authorize url' );
|
|
80
|
+
|
|
81
|
+
if( descriptor.pkce && ! challenge ) throw new Error( 'This connection requires PKCE, so a code challenge is not optional' );
|
|
82
|
+
|
|
83
|
+
return descriptor.authorize + '?' + new URLSearchParams({
|
|
84
|
+
// The descriptor's own params go FIRST, so a vendor quirk cannot quietly
|
|
85
|
+
// overwrite one of the fields below that every consent carries.
|
|
86
|
+
...( descriptor.params || {} ),
|
|
87
|
+
client_id : clientId,
|
|
88
|
+
redirect_uri : redirect,
|
|
89
|
+
response_type : 'code',
|
|
90
|
+
...( descriptor.scopes && { scope : descriptor.scopes }),
|
|
91
|
+
...( descriptor.pkce && {
|
|
92
|
+
code_challenge : challenge,
|
|
93
|
+
code_challenge_method : 'S256'
|
|
94
|
+
}),
|
|
95
|
+
state
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// The code-for-token trade.
|
|
101
|
+
//
|
|
102
|
+
// The redirect must byte-match what the consent carried — it is registered in
|
|
103
|
+
// the vendor's console — so the caller passes the declared callback and it is
|
|
104
|
+
// used verbatim, never rebuilt from a slug.
|
|
105
|
+
const exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetch, redirect, verifier } = {}) => {
|
|
106
|
+
|
|
107
|
+
if( ! descriptor?.token ) throw new Error( 'This connection declares no token url' );
|
|
108
|
+
|
|
109
|
+
if( descriptor.pkce && ! verifier ) throw new Error( 'This connection requires PKCE, so the code verifier is not optional' );
|
|
110
|
+
|
|
111
|
+
const client = credentials({ clientId, clientSecret, descriptor });
|
|
112
|
+
|
|
113
|
+
const response = await fetcher( descriptor.token, {
|
|
114
|
+
body : new URLSearchParams({
|
|
115
|
+
...client.body,
|
|
116
|
+
code : decodeURIComponent( String( code || '' ).trim() ),
|
|
117
|
+
grant_type : 'authorization_code',
|
|
118
|
+
redirect_uri : redirect,
|
|
119
|
+
...( descriptor.pkce && { code_verifier : verifier })
|
|
120
|
+
}),
|
|
121
|
+
headers : { 'content-type' : 'application/x-www-form-urlencoded', ...client.headers },
|
|
122
|
+
method : 'POST',
|
|
123
|
+
signal : AbortSignal.timeout( 15000 )
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const body = await response.json().catch( () => ({}) );
|
|
127
|
+
|
|
128
|
+
if( ! response.ok ){
|
|
129
|
+
|
|
130
|
+
throw new Error( 'The vendor refused the exchange (' + response.status + ')' + ( body?.error ? ': ' + body.error : '' ) );
|
|
131
|
+
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if( ! body.access_token ) throw new Error( 'The vendor returned no access token' );
|
|
135
|
+
|
|
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
|
+
return {
|
|
142
|
+
accessToken : body.access_token,
|
|
143
|
+
expiresIn : body.expires_in || null,
|
|
144
|
+
refreshToken : body.refresh_token || null,
|
|
145
|
+
scope : body.scope || null
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Short-lived access, minted from a stored refresh token.
|
|
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 };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// HOW A VENDOR WANTS TO BE TOLD WHO WE ARE. Two conventions, both standard, and
|
|
4
|
+
// vendors genuinely differ: Google takes client_id and client_secret as form
|
|
5
|
+
// fields; Klaviyo requires HTTP Basic and rejects the body form. Declared per
|
|
6
|
+
// vendor rather than branched on a slug, because the second Basic vendor would
|
|
7
|
+
// otherwise add a second branch.
|
|
8
|
+
const credentials = ({ clientId, clientSecret, descriptor }) => (
|
|
9
|
+
|
|
10
|
+
descriptor?.clientAuth === 'basic'
|
|
11
|
+
? {
|
|
12
|
+
headers : { authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ) },
|
|
13
|
+
body : {}
|
|
14
|
+
}
|
|
15
|
+
: {
|
|
16
|
+
headers : {},
|
|
17
|
+
body : { client_id : clientId, client_secret : clientSecret }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// The OAuth flow, once, for every vendor that uses one.
|
|
23
|
+
//
|
|
24
|
+
// A connection declares what it needs and this runs it. Adding an OAuth vendor
|
|
25
|
+
// is a manifest, not a module — which is the whole point: the alternative is
|
|
26
|
+
// what Shopify already is, a vendor whose consent, exchange and callback are
|
|
27
|
+
// hand-written, so a second one copies every line.
|
|
28
|
+
//
|
|
29
|
+
// auth : {
|
|
30
|
+
// type : 'oauth',
|
|
31
|
+
// oauth : {
|
|
32
|
+
// authorize : 'https://login.mailchimp.com/oauth2/authorize',
|
|
33
|
+
// client : { id : 'MAILCHIMP_OAUTH_CLIENT_ID', secret : 'MAILCHIMP_OAUTH_CLIENT_SECRET' },
|
|
34
|
+
// redirect : '/api/connection/mailchimp/callback',
|
|
35
|
+
// token : 'https://login.mailchimp.com/oauth2/token',
|
|
36
|
+
// params : { ... }, optional vendor quirks
|
|
37
|
+
// pkce : true, Klaviyo's OAuth 2.1 requires it
|
|
38
|
+
// scopes : '...' optional; Mailchimp has none
|
|
39
|
+
// }
|
|
40
|
+
// }
|
|
41
|
+
//
|
|
42
|
+
// NO CREDENTIAL LIVES HERE OR IN A MANIFEST. `client` NAMES the environment
|
|
43
|
+
// variables holding OUR application's client — one identity, every merchant,
|
|
44
|
+
// which is what an OAuth client is. The token belongs to the merchant and
|
|
45
|
+
// arrives from their own consent, and that distinction is what stops one
|
|
46
|
+
// organization reading another's data.
|
|
47
|
+
//
|
|
48
|
+
// `fetcher` is injected on every call so tests never open a socket. Production
|
|
49
|
+
// passes nothing and gets global fetch.
|
|
50
|
+
|
|
51
|
+
// PKCE, for the vendors that demand it. Klaviyo's OAuth 2.1 refuses an exchange
|
|
52
|
+
// without it: a random verifier is held by us, its SHA-256 challenge travels
|
|
53
|
+
// with the consent, and the vendor checks they match — which is what stops an
|
|
54
|
+
// intercepted code being redeemed by somebody else.
|
|
55
|
+
//
|
|
56
|
+
// 43-128 characters of unreserved alphabet, per RFC 7636. base64url of 32 bytes
|
|
57
|
+
// lands at 43 and needs no padding stripped beyond the two substitutions.
|
|
58
|
+
const pkcePair = () => {
|
|
59
|
+
|
|
60
|
+
const verifier = randomBytes( 32 ).toString( 'base64url' );
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
challenge : createHash( 'sha256' ).update( verifier ).digest( 'base64url' ),
|
|
64
|
+
method : 'S256',
|
|
65
|
+
verifier
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// The url a merchant follows. Side-effect free by construction — building it
|
|
71
|
+
// writes nothing, which is what lets a card render it as a link rather than a
|
|
72
|
+
// button that mints a consent on click.
|
|
73
|
+
const consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) => {
|
|
74
|
+
|
|
75
|
+
// A url naming no application is not a consent, and it reports a deployment
|
|
76
|
+
// fault nobody can fix from a connection page.
|
|
77
|
+
if( ! clientId ) throw new Error( 'This deployment has no OAuth client configured, so there is nothing to consent through' );
|
|
78
|
+
|
|
79
|
+
if( ! descriptor?.authorize ) throw new Error( 'This connection declares no authorize url' );
|
|
80
|
+
|
|
81
|
+
if( descriptor.pkce && ! challenge ) throw new Error( 'This connection requires PKCE, so a code challenge is not optional' );
|
|
82
|
+
|
|
83
|
+
return descriptor.authorize + '?' + new URLSearchParams({
|
|
84
|
+
// The descriptor's own params go FIRST, so a vendor quirk cannot quietly
|
|
85
|
+
// overwrite one of the fields below that every consent carries.
|
|
86
|
+
...( descriptor.params || {} ),
|
|
87
|
+
client_id : clientId,
|
|
88
|
+
redirect_uri : redirect,
|
|
89
|
+
response_type : 'code',
|
|
90
|
+
...( descriptor.scopes && { scope : descriptor.scopes }),
|
|
91
|
+
...( descriptor.pkce && {
|
|
92
|
+
code_challenge : challenge,
|
|
93
|
+
code_challenge_method : 'S256'
|
|
94
|
+
}),
|
|
95
|
+
state
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// The code-for-token trade.
|
|
101
|
+
//
|
|
102
|
+
// The redirect must byte-match what the consent carried — it is registered in
|
|
103
|
+
// the vendor's console — so the caller passes the declared callback and it is
|
|
104
|
+
// used verbatim, never rebuilt from a slug.
|
|
105
|
+
const exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetch, redirect, verifier } = {}) => {
|
|
106
|
+
|
|
107
|
+
if( ! descriptor?.token ) throw new Error( 'This connection declares no token url' );
|
|
108
|
+
|
|
109
|
+
if( descriptor.pkce && ! verifier ) throw new Error( 'This connection requires PKCE, so the code verifier is not optional' );
|
|
110
|
+
|
|
111
|
+
const client = credentials({ clientId, clientSecret, descriptor });
|
|
112
|
+
|
|
113
|
+
const response = await fetcher( descriptor.token, {
|
|
114
|
+
body : new URLSearchParams({
|
|
115
|
+
...client.body,
|
|
116
|
+
code : decodeURIComponent( String( code || '' ).trim() ),
|
|
117
|
+
grant_type : 'authorization_code',
|
|
118
|
+
redirect_uri : redirect,
|
|
119
|
+
...( descriptor.pkce && { code_verifier : verifier })
|
|
120
|
+
}),
|
|
121
|
+
headers : { 'content-type' : 'application/x-www-form-urlencoded', ...client.headers },
|
|
122
|
+
method : 'POST',
|
|
123
|
+
signal : AbortSignal.timeout( 15000 )
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const body = await response.json().catch( () => ({}) );
|
|
127
|
+
|
|
128
|
+
if( ! response.ok ){
|
|
129
|
+
|
|
130
|
+
throw new Error( 'The vendor refused the exchange (' + response.status + ')' + ( body?.error ? ': ' + body.error : '' ) );
|
|
131
|
+
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if( ! body.access_token ) throw new Error( 'The vendor returned no access token' );
|
|
135
|
+
|
|
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
|
+
return {
|
|
142
|
+
accessToken : body.access_token,
|
|
143
|
+
expiresIn : body.expires_in || null,
|
|
144
|
+
refreshToken : body.refresh_token || null,
|
|
145
|
+
scope : body.scope || null
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Short-lived access, minted from a stored refresh token.
|
|
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 };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// lib/connections/oauth.js
|
|
2
|
+
import { createHash, randomBytes } from "crypto";
|
|
3
|
+
var credentials = ({ clientId, clientSecret, descriptor }) => (descriptor == null ? void 0 : descriptor.clientAuth) === "basic" ? {
|
|
4
|
+
headers: { authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64") },
|
|
5
|
+
body: {}
|
|
6
|
+
} : {
|
|
7
|
+
headers: {},
|
|
8
|
+
body: { client_id: clientId, client_secret: clientSecret }
|
|
9
|
+
};
|
|
10
|
+
var pkcePair = () => {
|
|
11
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
12
|
+
return {
|
|
13
|
+
challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
14
|
+
method: "S256",
|
|
15
|
+
verifier
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
var consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) => {
|
|
19
|
+
if (!clientId) throw new Error("This deployment has no OAuth client configured, so there is nothing to consent through");
|
|
20
|
+
if (!(descriptor == null ? void 0 : descriptor.authorize)) throw new Error("This connection declares no authorize url");
|
|
21
|
+
if (descriptor.pkce && !challenge) throw new Error("This connection requires PKCE, so a code challenge is not optional");
|
|
22
|
+
return descriptor.authorize + "?" + new URLSearchParams({
|
|
23
|
+
// The descriptor's own params go FIRST, so a vendor quirk cannot quietly
|
|
24
|
+
// overwrite one of the fields below that every consent carries.
|
|
25
|
+
...descriptor.params || {},
|
|
26
|
+
client_id: clientId,
|
|
27
|
+
redirect_uri: redirect,
|
|
28
|
+
response_type: "code",
|
|
29
|
+
...descriptor.scopes && { scope: descriptor.scopes },
|
|
30
|
+
...descriptor.pkce && {
|
|
31
|
+
code_challenge: challenge,
|
|
32
|
+
code_challenge_method: "S256"
|
|
33
|
+
},
|
|
34
|
+
state
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
var exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetch, redirect, verifier } = {}) => {
|
|
38
|
+
if (!(descriptor == null ? void 0 : descriptor.token)) throw new Error("This connection declares no token url");
|
|
39
|
+
if (descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
|
|
40
|
+
const client = credentials({ clientId, clientSecret, descriptor });
|
|
41
|
+
const response = await fetcher(descriptor.token, {
|
|
42
|
+
body: new URLSearchParams({
|
|
43
|
+
...client.body,
|
|
44
|
+
code: decodeURIComponent(String(code || "").trim()),
|
|
45
|
+
grant_type: "authorization_code",
|
|
46
|
+
redirect_uri: redirect,
|
|
47
|
+
...descriptor.pkce && { code_verifier: verifier }
|
|
48
|
+
}),
|
|
49
|
+
headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
|
|
50
|
+
method: "POST",
|
|
51
|
+
signal: AbortSignal.timeout(15e3)
|
|
52
|
+
});
|
|
53
|
+
const body = await response.json().catch(() => ({}));
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new Error("The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : ""));
|
|
56
|
+
}
|
|
57
|
+
if (!body.access_token) throw new Error("The vendor returned no access token");
|
|
58
|
+
return {
|
|
59
|
+
accessToken: body.access_token,
|
|
60
|
+
expiresIn: body.expires_in || null,
|
|
61
|
+
refreshToken: body.refresh_token || null,
|
|
62
|
+
scope: body.scope || null
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
var refresh = async ({ clientId, clientSecret, descriptor, fetcher = fetch, refreshToken } = {}) => {
|
|
66
|
+
if (!clientId || !clientSecret) throw new Error("This deployment has no OAuth client configured, so no token can be minted");
|
|
67
|
+
if (!refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
|
|
68
|
+
if (!(descriptor == null ? void 0 : descriptor.token)) throw new Error("This connection declares no token url");
|
|
69
|
+
const client = credentials({ clientId, clientSecret, descriptor });
|
|
70
|
+
const response = await fetcher(descriptor.token, {
|
|
71
|
+
body: new URLSearchParams({
|
|
72
|
+
...client.body,
|
|
73
|
+
grant_type: "refresh_token",
|
|
74
|
+
refresh_token: refreshToken
|
|
75
|
+
}),
|
|
76
|
+
headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
|
|
77
|
+
method: "POST",
|
|
78
|
+
signal: AbortSignal.timeout(15e3)
|
|
79
|
+
});
|
|
80
|
+
if (!response.ok) throw new Error("The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection");
|
|
81
|
+
const body = await response.json();
|
|
82
|
+
return {
|
|
83
|
+
accessToken: body.access_token,
|
|
84
|
+
expiresIn: body.expires_in || null,
|
|
85
|
+
// A vendor that rotates its refresh token returns a new one, and dropping
|
|
86
|
+
// it silently invalidates the stored grant on the NEXT refresh rather
|
|
87
|
+
// than this one — a failure a day late and nowhere near its cause.
|
|
88
|
+
refreshToken: body.refresh_token || null
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
export {
|
|
92
|
+
consentUrl,
|
|
93
|
+
exchange,
|
|
94
|
+
pkcePair,
|
|
95
|
+
refresh
|
|
96
|
+
};
|
package/package.json
CHANGED
|
@@ -172,6 +172,16 @@
|
|
|
172
172
|
"types": "./dist/fields-validate.d.ts",
|
|
173
173
|
"import": "./dist/fields-validate.js",
|
|
174
174
|
"require": "./dist/fields-validate.cjs"
|
|
175
|
+
},
|
|
176
|
+
"./connections": {
|
|
177
|
+
"types": "./dist/connections/index.d.ts",
|
|
178
|
+
"import": "./dist/connections/index.js",
|
|
179
|
+
"require": "./dist/connections/index.cjs"
|
|
180
|
+
},
|
|
181
|
+
"./connections/oauth": {
|
|
182
|
+
"types": "./dist/connections/oauth.d.ts",
|
|
183
|
+
"import": "./dist/connections/oauth.js",
|
|
184
|
+
"require": "./dist/connections/oauth.cjs"
|
|
175
185
|
}
|
|
176
186
|
},
|
|
177
187
|
"files": [
|
|
@@ -190,5 +200,5 @@
|
|
|
190
200
|
"test": ". \"$HOME/.nvm/nvm.sh\" && nvm use && node --test"
|
|
191
201
|
},
|
|
192
202
|
"types": "dist/index.d.ts",
|
|
193
|
-
"version": "0.0.
|
|
203
|
+
"version": "0.0.107"
|
|
194
204
|
}
|