@small-tech/auto-encrypt 4.3.0 → 5.0.0

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.
@@ -1,15 +1,13 @@
1
1
  /**
2
- * Abstract base request class for carrying out signed ACME requests over HTTPS.
3
- *
4
- * @module
5
- * @copyright Copyright © 2020 Aral Balkan, Small Technology Foundation.
6
- * @license AGPLv3 or later.
7
- */
2
+ Abstract base request class for carrying out signed ACME requests over HTTPS.
3
+
4
+ @module
5
+ @copyright Copyright © 2020 Aral Balkan, Small Technology Foundation.
6
+ @license AGPLv3 or later.
7
+ */
8
8
 
9
9
  import packageJson from '../package.json' with { type: 'json' }
10
- import jose from 'jose'
11
- import prepareRequest from 'bent'
12
- import types from '../typedefs/lib/AcmeRequest.js'
10
+ import crypto from 'node:crypto'
13
11
  import Nonce from './Nonce.js'
14
12
  import Throws from './util/Throws.js'
15
13
  import log from './util/log.js'
@@ -21,40 +19,57 @@ const throws = new Throws({
21
19
  [Symbol.for('AcmeRequest.accountNotSetError')]:
22
20
  () => 'You cannot issue calls that require an account KeyId without first injecting a reference to the account',
23
21
 
24
- [Symbol.for('AcmeRequest.requestError')]: error => `(${error.status} ${error.type} ${error.detail})`
22
+ [Symbol.for('AcmeRequest.requestError')]:
23
+ (/** @type {{ status: number, type: string, detail: string }} */ error) => `(${error.status} ${error.type} ${error.detail})`
25
24
  })
26
25
 
