AcmeSwift icon indicating copy to clipboard operation
AcmeSwift copied to clipboard

Awkward API to get token and value for http challenge

Open sliemeobn opened this issue 1 year ago • 1 comments

tl;dr: Should we add the token to the ChallengeDescription type?

First: Thank you for this package! When I stumbled over the need to self-manage public certificates I couldn't believe my luck: There is a swift package for ACME!

A tiny little thing I think could be improved:

Currently, you can either call acme.orders.getAuthorizations(from: order) to get the "raw data", or use the acme.orders.describePendingChallenges(from: order, preferring: .http) API to get the little value encoding dance for free.

Getting the correct value for the challenges is clearly nicer, but on ChallengeDescription you can not access the token directly. Based on how you'd implement "placing" the http value on you server, you'll now have to extract it out of the endpoint URL again.

Additionally, we could also move the get me the value for this challenge code to some reusable place to decouple it from the describePendingChallenges API.

sliemeobn avatar Jan 25 '24 08:01 sliemeobn

For what it's worth, I think you have everything you need in ChallengeDescription. Here is what I use in my deployment:

struct ChallengeKey: Hashable {
    var host: String
    var path: String
}

extension ChallengeKey {
    init(url urlString: String) throws {
        guard 
            let url = URL(string: urlString),
            let host = url.host()
        else { throw CertificateRenewalError.invalidURL(urlString) }
        
        self.host = host
        self.path = url.path(percentEncoded: true)
    }
}

actor CertificateManager {
    // ...
    var activeChallenges: [ChallengeKey : ChallengeDescription] = [:]

    func refresh() async throws -> Date {
        // ...
        let pendingChallenges = try await acmeClient.orders.describePendingChallenges(from: certificateOrder, preferring: .http)
        
        defer { activeChallenges = [:] }
        activeChallenges = [:]
        logger.notice("Awaiting verifications for order:")
        for challenge in pendingChallenges {
            guard case .http = challenge.type else {
                logger.warning("Unknown type encountered: \(challenge.type)")
                continue
            }
            
            logger.notice("  • The URL \(challenge.endpoint) needs to return \(challenge.value)")
            activeChallenges[try ChallengeKey(url: challenge.endpoint)] = challenge
        }
        
        var failedChallenges = try await acmeClient.orders.validateChallenges(from: certificateOrder, preferring: .http)
        for timeout in [5, 10, 10, 10, 30] {
            guard !failedChallenges.isEmpty else { break }
            logger.notice("\(failedChallenges.count) challenges remain, trying again in \(timeout) seconds.")
            try await Task.sleep(for: .seconds(timeout))
            failedChallenges = try await acmeClient.orders.validateChallenges(from: certificateOrder, preferring: .http)
        }
        guard failedChallenges.isEmpty else {
            logger.error("Certificate order validations failed: \(failedChallenges)")
            throw CertificateRenewalError.failedToValidate(failedChallenges)
        }
        
        logger.notice("Finished validating orders. Downloading certificates.")
        
        let (privateKey, _, finalizedOrders) = try await acmeClient.orders.finalizeWithEcdsa(order: certificateOrder, domains: domains)
        // ...
    }
}

actor HTTPApplication {
    // ...
    @Sendable
    func redirect(request: Request) async throws -> Response {
        guard let host = request.headers[.host].first, validDomains.contains(host)
        else { return request.redirect(to: "https://\(request.application.environment.certificateDomains[0])\(request.url)", redirectType: .temporary) }
        
        // If we have active challenges to fulfill, go ahead and check if any requests match, and return the challenge value
        if let activeChallenges = await certificateManager?.activeChallenges, !activeChallenges.isEmpty {
            let key = ChallengeKey(host: host, path: request.url.path)
            request.logger.notice("Checking challenge for \(key) in \(activeChallenges.keys)")

            if let challenge = activeChallenges[key] {
                request.logger.notice("Returning for \(key): \(challenge.value)")
                return Response(status: .ok, body: .init(string: challenge.value))
            }
        }
        
        // Otherwise redirect to HTTPS
        return request.redirect(to: "https://\(host)\(request.url)", redirectType: .temporary)
    }
}

(Edit: I guess we can avoid the URL parsing, though this doesn't need to be done in a hot path, so the above works fine for me)

dimitribouniol avatar Jan 27 '24 14:01 dimitribouniol