UNPKG

@small-tech/auto-encrypt

Version:

Automatically provisions and renews Let’s Encrypt TLS certificates on Node.js https servers (including Kitten, Polka, Express.js, etc.)

184 lines (151 loc) 7.16 kB
//////////////////////////////////////////////////////////////////////////////// // // Authorisation // // (Use the async static get() method to await a fully-resolved instance.) // // Holds a single authorisation object. Note that the only authorisation type // supported by this library is HTTP-01 and this is hardcoded in its // behaviour. See RFC 8555 § 7.5, 7.5.1. // // Copyright © 2020 Aral Balkan, Small Technology Foundation. // License: AGPLv3 or later. // //////////////////////////////////////////////////////////////////////////////// import EventEmitter from 'events' import log from './util/log.js' import AuthorisationRequest from './acme-requests/AuthorisationRequest.js' import ReadyForChallengeValidationRequest from './acme-requests/ReadyForChallengeValidationRequest.js' import HttpServer from './HttpServer.js' import waitFor from './util/waitFor.js' export default class Authorisation extends EventEmitter { // Async factory method. Use this to instantiate. // TODO: add check to ensure factory method is used. static async getInstanceAsync (authorisationUrl, accountIdentity) { const authorisation = new Authorisation(authorisationUrl, accountIdentity) await authorisation.init() return authorisation } // Events static VALIDATED = 'validated' // // Accessors. // get domain () { return this._domain } get challenge () { return this._challenge } set domain (value) { throw new Error('domain is a read-only property') } set challenge (value) { throw new Error('challenge is a read-only property') } // // Private. // constructor (authorisationUrl, accountIdentity) { super() this.authorisationUrl = authorisationUrl this.accountIdentity = accountIdentity } async init () { try { this.data = await (new AuthorisationRequest()).execute(this.authorisationUrl) this.authorisation = this.data.body } catch (error) { // TODO: Handle the error. throw new Error(error) } // Save the identifier (this is the domain that we will be responding for). this._domain = this.authorisation.identifier.value // Check if the authorisation is already valid. // // Let’s Encrypt stores authorisations until a set expiry period. If this domain was already authorised // recently, it’s possible that the status will be valid (e.g., you provisioned a certificate for ar.al but // then decided to provision a certificate for ar.al and www.ar.al. The authorisation for ar.al will still be // valid until the expiry period. if (this.authorisation.status === 'valid') { log(` 💗 ❨auto-encrypt❩ Authorisation was previously validated and is still valid.`) return true } // We’re only interested in the HTTP-01 challenge url and token so make it easy to get at these. // See RFC 8555 § 7.5 (Identifier Authorization). this.authorisation.challenges.forEach(challenge => { if (challenge.type === 'http-01') { // Add the domain to the challenge object. this._challenge = challenge } }) // Add the responder for the challenge to the challenge server singleton instance. const httpServer = await HttpServer.getSharedInstance() // Make sure the HTTP server is in Let’s Encrypt challenge-answering mode. // (By default it will be in HTTP → HTTPS redirection mode.) httpServer.challengeServer = true httpServer.addResponder((request, response) => { if (request.url === `/.well-known/acme-challenge/${this.challenge.token}`) { // OK, this is the authorisation we’re being pinged for by the Let’s Encrypt servers. // Respond with the response it expects according to RFC 8555 § 8.1 (Key Authorizations) log(` 👍 ❨auto-encrypt❩ Responding to ACME authorisation request for ${this.domain}`) // TODO: We should validate (as much as possible) that this is actually coming from Let’s // ===== Encrypt’s servers. const keyAuthorisation = `${this.challenge.token}.${this.accountIdentity.thumbprint}` response.statusCode = 200 response.setHeader('Content-Type', 'application/octet-stream') // as per RFC 8555 § 8.3 (HTTP Challenge) response.end(keyAuthorisation) // "For challenges where the client can tell when the server // has validated the challenge (e.g., by seeing an HTTP or DNS request // from the server), the client SHOULD NOT begin polling until it has // seen the validation request from the server." – RFC 8555 § 7.5.1 // (Responding to Challenges) this.startPollingForValidationState() return true } else { // This request is not for this challenge; do not respond. return false } }) // Now that we’re able to respond to the challenge, signal to Let’s Encrypt that it can hit the endpoint. // See RFC 8555 § 7.5.1 (Responding to Challenges). try { await (new ReadyForChallengeValidationRequest()).execute(this.challenge.url) } catch (error) { // TODO: Handle error. throw new Error(error) } // Wait for the authorisation to be validated before returning. await new Promise((resolve, reject) => { this.once(Authorisation.VALIDATED, () => { resolve() }) // TODO: Also listen for errors and reject the promise accordingly. }) } startPollingForValidationState () { // Only start polling for validation state once. if (this.alreadyPollingForValidationState) { return } this.alreadyPollingForValidationState = true log(` 🧐 ❨auto-encrypt❩ Starting to poll for authorisation state for domain ${this.domain}…`) // Note: while this is an async function, we are not awaiting the result // ===== here. Our goal is to simply trigger the start of polling. We do // not care about the result. this.pollForValidationState() } async pollForValidationState () { log(` 👋 ❨auto-encrypt❩ Polling for authorisation state for domain ${this.domain}…`) const result = await (new AuthorisationRequest()).execute(this.authorisationUrl) if (result.body.status === 'valid') { log(` 🎉 ❨auto-encrypt❩ Authorisation validated for domain ${this.domain}`) this.emit(Authorisation.VALIDATED) return } else { // Check if there is a Retry-After header – there SHOULD be, according to RFC 8555 § 7.5.1 // (Responding to Challenges) – and use that as the polling interval. If there isn’t, default // to polling every second. const retryAfterHeader = result.headers['Retry-After'] let pollingDuration = 1000 if (retryAfterHeader) { pollingDuration = parseInt(retryAfterHeader) } log(` ⌚ ❨auto-encrypt❩ Authorisation not valid yet for domain ${this.domain}. Waiting to check again in ${pollingDuration/1000} second${pollingDuration === 1000 ? '' : 's'}…`) await waitFor(pollingDuration) await this.pollForValidationState() } } }