@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.
@@ -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
- exchange: () => exchange,
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, descriptor }) => (descriptor == null ? void 0 : descriptor.clientAuth) === "basic" ? {
30
- headers: { authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64") },
31
- body: {}
28
+ var credentials = ({ basic, clientId, clientSecret }) => basic ? {
29
+ body: {},
30
+ headers: { authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64") }
32
31
  } : {
33
- headers: {},
34
- body: { client_id: clientId, client_secret: clientSecret }
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 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, {
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
- code: decodeURIComponent(String(code || "").trim()),
71
- grant_type: "authorization_code",
72
- redirect_uri: redirect,
73
- ...descriptor.pkce && { code_verifier: verifier }
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("The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.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
- exchange,
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. 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 }) => (
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
- descriptor?.clientAuth === 'basic'
17
+ basic
11
18
  ? {
12
- headers : { authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ) },
13
- body : {}
19
+ body : {},
20
+ headers : { authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ) }
14
21
  }
15
22
  : {
16
- headers : {},
17
- body : { client_id : clientId, client_secret : clientSecret }
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
- // redirect : '/api/connection/mailchimp/callback',
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
- // The code-for-token trade.
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
- const exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetch, redirect, verifier } = {}) => {
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
- if( ! descriptor?.token ) throw new Error( 'This connection declares no token url' );
144
+ const renewing = ! code;
108
145
 
109
- if( descriptor.pkce && ! verifier ) throw new Error( 'This connection requires PKCE, so the code verifier is not optional' );
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, descriptor });
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
- code : decodeURIComponent( String( code || '' ).trim() ),
117
- grant_type : 'authorization_code',
118
- redirect_uri : redirect,
119
- ...( descriptor.pkce && { code_verifier : verifier })
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
- throw new Error( 'The vendor refused the exchange (' + response.status + ')' + ( body?.error ? ': ' + body.error : '' ) );
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
- // 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 };
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. 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 }) => (
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
- descriptor?.clientAuth === 'basic'
17
+ basic
11
18
  ? {
12
- headers : { authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ) },
13
- body : {}
19
+ body : {},
20
+ headers : { authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ) }
14
21
  }
15
22
  : {
16
- headers : {},
17
- body : { client_id : clientId, client_secret : clientSecret }
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
- // redirect : '/api/connection/mailchimp/callback',
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
- // The code-for-token trade.
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
- const exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetch, redirect, verifier } = {}) => {
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
- if( ! descriptor?.token ) throw new Error( 'This connection declares no token url' );
144
+ const renewing = ! code;
108
145
 
109
- if( descriptor.pkce && ! verifier ) throw new Error( 'This connection requires PKCE, so the code verifier is not optional' );
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, descriptor });
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
- code : decodeURIComponent( String( code || '' ).trim() ),
117
- grant_type : 'authorization_code',
118
- redirect_uri : redirect,
119
- ...( descriptor.pkce && { code_verifier : verifier })
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
- throw new Error( 'The vendor refused the exchange (' + response.status + ')' + ( body?.error ? ': ' + body.error : '' ) );
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
- // 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 };
202
+ export { authToken, consentUrl, pkcePair };