27
26
  /**
28
- * Abstract base request class for carrying out signed ACME requests over HTTPS.
29
- *
30
- * @alias module:lib/AcmeRequest
31
- */
27
+ Abstract base request class for carrying out signed ACME requests over HTTPS.
28
+
29
+ @alias module:lib/AcmeRequest
30
+ */
32
31
  export default class AcmeRequest {
33
32
  static initialised = false
33
+ static initialisationPromise = null
34
+
35
+ /** @import('./Directory.js').default */
34
36
  static directory = null
37
+
38
+ /** @import('./identities/AccountIdentity.js').default */
35
39
  static accountIdentity = null
40
+
36
41
  static nonce = null
37
42
  static __account = null
38
43
  /** @type {string} */
39
44
  static autoEncryptVersion = packageJson.version
40
45
 
46
+ /**
47
+ Initialise the AcmeRequest class with the directory and account identity.
48
+
49
+ @param {import('./Directory.js').default|void} directory - (Required) The ACME directory.
50
+
51
+ @param {import('./identities/AccountIdentity.js').default|void} accountIdentity - (Required) The account identity.
52
+ */
41
53
  static initialise (directory = throws.ifMissing(), accountIdentity = throws.ifMissing()) {
42
- this.directory = directory
43
- this.accountIdentity = accountIdentity
44
- this.nonce = new Nonce(directory)
45
- this.initialised = true
54
+ AcmeRequest.directory = /** @type { import('./Directory.js').default } */ (directory)
55
+ AcmeRequest.accountIdentity = accountIdentity
56
+ AcmeRequest.nonce = new Nonce(AcmeRequest.directory)
57
+ AcmeRequest.initialised = true
46
58
  }
47
59
 
48
60
  static uninitialise () {
49
- this.directory = null
50
- this.accountIdentity = null
51
- this.nonce = null
52
- this.__account = null
53
- this.initialised = false
61
+ AcmeRequest.directory = null
62
+ AcmeRequest.accountIdentity = null
63
+ AcmeRequest.nonce = null
64
+ AcmeRequest.__account = null
65
+ AcmeRequest.initialised = false
66
+ AcmeRequest.initialisationPromise = null
54
67
  }
55
68
 
56
- static set account (_account = throws.ifMissing()) { this.__account = _account }
57
- static get account () { return this.__account }
69
+ static set account (/** import('./Account.js').default */ _account) { AcmeRequest.__account = _account }
70
+
71
+ /** @returns { import('./Account.js').default } */
72
+ static get account () { return AcmeRequest.__account }
58
73
 
59
74
  constructor () {
60
75
  if (!AcmeRequest.initialised) {
@@ -63,17 +78,21 @@ export default class AcmeRequest {
63
78
  }
64
79
 
65
80
  /**
66
- * Executes a remote Let’s Encrypt command and either returns the result or throws.
67
- *
68
- * @param {String} command Name of {@link Directory} command to invoke e.g. 'newAccount'
69
- * @param {Object|String} payload Object to use as payload. For no payload, pass empty string.
70
- * @param {Boolean} useKid Use Key ID (true) or public JWK (false) (see RFC 8555 § 6.2).
71
- * @param {Number[]} [successCodes=[200]] Return codes accepted as success. Any other code throws.
72
- * @param {String} [url=null] If specified, use this URL, ignoring the command parameter.
73
- * @param {Boolean} [parseResponseBodyAsJSON=true] Parse response body as JSON (true) or as string (false).
74
- * @returns {types.ResponseObject}
75
- */
76
- async execute (
81
+ Executes a remote Let’s Encrypt command and either returns the result or throws.
82
+
83
+ @param {string|void} command - (Required) Name of {@link Directory} command to invoke e.g. 'newAccount'.
84
+
85
+ @param {Object|string|void} payload - (Required) Object to use as payload. For no payload, pass empty string.
86
+
87
+ @param {boolean} [useKid=true] - Use Key ID (true) or public JWK (false) (see RFC 8555 § 6.2).
88
+
89
+ @param {Number[]} [successCodes=[200]] - Optional array of codes that signals success. Any other code throws.
90
+
91
+ @param {string} [url=null] - If specified, use this URL, ignoring the command parameter.
92
+
93
+ @param {boolean} [parseResponseBodyAsJSON=true] - Parse response body as JSON (true) or as string (false).
94
+ */
95
+ async request (
77
96
  command = throws.ifMissing(),
78
97
  payload = throws.ifMissing(),
79
98
  useKid = true,
@@ -83,76 +102,90 @@ export default class AcmeRequest {
83
102
  ) {
84
103
  if (useKid && AcmeRequest.account === null) { throws.error(Symbol.for('AcmeRequest.accountNotSetError')) }
85
104
 
86
- const preparedRequest = await this.prepare(command, payload, useKid, successCodes, url)
105
+ const preparedRequest = await this.prepare(/** @type { string } */ (command), payload, useKid, successCodes, url)
87
106
  const responseObject = await this._execute(preparedRequest, parseResponseBodyAsJSON)
88
107
  return responseObject
89
108
  }
90
109
 
91
110
  /**
92
- * Executes a prepared request.
93
- *
94
- * @param {types.PreparedRequest} preparedRequest The prepared request, ready to be executed.
95
- * @param {Boolean} parseResponseBodyAsJSON Should the request body be parsed as JSON (true) or should
96
- * the native response object be returned (false).
97
- * @returns {types.ResponseObject}
98
- */
111
+ Executes a prepared request.
112
+
113
+ @param {Awaited<ReturnType<typeof this.prepare>>|void} preparedRequest - (Required) The prepared request, ready to be executed.
114
+
115
+ @param {boolean|void} parseResponseBodyAsJSON - (Required) Should the request body be parsed as JSON (true) or should the native response object be returned (false).
116
+ */
99
117
  async _execute (preparedRequest = throws.ifMissing(), parseResponseBodyAsJSON = throws.ifMissing()) {
100
- const { signedRequest, httpsRequest, httpsHeaders, originalRequestDetails } = preparedRequest
118
+ const { signedRequest, httpsRequest, httpsHeaders, originalRequestDetails } = /** @type {Awaited<ReturnType<typeof this.prepare>>} */ (preparedRequest)
101
119
 
102
120
  let response, errorHeaders, errorBody
103
121
  try {
104
122
  response = await httpsRequest('', signedRequest, httpsHeaders)
105
123
  } catch (error) {
106
124
  errorBody = error.responseBody
107
- errorHeaders = error.responseHeaders
108
- }
125
+ errorHeaders = error.headers
126
+
127
+ // The error body is a promise. Wait for it to resolve.
128
+ if (errorBody) {
129
+ const errorBodyBuffer = await errorBody
130
+ const errorBodyString = Buffer.from(errorBodyBuffer).toString('utf-8')
131
+
132
+ // If the error body is JSON (i.e., as expected to be returned from Let’s Encrypt),
133
+ // handle it. If not (for whatever reason), still handle the error gracefully.
134
+ let acmeError = null
135
+ try {
136
+ acmeError = JSON.parse(errorBodyString)
137
+ } catch (_) {
138
+ acmeError = {
139
+ status: -1,
140
+ type: 'Unexpected error',
141
+ detail: errorBodyString
142
+ }
143
+ }
109
144
 
110
- // The error body is a promise. Wait for it to resolve.
111
- if (errorBody) {
112
- const errorBodyBuffer = await errorBody
113
-
114
- // If the error body is JSON (i.e., as expected to be returned from Let’s Encrypt),
115
- // handle it. If not (for whatever reason), still handle the error gracefully.
116
- let error = null
117
- const errorBodyString = errorBodyBuffer.toString('utf-8')
118
-
119
- try {
120
- error = JSON.parse(errorBodyString)
121
- } catch (_) {
122
- error = {
123
- status: -1,
124
- type: 'Unexpected error',
125
- detail: errorBodyString
145
+ // If it’s valid JSON but doesn’t have the expected ACME error fields, wrap it.
146
+ if (acmeError && acmeError.status === undefined && acmeError.type === undefined) {
147
+ acmeError = {
148
+ status: -1,
149
+ type: 'Unexpected JSON error',
150
+ detail: errorBodyString
151
+ }
126
152
  }
127
- }
128
153
 
129
- // According to RFC 8555 § 6.5, a bad nonce error should result in retry attempt.
130
- if (error.status === 400 && error.type === 'urn:ietf:params:acme:error:badNonce') {
131
- log(' 🔄 ❨auto-encrypt❩ Server returned a bad nonce error. Retrying with provided nonce. (RFC 8555 § 6.5)')
132
- const serverProvidedNonce = errorHeaders['replay-nonce']
133
-
134
- // Take the original request details (arguments array passed to the prepare() method) and
135
- // re-prepare and retry the request, replacing the nonce (last argument), with the one provided
136
- // by the ACME server.
137
- const originalRequestWithServerProvidedNonce = originalRequestDetails
138
- originalRequestWithServerProvidedNonce[originalRequestWithServerProvidedNonce.length-1] = serverProvidedNonce
139
-
140
- return await this._execute(
141
- await this.prepare(...originalRequestWithServerProvidedNonce),
142
- parseResponseBodyAsJSON
143
- )
154
+ // According to RFC 8555 § 6.5, a bad nonce error should result in retry attempt.
155
+ if (acmeError.status === 400 && acmeError.type === 'urn:ietf:params:acme:error:badNonce') {
156
+ log(' 🔄 ❨auto-encrypt❩ Server returned a bad nonce error. Retrying with provided nonce. (RFC 8555 § 6.5)')
157
+ const serverProvidedNonce = errorHeaders && errorHeaders['replay-nonce']
158
+
159
+ if (!serverProvidedNonce) {
160
+ throws.error(Symbol.for('AcmeRequest.requestError'), acmeError)
161
+ }
162
+
163
+ // Take the original request details (arguments array passed to the prepare() method) and
164
+ // re-prepare and retry the request, replacing the nonce (last argument), with the one provided
165
+ // by the ACME server.
166
+ const originalRequestWithServerProvidedNonce = [...originalRequestDetails]
167
+ originalRequestWithServerProvidedNonce[originalRequestWithServerProvidedNonce.length - 1] = serverProvidedNonce
168
+
169
+ return await this._execute(
170
+ await this.prepare(...originalRequestWithServerProvidedNonce),
171
+ parseResponseBodyAsJSON
172
+ )
173
+ }
174
+
175
+ throws.error(Symbol.for('AcmeRequest.requestError'), acmeError)
144
176
  }
145
177
 
146
- throws.error(Symbol.for('AcmeRequest.requestError'), error)
178
+ // If we got here, it's a non-ACME error (e.g. network error).
179
+ throw error
147
180
  }
148
181
 
149
182
  // Always save the fresh nonce returned from API calls.
150
- const freshNonce = response.headers['replay-nonce']
183
+ const freshNonce = response.headers.get('replay-nonce')
151
184
  AcmeRequest.nonce.set(freshNonce)
152
185
 
153
186
  // The response returned is the raw response object. Let’s consume
154
187
  // it and return a more relevant response.
155
- const headers = response.headers
188
+ const headers = Object.fromEntries(response.headers.entries())
156
189
  const responseBodyBuffer = await this.getBuffer(response)
157
190
  let body = responseBodyBuffer.toString('utf-8')
158
191
  if (parseResponseBodyAsJSON) {
@@ -166,36 +199,31 @@ export default class AcmeRequest {
166
199
  }
167
200
 
168
201
  /**
169
- * Concatenates the output of a stream and returns a buffer. Taken from the bent module.
170
- *
171
- * @param {stream} stream A Node stream.
172
- * @returns {Promise<Buffer>} The concatenated output of the Node stream.
173
- */
174
- getBuffer (stream) {
175
- return new Promise((resolve, reject) => {
176
- const parts = []
177
- stream.on('error', reject)
178
- stream.on('end', () => resolve(Buffer.concat(parts)))
179
- stream.on('data', d => parts.push(d))
180
- })
202
+ Returns a buffer from the response body.
203
+
204
+ @param {Response} response - (Required) A fetch response.
205
+
206
+ @returns {Promise<Buffer>} The body of the response as a buffer.
207
+ */
208
+ async getBuffer (response) {
209
+ return Buffer.from(await response.arrayBuffer())
181
210
  }
182
211
 
183
212
  /**
184
- * Separate the preparation of a request from the execution of it so we can easily test
185
- * that different request configurations conform to our expectations.
186
- *
187
- * @param {String} command (Required) Name of Let’s Encrypt command to invoke (see Directory).
188
- * (sans 'Url' suffix). e.g. 'newAccount', 'newOrder', etc.
189
- * @param {Object|String} payload (Required) Either an object to use as the payload or, if there is no
190
- * payload, an empty string.
191
- * @param {Boolean} useKid (Required) Should request use a Key ID (true) or, public JWK (false).
192
- * (See RFC 8555 § 6.2 Request Authentication)
193
- * @param {Number[]} [successCodes=[200]] Optional array of codes that signals success. Any other code throws.
194
- * @param {String} [url=null] If specified, will use this URL directly, ignoring the value in
195
- * the command parameter.
196
- *
197
- * @returns {types.PreparedRequest}
198
- */
213
+ Separate the preparation of a request from the execution of it so we can easily test that different request configurations conform to our expectations.
214
+
215
+ @param {string|void} command - (Required) Name of Let’s Encrypt command to invoke (see Directory; sans 'Url' suffix). e.g. 'newAccount', 'newOrder', etc.
216
+
217
+ @param {Object|string|void} payload - (Required) Either an object to use as the payload or, if there is no payload, an empty string.
218
+
219
+ @param {boolean|void} useKid - (Required) Should request use a Key ID (true) or, public JWK (false). (See RFC 8555 § 6.2 Request Authentication.)
220
+
221
+ @param {Number[]} [successCodes=[200]] - Optional array of codes that signals success. Any other code throws.
222
+
223
+ @param {string} [url=null] - If specified, will use this URL directly, ignoring the value in the command parameter.
224
+
225
+ @param {Nonce} [nonce=null] - If specified, the nonce to use.
226
+ */
199
227
  async prepare (
200
228
  command = throws.ifMissing(),
201
229
  payload = throws.ifMissing(),
@@ -204,15 +232,21 @@ export default class AcmeRequest {
204
232
  url = null,
205
233
  nonce = null
206
234
  ) {
235
+ command = /** @type { String } */ (command)
236
+ payload = /** @type { Object | String } */ (payload)
237
+ useKid = /** @type { Boolean } */ (useKid)
238
+
207
239
  if (useKid && AcmeRequest.account === null) { throws.error(Symbol.for('AcmeRequest.accountNotSetError')) }
208
240
 
209
- // We will also return the original request details in case the call needs to be retried later.
210
- // Note: we have to create our own object using the actual individual argument values instead of
211
- // ===== the arguments array as the latter does not reflect default parameters.
241
+ // We will also return the original request details in case the call needs to be retried later. Note: we have to create our own object using the actual individual argument values instead of the arguments array as the latter does not reflect default parameters.
212
242
  const originalRequestDetails = [command, payload, useKid, successCodes, url, nonce]
213
243
 
214
244
  url = url || AcmeRequest.directory[`${command}Url`]
215
245
 
246
+ // Capture static state at the beginning of method to avoid race conditions if uninitialise() is called while awaiting a nonce. (This should really only be a race condition encountered during unit tests, but still.)
247
+ const account = AcmeRequest.account
248
+ const accountIdentity = AcmeRequest.accountIdentity
249
+
216
250
  const protectedHeader = {
217
251
  alg: 'RS256',
218
252
  nonce: nonce || await AcmeRequest.nonce.get(),
@@ -221,13 +255,30 @@ export default class AcmeRequest {
221
255
 
222
256
  if (useKid) {
223
257
  // The kid is the account location URL as previously returned by the ACME server.
224
- protectedHeader.kid = AcmeRequest.account.kid
258
+ protectedHeader.kid = account.kid
225
259
  } else {
226
260
  // If we’re not using the kid, we must use the public JWK (see RFC 8555 § 6.2 Request Authentication)
227
- protectedHeader.jwk = AcmeRequest.accountIdentity.publicJWK
261
+ protectedHeader.jwk = accountIdentity.publicJWK
228
262
  }
229
263
 
230
- const signedRequest = jose.JWS.sign.flattened(payload, AcmeRequest.accountIdentity.key, protectedHeader)
264
+ // Flatten the payload and sign it and use the signature in the signed request.
265
+ // (We were previously using jose.FlattenedSign for this.)
266
+
267
+ const encoder = new TextEncoder()
268
+ const protectedHeaderBase64 = Buffer.from(encoder.encode(JSON.stringify(protectedHeader))).toString('base64url')
269
+ const payloadBase64 = Buffer.from(typeof payload === 'string' ? encoder.encode(payload) : encoder.encode(JSON.stringify(payload))).toString('base64url')
270
+
271
+ const signature = crypto.sign(
272
+ 'sha256',
273
+ Buffer.from(`${protectedHeaderBase64}.${payloadBase64}`),
274
+ accountIdentity.key
275
+ )
276
+
277
+ const signedRequest = {
278
+ protected: protectedHeaderBase64,
279
+ payload: payloadBase64,
280
+ signature: signature.toString('base64url')
281
+ }
231
282
 
232
283
  const httpsHeaders = {
233
284
  'Content-Type': 'application/jose+json',
@@ -235,8 +286,27 @@ export default class AcmeRequest {
235
286
  'Accept-Language': 'en-US'
236
287
  }
237
288
 
238
- // Prepare a new account request (RFC 8555 § 7.3 Account Management)
239
- const httpsRequest = prepareRequest('POST', url, /* acceptable responses are */ ...successCodes)
289
+ // Carries out an https request.
290
+ const httpsRequest = async (/** @type {string} */ _path, /** @type {any} */ body, /** @type {any} */ headers) => {
291
+ const response = await fetch(url, {
292
+ method: 'POST',
293
+ headers,
294
+ body: JSON.stringify(body)
295
+ })
296
+
297
+ if (!successCodes.includes(response.status)) {
298
+ const error = new Error(`HTTP Error: ${response.status} ${response.statusText}`)
299
+ // @ts-ignore Error object mixin.
300
+ error.status = response.status
301
+ // @ts-ignore Error object mixin.
302
+ error.headers = Object.fromEntries(response.headers.entries())
303
+ // @ts-ignore Error object mixin.
304
+ error.responseBody = response.arrayBuffer().then(Buffer.from)
305
+ throw error
306
+ }
307
+
308
+ return response
309
+ }
240
310
 
241
311
  return {
242
312
  protectedHeader,
@@ -1,17 +1,16 @@
1
- ////////////////////////////////////////////////////////////////////////////////
2
- //
3
- // Authorisation
4
- //
5
- // (Use the async static get() method to await a fully-resolved instance.)
6
- //
7
- // Holds a single authorisation object. Note that the only authorisation type
8
- // supported by this library is HTTP-01 and this is hardcoded in its
9
- // behaviour. See RFC 8555 § 7.5, 7.5.1.
10
- //
11
- // Copyright © 2020 Aral Balkan, Small Technology Foundation.
12
- // License: AGPLv3 or later.
13
- //
14
- ////////////////////////////////////////////////////////////////////////////////
1
+ /**
2
+
3
+ Authorisation
4
+
5
+ (Use the async static get() method to await a fully-resolved instance.)
6
+
7
+ Holds a single authorisation object. Note that the only authorisation type
8
+ supported by this library is HTTP-01 and this is hardcoded in its
9
+ behaviour. See RFC 8555 § 7.5, 7.5.1.
10
+
11
+ Copyright © 2020 Aral Balkan, Small Technology Foundation.
12
+ License: AGPLv3 or later.
13
+ */
15
14
 
16
15
  import EventEmitter from 'events'
17
16
  import log from './util/log.js'
@@ -20,10 +19,23 @@ import ReadyForChallengeValidationRequest from './acme-requests/ReadyForChalleng
20
19
  import HttpServer from './HttpServer.js'
21
20
  import waitFor from './util/waitFor.js'
22
21
 
22
+ /**
23
+ @typedef {{
24
+ type: string,
25
+ url: string,
26
+ token: string,
27
+ status: string
28
+ }} Challenge
29
+ */
30
+
23
31
  export default class Authorisation extends EventEmitter {
24
32
 
25
33
  // Async factory method. Use this to instantiate.
26
34
  // TODO: add check to ensure factory method is used.
35
+ /**
36
+ @param {string} authorisationUrl
37
+ @param {import('./identities/AccountIdentity.js').default } accountIdentity
38
+ */
27
39
  static async getInstanceAsync (authorisationUrl, accountIdentity) {
28
40
  const authorisation = new Authorisation(authorisationUrl, accountIdentity)
29
41
  await authorisation.init()
@@ -32,6 +44,7 @@ export default class Authorisation extends EventEmitter {
32
44
 
33
45
  // Events
34
46
  static VALIDATED = 'validated'
47
+ static ERROR = 'error'
35
48
 
36
49
  //
37
50
  // Accessors.
@@ -40,13 +53,17 @@ export default class Authorisation extends EventEmitter {
40
53
  get domain () { return this._domain }
41
54
  get challenge () { return this._challenge }
42
55
 
43
- set domain (value) { throw new Error('domain is a read-only property') }
44
- set challenge (value) { throw new Error('challenge is a read-only property') }
56
+ set domain (_value) { throw new Error('domain is a read-only property') }
57
+ set challenge (_value) { throw new Error('challenge is a read-only property') }
45
58
 
46
59
  //
47
60
  // Private.
48
61
  //
49
62
 
63
+ /**
64
+ @param {string} authorisationUrl
65
+ @param {import('./identities/AccountIdentity.js').default} accountIdentity
66
+ */
50
67
  constructor (authorisationUrl, accountIdentity) {
51
68
  super()
52
69
  this.authorisationUrl = authorisationUrl
@@ -78,7 +95,7 @@ export default class Authorisation extends EventEmitter {
78
95
 
79
96
  // We’re only interested in the HTTP-01 challenge url and token so make it easy to get at these.
80
97
  // See RFC 8555 § 7.5 (Identifier Authorization).
81
- this.authorisation.challenges.forEach(challenge => {
98
+ this.authorisation.challenges.forEach(( /** @type { Challenge } */ challenge ) => {
82
99
  if (challenge.type === 'http-01') {
83
100
  // Add the domain to the challenge object.
84
101
  this._challenge = challenge
@@ -134,7 +151,9 @@ export default class Authorisation extends EventEmitter {
134
151
  this.once(Authorisation.VALIDATED, () => {
135
152
  resolve()
136
153
  })
137
- // TODO: Also listen for errors and reject the promise accordingly.
154
+ this.once(Authorisation.ERROR, error => {
155
+ reject(error)
156
+ })
138
157
  })
139
158
  }
140
159
 
@@ -154,30 +173,55 @@ export default class Authorisation extends EventEmitter {
154
173
  this.pollForValidationState()
155
174
  }
156
175
 
157
- async pollForValidationState () {
176
+ /**
177
+ Calculates polling duration according to RFC 7231 (referenced by RFC 8555).
178
+
179
+ The Retry-After header can contain either a non-negative integer representing the delay duration in seconds or an HTTP-date instance.
158
180
 
181
+ @param {{ retryAfterHeader: string? }} parameterObject
182
+ */
183
+ pollingDurationBasedOn ({ retryAfterHeader }) {
184
+ let pollingDuration = 1000
185
+ if (retryAfterHeader) {
186
+ const delaySeconds = parseInt(retryAfterHeader, 10)
187
+ if (!isNaN(delaySeconds)) {
188
+ pollingDuration = delaySeconds * 1000
189
+ } else {
190
+ // Try parsing as HTTP-date
191
+ const date = new Date(retryAfterHeader)
192
+ if (!isNaN(date.getTime())) {
193
+ pollingDuration = date.getTime() - Date.now()
194
+ }
195
+ }
196
+ }
197
+ if (pollingDuration < 1000) {
198
+ pollingDuration = 1000
199
+ }
200
+ return pollingDuration
201
+ }
202
+
203
+ async pollForValidationState () {
159
204
  log(` 👋 ❨auto-encrypt❩ Polling for authorisation state for domain ${this.domain}…`)
160
205
 
161
- const result = await (new AuthorisationRequest()).execute(this.authorisationUrl)
206
+ try {
207
+ const result = await (new AuthorisationRequest()).execute(this.authorisationUrl)
162
208
 
163
- if (result.body.status === 'valid') {
164
- log(` 🎉 ❨auto-encrypt❩ Authorisation validated for domain ${this.domain}`)
165
- this.emit(Authorisation.VALIDATED)
166
- return
167
- } else {
168
- // Check if there is a Retry-After header – there SHOULD be, according to RFC 8555 § 7.5.1
169
- // (Responding to Challenges) – and use that as the polling interval. If there isn’t, default
170
- // to polling every second.
171
- const retryAfterHeader = result.headers['Retry-After']
172
- let pollingDuration = 1000
173
- if (retryAfterHeader) {
174
- pollingDuration = parseInt(retryAfterHeader)
175
- }
209
+ if (result.body.status === 'valid') {
210
+ log(` 🎉 ❨auto-encrypt❩ Authorisation validated for domain ${this.domain}`)
211
+ this.emit(Authorisation.VALIDATED)
212
+ return
213
+ } else {
214
+ const retryAfterHeader = result.headers['Retry-After']
215
+ const pollingDuration = this.pollingDurationBasedOn({ retryAfterHeader })
176
216
 
177
- log(` ⌚ ❨auto-encrypt❩ Authorisation not valid yet for domain ${this.domain}. Waiting to check again in ${pollingDuration/1000} second${pollingDuration === 1000 ? '' : 's'}…`)
217
+ log(` ⌚ ❨auto-encrypt❩ Authorisation not valid yet for domain ${this.domain}. Waiting to check again in ${pollingDuration/1000} second${pollingDuration === 1000 ? '' : 's'}…`)
178
218
 
179
- await waitFor(pollingDuration)
180
- await this.pollForValidationState()
219
+ await waitFor(pollingDuration)
220
+ await this.pollForValidationState()
221
+ }
222
+ } catch (error) {
223
+ log(` ❌ ❨auto-encrypt❩ Error during polling for authorisation state for domain ${this.domain}: ${error.message}`)
224
+ this.emit(Authorisation.ERROR, error)
181
225
  }
182
226
  }
183
227
  }