diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java new file mode 100644 index 00000000..1845bd63 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java @@ -0,0 +1,133 @@ +/* + * 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; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import org.shredzone.acme4j.exception.AcmeProtocolException; + +/** + * Contains metadata related to the provider. + * + * @author Richard "Shred" Körber + */ +public class Metadata { + + private final Map meta; + + /** + * Creates an empty new {@link Metadata} instance. + */ + public Metadata() { + this(new HashMap()); + } + + /** + * Creates a new {@link Metadata} instance. + * + * @param meta + * JSON map of metadata + */ + public Metadata(Map meta) { + this.meta = meta; + } + + /** + * Returns an {@link URI} to the current terms of service, or {@code null} if not + * available. + */ + public URI getTermsOfService() { + return getUri("terms-of-service"); + } + + /** + * Returns an {@link URI} to a website providing more information about the ACME + * server. {@code null} if not available. + */ + public URI getWebsite() { + return getUri("website"); + } + + /** + * Returns an array of hostnames, which the ACME server recognises as referring to + * itself for the purposes of CAA record validation. {@code null} if not available. + */ + public String[] getCaaIdentities() { + return getStringArray("caa-identities"); + } + + /** + * Gets a custom metadata value, as {@link String}. + * + * @param key + * Key of the meta value + * @return Value as {@link String}, or {@code null} if there is no such key in the + * directory metadata. + */ + public String get(String key) { + Object value = meta.get(key); + return (value != null ? value.toString() : null); + } + + /** + * Gets a custom metadata value, as {@link URI}. + * + * @param key + * Key of the meta value + * @return Value as {@link URI}, or {@code null} if there is no such key in the + * directory metadata. + * @throws AcmeProtocolException + * if the value is not an {@link URI} + */ + public URI getUri(String key) { + Object uri = meta.get(key); + try { + return (uri != null ? new URI(uri.toString()) : null); + } catch (URISyntaxException ex) { + throw new AcmeProtocolException("Bad URI: " + uri, ex); + } + } + + /** + * Gets a custom metadata value, as array of {@link String}. + * + * @param key + * Key of the meta value + * @return {@link String} array, or {@code null} if there is no such key in the + * directory metadata. + */ + @SuppressWarnings("unchecked") + public String[] getStringArray(String key) { + Object value = meta.get(key); + if (value != null && value instanceof Collection) { + Collection data = (Collection) value; + return data.toArray(new String[data.size()]); + } + return null; + } + + /** + * Returns the metadata as raw JSON map. + *

+ * Do not modify the map or its contents. Changes will have a session-wide effect. + */ + public Map getJsonData() { + return meta; + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java index c36e0aee..a1399f59 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java @@ -14,6 +14,7 @@ package org.shredzone.acme4j; import java.net.URI; +import java.net.URISyntaxException; import java.security.KeyPair; import java.util.ArrayList; import java.util.Date; @@ -26,6 +27,7 @@ import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.TokenChallenge; import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.provider.AcmeProvider; /** @@ -38,12 +40,14 @@ import org.shredzone.acme4j.provider.AcmeProvider; * @author Richard "Shred" Körber */ public class Session { - private final Map directoryMap = new EnumMap<>(Resource.class); + private final Map resourceMap = new EnumMap<>(Resource.class); private final URI serverUri; private KeyPair keyPair; private AcmeProvider provider; private byte[] nonce; + private Map directoryMap; + private Metadata metadata; protected Date directoryCacheExpiry; /** @@ -184,21 +188,53 @@ public class Session { if (resource == null) { throw new NullPointerException("resource must not be null"); } + readDirectory(); + return resourceMap.get(resource); + } + /** + * Gets the metadata of the provider's directory. This may involve connecting to the + * server and getting a directory. The result is cached. + * + * @return {@link Metadata}. May contain no data, but is never {@code null}. + */ + public Metadata getMetadata() throws AcmeException { + readDirectory(); + return metadata; + } + + /** + * Reads the provider's directory, then rebuild the resource map. The response is + * cached. + */ + @SuppressWarnings("unchecked") + private void readDirectory() throws AcmeException { synchronized (this) { Date now = new Date(); - - if (directoryMap.isEmpty() || !directoryCacheExpiry.after(now)) { - Map newMap = provider().resources(this, getServerUri()); - - // only reached when readDirectory did not throw an exception - directoryMap.clear(); - directoryMap.putAll(newMap); + if (directoryMap == null || !directoryCacheExpiry.after(now)) { + directoryMap = provider().directory(this, getServerUri()); directoryCacheExpiry = new Date(now.getTime() + 60 * 60 * 1000L); + + Object meta = directoryMap.get("meta"); + if (meta != null && meta instanceof Map) { + metadata = new Metadata((Map) meta); + } else { + metadata = new Metadata(); + } + + resourceMap.clear(); + for (Map.Entry entry : directoryMap.entrySet()) { + Resource res = Resource.parse(entry.getKey()); + if (res != null) { + try { + resourceMap.put(res, new URI(entry.getValue().toString())); + } catch (URISyntaxException ex) { + throw new AcmeProtocolException("Illegal URI for resource " + res, ex); + } + } + } } } - - return directoryMap.get(resource); } } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java index 47631415..179b642a 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java @@ -67,13 +67,6 @@ public interface Connection extends AutoCloseable { */ X509Certificate readCertificate() throws IOException; - /** - * Reads a resource directory. - * - * @return Map of {@link Resource} and the respective {@link URI} to invoke - */ - Map readDirectory() throws IOException; - /** * Updates a {@link Session} by evaluating the HTTP response header. * diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java index efb1bdd8..9b9fea08 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java @@ -28,7 +28,6 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.Date; -import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; @@ -217,38 +216,6 @@ public class DefaultConnection implements Connection { } } - @Override - public Map readDirectory() throws IOException { - assertConnectionIsOpen(); - - String contentType = conn.getHeaderField("Content-Type"); - if (!("application/json".equals(contentType))) { - throw new AcmeProtocolException("Unexpected content type: " + contentType); - } - - EnumMap resourceMap = new EnumMap<>(Resource.class); - String response = ""; - - try { - response = readStream(conn.getInputStream()); - - Map result = JsonUtil.parseJson(response); - for (Map.Entry entry : result.entrySet()) { - Resource res = Resource.parse(entry.getKey()); - if (res != null) { - URI uri = new URI(entry.getValue().toString()); - resourceMap.put(res, uri); - } - } - - LOG.debug("Resource directory: {}", resourceMap); - } catch (JoseException | URISyntaxException ex) { - throw new AcmeProtocolException("Failed to read directory: " + response, ex); - } - - return resourceMap; - } - @Override public void updateSession(Session session) { assertConnectionIsOpen(); diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java index 3a94d854..e71d6ea8 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java @@ -27,7 +27,6 @@ import org.shredzone.acme4j.challenge.TlsSni02Challenge; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.DefaultConnection; import org.shredzone.acme4j.connector.HttpConnector; -import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeNetworkException; @@ -48,7 +47,7 @@ public abstract class AbstractAcmeProvider implements AcmeProvider { } @Override - public Map resources(Session session, URI serverUri) throws AcmeException { + public Map directory(Session session, URI serverUri) throws AcmeException { try (Connection conn = connect()) { int rc = conn.sendRequest(resolve(serverUri)); if (rc != HttpURLConnection.HTTP_OK) { @@ -58,7 +57,7 @@ public abstract class AbstractAcmeProvider implements AcmeProvider { // use nonce header if there is one, saves a HEAD request... conn.updateSession(session); - return conn.readDirectory(); + return conn.readJsonResponse(); } catch (IOException ex) { throw new AcmeNetworkException(ex); } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeProvider.java index a60fbe00..7782775b 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeProvider.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeProvider.java @@ -20,7 +20,6 @@ import java.util.ServiceLoader; import org.shredzone.acme4j.Session; import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.connector.Connection; -import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.exception.AcmeException; /** @@ -62,7 +61,8 @@ public interface AcmeProvider { Connection connect(); /** - * Returns a map of all known {@link Resource} {@link URI} of this provider. + * Returns the provider's directory. The map must contain resource URIs, and may + * optionally contain metadata. *

* The default implementation resolves the server URI and fetches the directory via * HTTP request. Subclasses may override this method, e.g. if the directory is static. @@ -71,9 +71,9 @@ public interface AcmeProvider { * {@link Session} to be used * @param serverUri * Server {@link URI} - * @return Map of resource URIs + * @return Map of directory data */ - Map resources(Session session, URI serverUri) throws AcmeException; + Map directory(Session session, URI serverUri) throws AcmeException; /** * Creates a {@link Challenge} instance for the given challenge type. diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java index 31d1e6d7..67eb8e11 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java @@ -19,10 +19,8 @@ import static org.mockito.Mockito.*; import java.io.IOException; import java.net.URI; -import java.net.URISyntaxException; import java.security.KeyPair; import java.util.Date; -import java.util.HashMap; import java.util.Map; import org.junit.Test; @@ -46,9 +44,9 @@ public class SessionTest { * Test constructor */ @Test - public void testConstructor() throws Exception { + public void testConstructor() throws IOException { KeyPair keyPair = TestUtils.createKeyPair(); - URI serverUri = new URI(TestUtils.ACME_SERVER_URI); + URI serverUri = URI.create(TestUtils.ACME_SERVER_URI); try { new Session((URI) null, null); @@ -93,10 +91,10 @@ public class SessionTest { * Test getters and setters. */ @Test - public void testGettersAndSetters() throws Exception { + public void testGettersAndSetters() throws IOException { KeyPair kp1 = TestUtils.createKeyPair(); KeyPair kp2 = TestUtils.createDomainKeyPair(); - URI serverUri = new URI(TestUtils.ACME_SERVER_URI); + URI serverUri = URI.create(TestUtils.ACME_SERVER_URI); Session session = new Session(serverUri, kp1); @@ -116,9 +114,9 @@ public class SessionTest { * Test if challenges are correctly created via provider. */ @Test - public void testCreateChallenge() throws IOException, URISyntaxException { + public void testCreateChallenge() throws IOException { KeyPair keyPair = TestUtils.createKeyPair(); - URI serverUri = new URI(TestUtils.ACME_SERVER_URI); + URI serverUri = URI.create(TestUtils.ACME_SERVER_URI); String challengeType = Http01Challenge.TYPE; Map data = new ClaimBuilder() @@ -151,19 +149,15 @@ public class SessionTest { * Test that the directory is properly read and cached. */ @Test - public void testResourceUri() throws AcmeException, IOException, URISyntaxException { + public void testDirectory() throws AcmeException, IOException { KeyPair keyPair = TestUtils.createKeyPair(); - URI serverUri = new URI(TestUtils.ACME_SERVER_URI); - - Map directoryMap = new HashMap<>(); - directoryMap.put(Resource.NEW_AUTHZ, new URI("http://example.com/acme/new-authz")); - directoryMap.put(Resource.NEW_CERT, new URI("http://example.com/acme/new-cert")); + URI serverUri = URI.create(TestUtils.ACME_SERVER_URI); final AcmeProvider mockProvider = mock(AcmeProvider.class); - when(mockProvider.resources( + when(mockProvider.directory( ArgumentMatchers.any(Session.class), ArgumentMatchers.eq(serverUri))) - .thenReturn(directoryMap); + .thenReturn(TestUtils.getJsonAsMap("directory")); Session session = new Session(serverUri, keyPair) { @Override @@ -172,15 +166,10 @@ public class SessionTest { }; }; - assertThat(session.resourceUri(Resource.NEW_AUTHZ), - is(new URI("http://example.com/acme/new-authz"))); - assertThat(session.resourceUri(Resource.NEW_CERT), - is(new URI("http://example.com/acme/new-cert"))); - assertThat(session.resourceUri(Resource.NEW_REG), - is(nullValue())); + assertSession(session); // Make sure directory is only read once! - verify(mockProvider, times(1)).resources( + verify(mockProvider, times(1)).directory( ArgumentMatchers.any(Session.class), ArgumentMatchers.any(URI.class)); @@ -188,15 +177,74 @@ public class SessionTest { session.directoryCacheExpiry = new Date(); // Make sure directory is read once again - assertThat(session.resourceUri(Resource.NEW_AUTHZ), - is(new URI("http://example.com/acme/new-authz"))); - assertThat(session.resourceUri(Resource.NEW_CERT), - is(new URI("http://example.com/acme/new-cert"))); - assertThat(session.resourceUri(Resource.NEW_REG), - is(nullValue())); - verify(mockProvider, times(2)).resources( + assertSession(session); + verify(mockProvider, times(2)).directory( ArgumentMatchers.any(Session.class), ArgumentMatchers.any(URI.class)); } + /** + * Test that the directory is properly read even if there are no metadata. + */ + @Test + public void testNoMeta() throws AcmeException, IOException { + KeyPair keyPair = TestUtils.createKeyPair(); + URI serverUri = URI.create(TestUtils.ACME_SERVER_URI); + + final AcmeProvider mockProvider = mock(AcmeProvider.class); + when(mockProvider.directory( + org.mockito.Matchers.any(Session.class), + org.mockito.Matchers.eq(serverUri))) + .thenReturn(TestUtils.getJsonAsMap("directoryNoMeta")); + + Session session = new Session(serverUri, keyPair) { + @Override + public AcmeProvider provider() { + return mockProvider; + }; + }; + + assertThat(session.resourceUri(Resource.NEW_REG), + is(URI.create("https://example.com/acme/new-reg"))); + assertThat(session.resourceUri(Resource.NEW_AUTHZ), + is(URI.create("https://example.com/acme/new-authz"))); + assertThat(session.resourceUri(Resource.NEW_CERT), + is(URI.create("https://example.com/acme/new-cert"))); + assertThat(session.resourceUri(Resource.REVOKE_CERT), + is(nullValue())); + + Metadata meta = session.getMetadata(); + assertThat(meta, not(nullValue())); + assertThat(meta.getTermsOfService(), is(nullValue())); + assertThat(meta.getWebsite(), is(nullValue())); + assertThat(meta.getCaaIdentities(), is(nullValue())); + } + + /** + * Asserts that the {@link Session} returns correct + * {@link Session#resourceUri(Resource)} and {@link Session#getMetadata()}. + * + * @param session + * {@link Session} to assert + */ + private void assertSession(Session session) throws AcmeException { + assertThat(session.resourceUri(Resource.NEW_REG), + is(URI.create("https://example.com/acme/new-reg"))); + assertThat(session.resourceUri(Resource.NEW_AUTHZ), + is(URI.create("https://example.com/acme/new-authz"))); + assertThat(session.resourceUri(Resource.NEW_CERT), + is(URI.create("https://example.com/acme/new-cert"))); + assertThat(session.resourceUri(Resource.REVOKE_CERT), + is(nullValue())); + + Metadata meta = session.getMetadata(); + assertThat(meta, not(nullValue())); + assertThat(meta.getTermsOfService(), is(URI.create("https://example.com/acme/terms"))); + assertThat(meta.getWebsite(), is(URI.create("https://www.example.com/"))); + assertThat(meta.getCaaIdentities(), is(arrayContaining("example.com"))); + assertThat(meta.get("x-test-string"), is("foobar")); + assertThat(meta.getUri("x-test-uri"), is(URI.create("https://www.example.org"))); + assertThat(meta.getStringArray("x-test-array"), is(arrayContaining("foo", "bar", "barfoo"))); + } + } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java index c68518c5..bbac9a30 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java @@ -470,33 +470,4 @@ public class DefaultConnectionTest { verifyNoMoreInteractions(mockUrlConnection); } - /** - * Test if a resource directory is read correctly. - */ - @Test - public void testReadDirectory() throws Exception { - StringBuilder jsonData = new StringBuilder(); - jsonData.append("{\n"); - jsonData.append("\"new-reg\":\"http://example.com/acme/newreg\",\n"); - jsonData.append("\"new-authz\":\"http://example.com/acme/newauthz\",\n"); - jsonData.append("\"old-foo\":\"http://example.com/acme/oldfoo\"\n"); - jsonData.append("}\n"); - - when(mockUrlConnection.getHeaderField("Content-Type")).thenReturn("application/json"); - when(mockUrlConnection.getInputStream()).thenReturn(new ByteArrayInputStream(jsonData.toString().getBytes("utf-8"))); - - try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) { - conn.conn = mockUrlConnection; - Map result = conn.readDirectory(); - assertThat(result.keySet(), hasSize(2)); - assertThat(result, hasEntry(Resource.NEW_REG, new URI("http://example.com/acme/newreg"))); - assertThat(result, hasEntry(Resource.NEW_AUTHZ, new URI("http://example.com/acme/newauthz"))); - // "old-foo" resource is unknown and thus not available in the map - } - - verify(mockUrlConnection).getHeaderField("Content-Type"); - verify(mockUrlConnection).getInputStream(); - verifyNoMoreInteractions(mockUrlConnection); - } - } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java index 6a7eba50..b8f0ee85 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java @@ -50,11 +50,6 @@ public class DummyConnection implements Connection { throw new UnsupportedOperationException(); } - @Override - public Map readDirectory() { - throw new UnsupportedOperationException(); - } - @Override public void updateSession(Session session) { throw new UnsupportedOperationException(); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/SessionProviderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/SessionProviderTest.java index 392e25fb..de1687df 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/SessionProviderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/SessionProviderTest.java @@ -99,7 +99,7 @@ public class SessionProviderTest { } @Override - public Map resources(Session session, URI serverUri) throws AcmeException { + public Map directory(Session session, URI serverUri) throws AcmeException { throw new UnsupportedOperationException(); } @@ -127,7 +127,7 @@ public class SessionProviderTest { } @Override - public Map resources(Session session, URI serverUri) throws AcmeException { + public Map directory(Session session, URI serverUri) throws AcmeException { throw new UnsupportedOperationException(); } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java index bada6f01..edd7c9f6 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java @@ -17,13 +17,14 @@ import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; import static org.mockito.Matchers.any; import static org.mockito.Mockito.*; +import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; import java.net.HttpURLConnection; import java.net.URI; -import java.util.EnumMap; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import org.jose4j.json.JsonUtil; import org.junit.Test; import org.shredzone.acme4j.Session; import org.shredzone.acme4j.challenge.Challenge; @@ -34,7 +35,7 @@ import org.shredzone.acme4j.challenge.TlsSni02Challenge; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.DefaultConnection; import org.shredzone.acme4j.connector.HttpConnector; -import org.shredzone.acme4j.connector.Resource; +import org.shredzone.acme4j.util.TestUtils; /** * Unit tests for {@link AbstractAcmeProvider}. @@ -83,12 +84,9 @@ public class AbstractAcmeProviderTest { final URI testResolvedUri = new URI("http://example.com/acme/directory"); final Connection connection = mock(Connection.class); final Session session = mock(Session.class); - final Map resourceMap = new EnumMap<>(Resource.class); - resourceMap.put(Resource.NEW_REG, new URI("http://example.com/acme/new-reg")); - resourceMap.put(Resource.NEW_CERT, new URI("http://example.com/acme/new-cert")); when(connection.sendRequest(testResolvedUri)).thenReturn(HttpURLConnection.HTTP_OK); - when(connection.readDirectory()).thenReturn(resourceMap); + when(connection.readJsonResponse()).thenReturn(TestUtils.getJsonAsMap("directory")); AbstractAcmeProvider provider = new AbstractAcmeProvider() { @Override @@ -109,12 +107,12 @@ public class AbstractAcmeProviderTest { } }; - Map map = provider.resources(session, testServerUri); - assertThat(map, is(resourceMap)); + Map map = provider.directory(session, testServerUri); + assertThat(JsonUtil.toJson(map), sameJSONAs(TestUtils.getJson("directory"))); verify(connection).sendRequest(testResolvedUri); verify(connection).updateSession(any(Session.class)); - verify(connection).readDirectory(); + verify(connection).readJsonResponse(); verify(connection).close(); verifyNoMoreInteractions(connection); } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java index 2958986b..48d35315 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java @@ -15,7 +15,6 @@ package org.shredzone.acme4j.provider; import java.io.IOException; import java.net.URI; -import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -25,6 +24,7 @@ import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.DummyConnection; import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.util.ClaimBuilder; import org.shredzone.acme4j.util.TestUtils; /** @@ -34,8 +34,8 @@ import org.shredzone.acme4j.util.TestUtils; * @author Richard "Shred" Körber */ public class TestableConnectionProvider extends DummyConnection implements AcmeProvider { - private final Map resourceMap = new HashMap<>(); private final Map challengeMap = new HashMap<>(); + private final ClaimBuilder directory = new ClaimBuilder(); /** * Register a {@link Resource} mapping. @@ -46,7 +46,7 @@ public class TestableConnectionProvider extends DummyConnection implements AcmeP * {@link URI} to be returned */ public void putTestResource(Resource r, URI u) { - resourceMap.put(r, u); + directory.put(r.path(), u); } /** @@ -86,11 +86,12 @@ public class TestableConnectionProvider extends DummyConnection implements AcmeP } @Override - public Map resources(Session session, URI serverUri) throws AcmeException { - if (resourceMap.isEmpty()) { + public Map directory(Session session, URI serverUri) throws AcmeException { + Map result = directory.toMap(); + if (result.isEmpty()) { throw new UnsupportedOperationException(); } - return Collections.unmodifiableMap(resourceMap); + return result; } @Override diff --git a/acme4j-client/src/test/resources/json.properties b/acme4j-client/src/test/resources/json.properties index a2d4311d..c8d687a5 100644 --- a/acme4j-client/src/test/resources/json.properties +++ b/acme4j-client/src/test/resources/json.properties @@ -12,6 +12,29 @@ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # +directory = \ + {\ + "new-reg": "https://example.com/acme/new-reg",\ + "new-authz": "https://example.com/acme/new-authz",\ + "new-cert": "https://example.com/acme/new-cert",\ + "meta": {\ + "terms-of-service": "https://example.com/acme/terms",\ + "website": "https://www.example.com/",\ + "caa-identities": ["example.com"],\ + "x-test-string": "foobar",\ + "x-test-uri": "https://www.example.org",\ + "x-test-array": ["foo", "bar", "barfoo"]\ + }\ + } + +directoryNoMeta = \ + {\ + "new-reg": "https://example.com/acme/new-reg",\ + "new-authz": "https://example.com/acme/new-authz",\ + "new-cert": "https://example.com/acme/new-cert"\ + } + + newRegistration = \ {"resource":"new-reg",\ "contact":["mailto:foo@example.com"]} diff --git a/src/site/markdown/ca/index.md b/src/site/markdown/ca/index.md index 7b9daec3..da42ad70 100644 --- a/src/site/markdown/ca/index.md +++ b/src/site/markdown/ca/index.md @@ -18,6 +18,17 @@ Session session = Connecting via `acme` URI should always be preferred over using the directory URL. +## Metadata + +Some CAs provide metadata related to their ACME server. This information can be retrieved via the `Session` object: + +```java +Metadata meta = session.getMetadata(); +URI website = meta.getWebsite(); +``` + +`meta` is never `null`, even if the server did not provide any metadata. All of the `Metadata` getters are optional though, and may return `null` if the respective information was not provided by the server. + ## Available Providers In _acme4j_ these providers are available: