diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java index b9f5c34b..793b75de 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java @@ -28,6 +28,7 @@ import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeNetworkException; import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.util.ClaimBuilder; import org.shredzone.acme4j.util.TimestampParser; import org.slf4j.Logger; @@ -162,6 +163,13 @@ public class Authorization extends AcmeResource { /** * Updates the {@link Authorization}. After invocation, the {@link Authorization} * reflects the current state at the ACME server. + * + * @throws AcmeRetryAfterException + * the auhtorization is still being validated, and the server returned an + * estimated date when the validation will be completed. If you are + * polling for the authorization to complete, you should wait for the date + * given in {@link AcmeRetryAfterException#getRetryAfter()}. Note that the + * authorization status is updated even if this exception was thrown. */ public void update() throws AcmeException { LOG.debug("update"); @@ -171,10 +179,15 @@ public class Authorization extends AcmeResource { conn.throwAcmeException(); } - // HTTP_ACCEPTED requires Retry-After header to be set - Map result = conn.readJsonResponse(); unmarshalAuthorization(result); + + if (rc == HttpURLConnection.HTTP_ACCEPTED) { + Date retryAfter = conn.getRetryAfterHeader(); + throw new AcmeRetryAfterException( + "authorization is not completed yet", + retryAfter); + } } catch (IOException ex) { throw new AcmeNetworkException(ex); } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Certificate.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Certificate.java index aa30be22..3f8b2077 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Certificate.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Certificate.java @@ -19,6 +19,7 @@ import java.net.URI; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Date; import java.util.List; import org.shredzone.acme4j.connector.Connection; @@ -26,6 +27,7 @@ import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeNetworkException; import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.util.ClaimBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -80,20 +82,29 @@ public class Certificate extends AcmeResource { * Downloads the certificate. The result is cached. * * @return {@link X509Certificate} that was downloaded + * @throws AcmeRetryAfterException + * the certificate is still being created, and the server returned an + * estimated date when it will be ready for download. You should wait for + * the date given in {@link AcmeRetryAfterException#getRetryAfter()} + * before trying again. */ public X509Certificate download() throws AcmeException { if (cert == null) { LOG.debug("download"); try (Connection conn = getSession().provider().connect()) { int rc = conn.sendRequest(getLocation()); + if (rc == HttpURLConnection.HTTP_ACCEPTED) { + Date retryAfter = conn.getRetryAfterHeader(); + throw new AcmeRetryAfterException( + "certificate is not available for download yet", + retryAfter); + } + if (rc != HttpURLConnection.HTTP_OK) { conn.throwAcmeException(); } - // TODO: HTTP_ACCEPTED plus Retry-After header if not yet available - chainCertUri = conn.getLink("up"); - cert = conn.readCertificate(); } catch (IOException ex) { throw new AcmeNetworkException(ex); @@ -106,6 +117,11 @@ public class Certificate extends AcmeResource { * Downloads the certificate chain. The result is cached. * * @return Chain of {@link X509Certificate}s + * @throws AcmeRetryAfterException + * the certificate is still being created, and the server returned an + * estimated date when it will be ready for download. You should wait for + * the date given in {@link AcmeRetryAfterException#getRetryAfter()} + * before trying again. */ public X509Certificate[] downloadChain() throws AcmeException { if (chain == null) { diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java index 0387825f..0a2ac1ba 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java @@ -31,6 +31,7 @@ import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeNetworkException; import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.util.ClaimBuilder; import org.shredzone.acme4j.util.TimestampParser; import org.slf4j.Logger; @@ -228,16 +229,31 @@ public class Challenge extends AcmeResource { /** * Updates the state of this challenge. + * + * @throws AcmeRetryAfterException + * the challenge is still being validated, and the server returned an + * estimated date when the challenge will be completed. If you are polling + * for the challenge to complete, you should wait for the date given in + * {@link AcmeRetryAfterException#getRetryAfter()}. Note that the + * challenge status is updated even if this exception was thrown. */ public void update() throws AcmeException { LOG.debug("update"); try (Connection conn = getSession().provider().connect()) { int rc = conn.sendRequest(getLocation()); - if (rc != HttpURLConnection.HTTP_ACCEPTED) { + if (rc != HttpURLConnection.HTTP_OK && rc != HttpURLConnection.HTTP_ACCEPTED) { conn.throwAcmeException(); } unmarshall(conn.readJsonResponse()); + + if (rc == HttpURLConnection.HTTP_ACCEPTED) { + Date retryAfter = conn.getRetryAfterHeader(); + if (retryAfter != null) { + throw new AcmeRetryAfterException("challenge is not completed yet", + retryAfter); + } + } } catch (IOException ex) { throw new AcmeNetworkException(ex); } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRetryAfterException.java b/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRetryAfterException.java new file mode 100644 index 00000000..f5cfcc0b --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRetryAfterException.java @@ -0,0 +1,41 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2016 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.exception; + +import java.util.Date; + +/** + * This exception is thrown when a server side process has not been completed yet, and the + * server returned an estimated retry date. + * + * @author Richard "Shred" Körber + */ +public class AcmeRetryAfterException extends AcmeException { + private static final long serialVersionUID = 4461979121063649905L; + + private final Date retryAfter; + + public AcmeRetryAfterException(String msg, Date retryAfter) { + super(msg); + this.retryAfter = retryAfter; + } + + /** + * Returns the retry-after date returned by the server. + */ + public Date getRetryAfter() { + return (retryAfter != null ? new Date(retryAfter.getTime()) : null); + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java index 516723c3..628e7e6a 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java @@ -14,13 +14,14 @@ package org.shredzone.acme4j; import static org.hamcrest.Matchers.*; -import static org.junit.Assert.assertThat; +import static org.junit.Assert.*; import static org.shredzone.acme4j.util.TestUtils.getJsonAsMap; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URI; import java.util.Collection; +import java.util.Date; import java.util.Map; import org.junit.Test; @@ -28,6 +29,7 @@ import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.TlsSni02Challenge; +import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.util.ClaimBuilder; import org.shredzone.acme4j.util.TimestampParser; @@ -156,6 +158,64 @@ public class AuthorizationTest { provider.close(); } + /** + * Test that authorization is properly updated, with retry-after header set. + */ + @Test + public void testUpdateRetryAfter() throws Exception { + final long retryAfter = System.currentTimeMillis() + 30 * 1000L; + + TestableConnectionProvider provider = new TestableConnectionProvider() { + @Override + public int sendRequest(URI uri) { + assertThat(uri, is(locationUri)); + return HttpURLConnection.HTTP_ACCEPTED; + } + + @Override + public Map readJsonResponse() { + return getJsonAsMap("updateAuthorizationResponse"); + } + + @Override + public Date getRetryAfterHeader() { + return new Date(retryAfter); + } + }; + + Session session = provider.createSession(); + + Http01Challenge httpChallenge = new Http01Challenge(session); + Dns01Challenge dnsChallenge = new Dns01Challenge(session); + provider.putTestChallenge("http-01", httpChallenge); + provider.putTestChallenge("dns-01", dnsChallenge); + + Authorization auth = new Authorization(session, locationUri); + + try { + auth.update(); + fail("Expected AcmeRetryAfterException"); + } catch (AcmeRetryAfterException ex) { + assertThat(ex.getRetryAfter(), is(new Date(retryAfter))); + } + + assertThat(auth.getDomain(), is("example.org")); + assertThat(auth.getStatus(), is(Status.VALID)); + assertThat(auth.getExpires(), is(TimestampParser.parse("2016-01-02T17:12:40Z"))); + assertThat(auth.getLocation(), is(locationUri)); + + assertThat(auth.getChallenges(), containsInAnyOrder( + (Challenge) httpChallenge, (Challenge) dnsChallenge)); + + assertThat(auth.getCombinations(), hasSize(2)); + assertThat(auth.getCombinations().get(0), containsInAnyOrder( + (Challenge) httpChallenge)); + assertThat(auth.getCombinations().get(1), containsInAnyOrder( + (Challenge) httpChallenge, (Challenge) dnsChallenge)); + + provider.close(); + } + /** * Test that an authorization can be deactivated. */ diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java index 182a4e5c..20fc7037 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java @@ -14,7 +14,7 @@ package org.shredzone.acme4j; import static org.hamcrest.Matchers.*; -import static org.junit.Assert.assertThat; +import static org.junit.Assert.*; import static org.shredzone.acme4j.util.TestUtils.getJson; import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; @@ -22,10 +22,12 @@ import java.io.IOException; import java.net.HttpURLConnection; import java.net.URI; import java.security.cert.X509Certificate; +import java.util.Date; import org.junit.Test; import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.util.ClaimBuilder; import org.shredzone.acme4j.util.TestUtils; @@ -84,6 +86,38 @@ public class CertificateTest { provider.close(); } + /** + * Test that a {@link AcmeRetryAfterException} is thrown. + */ + @Test + public void testRetryAfter() throws AcmeException, IOException { + final long retryAfter = System.currentTimeMillis() + 30 * 1000L; + + TestableConnectionProvider provider = new TestableConnectionProvider() { + @Override + public int sendRequest(URI uri) { + assertThat(uri, is(locationUri)); + return HttpURLConnection.HTTP_ACCEPTED; + } + + @Override + public Date getRetryAfterHeader() { + return new Date(retryAfter); + } + }; + + Certificate cert = new Certificate(provider.createSession(), locationUri); + + try { + cert.download(); + fail("Expected AcmeRetryAfterException"); + } catch (AcmeRetryAfterException ex) { + assertThat(ex.getRetryAfter(), is(new Date(retryAfter))); + } + + provider.close(); + } + /** * Test that a certificate can be revoked. */ diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java index 0f23f694..514ddee4 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java @@ -14,7 +14,7 @@ package org.shredzone.acme4j.challenge; import static org.hamcrest.Matchers.*; -import static org.junit.Assert.assertThat; +import static org.junit.Assert.*; import static org.shredzone.acme4j.util.TestUtils.*; import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; @@ -27,6 +27,7 @@ import java.net.HttpURLConnection; import java.net.URI; import java.net.URISyntaxException; import java.security.KeyPair; +import java.util.Date; import java.util.Map; import org.jose4j.base64url.Base64Url; @@ -39,6 +40,7 @@ import org.junit.Test; import org.shredzone.acme4j.Session; import org.shredzone.acme4j.Status; import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.util.ClaimBuilder; import org.shredzone.acme4j.util.SignatureUtils; @@ -208,7 +210,7 @@ public class ChallengeTest { @Override public int sendRequest(URI uri) { assertThat(uri, is(locationUri)); - return HttpURLConnection.HTTP_ACCEPTED; + return HttpURLConnection.HTTP_OK; } @Override @@ -230,6 +232,49 @@ public class ChallengeTest { provider.close(); } + /** + * Test that a challenge is properly updated, with Retry-After header. + */ + @Test + public void testUpdateRetryAfter() throws Exception { + final long retryAfter = System.currentTimeMillis() + 30 * 1000L; + + TestableConnectionProvider provider = new TestableConnectionProvider() { + @Override + public int sendRequest(URI uri) { + assertThat(uri, is(locationUri)); + return HttpURLConnection.HTTP_ACCEPTED; + } + + @Override + public Map readJsonResponse() { + return getJsonAsMap("updateHttpChallengeResponse"); + } + + @Override + public Date getRetryAfterHeader() { + return new Date(retryAfter); + } + }; + + Session session = provider.createSession(); + + Challenge challenge = new Http01Challenge(session); + challenge.unmarshall(getJsonAsMap("triggerHttpChallengeResponse")); + + try { + challenge.update(); + fail("Expected AcmeRetryAfterException"); + } catch (AcmeRetryAfterException ex) { + assertThat(ex.getRetryAfter(), is(new Date(retryAfter))); + } + + assertThat(challenge.getStatus(), is(Status.VALID)); + assertThat(challenge.getLocation(), is(locationUri)); + + provider.close(); + } + /** * Test that challenge serialization works correctly. */ diff --git a/src/site/markdown/usage/authorization.md b/src/site/markdown/usage/authorization.md index c5f2df06..58b9edad 100644 --- a/src/site/markdown/usage/authorization.md +++ b/src/site/markdown/usage/authorization.md @@ -48,6 +48,8 @@ while (challenge.getStatus() != Status.VALID) { This is a very simple example. You should limit the number of loop iterations, and abort the loop when the status should turn to `INVALID`. If you know when the CA server actually requested your response (e.g. when you notice a HTTP request on the response file), you should start polling after that event. +`update()` may throw an `AcmeRetryAfterException`, giving an estimated time in `getRetryAfter()` for when the challenge is completed. You should then wait until that moment has been reached, before trying again. The challenge state is still updated when this exception is thrown. + As soon as all the necessary challenges are `VALID`, you have successfully associated the domain with your account. If your final certificate will contain further domains or subdomains, repeat the authorization run with each of them. @@ -67,6 +69,8 @@ auth.update(); After invoking `update()`, the `Authorization` object contains the current server state about your authorization, including the domain name, the overall status, and an expiry date. +`update()` may throw an `AcmeRetryAfterException`, giving an estimated time in `getRetryAfter()` for when all challenges are completed. You should then wait until that moment has been reached, before trying again. The authorization state is still updated when this exception is thrown. + ## Deactivate an Authorization It is possible to deactivate an `Authorization`, for example if you sell the associated domain. diff --git a/src/site/markdown/usage/certificate.md b/src/site/markdown/usage/certificate.md index c6e36a17..f75fc9e6 100644 --- a/src/site/markdown/usage/certificate.md +++ b/src/site/markdown/usage/certificate.md @@ -45,6 +45,8 @@ X509Certificate[] chain = cert.downloadChain(); Congratulations! You have just created your first certificate via _acme4j_. +`download()` may throw an `AcmeRetryAfterException`, giving an estimated time in `getRetryAfter()` for when the certificate is ready for download. You should then wait until that moment has been reached, before trying again. + To recreate a `Certificate` object from the location, just bind it: ```java