Give access to directory metadata

pull/30/head
Richard Körber 2016-07-21 00:45:01 +02:00
parent 0195e5b16c
commit 8f2ac7c4c7
14 changed files with 313 additions and 138 deletions

View File

@ -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<String, Object> meta;
/**
* Creates an empty new {@link Metadata} instance.
*/
public Metadata() {
this(new HashMap<String, Object>());
}
/**
* Creates a new {@link Metadata} instance.
*
* @param meta
* JSON map of metadata
*/
public Metadata(Map<String, Object> 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<String> data = (Collection<String>) value;
return data.toArray(new String[data.size()]);
}
return null;
}
/**
* Returns the metadata as raw JSON map.
* <p>
* Do not modify the map or its contents. Changes will have a session-wide effect.
*/
public Map<String, Object> getJsonData() {
return meta;
}
}

View File

@ -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<Resource, URI> directoryMap = new EnumMap<>(Resource.class);
private final Map<Resource, URI> resourceMap = new EnumMap<>(Resource.class);
private final URI serverUri;
private KeyPair keyPair;
private AcmeProvider provider;
private byte[] nonce;
private Map<String, Object> 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<Resource, URI> 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<String, Object>) meta);
} else {
metadata = new Metadata();
}
return directoryMap.get(resource);
resourceMap.clear();
for (Map.Entry<String, Object> 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);
}
}
}
}
}
}
}

View File

@ -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<Resource, URI> readDirectory() throws IOException;
/**
* Updates a {@link Session} by evaluating the HTTP response header.
*

View File

@ -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<Resource, URI> readDirectory() throws IOException {
assertConnectionIsOpen();
String contentType = conn.getHeaderField("Content-Type");
if (!("application/json".equals(contentType))) {
throw new AcmeProtocolException("Unexpected content type: " + contentType);
}
EnumMap<Resource, URI> resourceMap = new EnumMap<>(Resource.class);
String response = "";
try {
response = readStream(conn.getInputStream());
Map<String, Object> result = JsonUtil.parseJson(response);
for (Map.Entry<String, Object> 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();

View File

@ -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<Resource, URI> resources(Session session, URI serverUri) throws AcmeException {
public Map<String, Object> 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);
}

View File

@ -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.
* <p>
* 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<Resource, URI> resources(Session session, URI serverUri) throws AcmeException;
Map<String, Object> directory(Session session, URI serverUri) throws AcmeException;
/**
* Creates a {@link Challenge} instance for the given challenge type.

View File

@ -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<String, Object> 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<Resource, URI> 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")));
}
}

View File

@ -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<Resource, URI> 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);
}
}

View File

@ -50,11 +50,6 @@ public class DummyConnection implements Connection {
throw new UnsupportedOperationException();
}
@Override
public Map<Resource, URI> readDirectory() {
throw new UnsupportedOperationException();
}
@Override
public void updateSession(Session session) {
throw new UnsupportedOperationException();

View File

@ -99,7 +99,7 @@ public class SessionProviderTest {
}
@Override
public Map<Resource, URI> resources(Session session, URI serverUri) throws AcmeException {
public Map<String, Object> directory(Session session, URI serverUri) throws AcmeException {
throw new UnsupportedOperationException();
}
@ -127,7 +127,7 @@ public class SessionProviderTest {
}
@Override
public Map<Resource, URI> resources(Session session, URI serverUri) throws AcmeException {
public Map<String, Object> directory(Session session, URI serverUri) throws AcmeException {
throw new UnsupportedOperationException();
}

View File

@ -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<Resource, URI> 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<Resource, URI> map = provider.resources(session, testServerUri);
assertThat(map, is(resourceMap));
Map<String, Object> 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);
}

View File

@ -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<Resource, URI> resourceMap = new HashMap<>();
private final Map<String, Challenge> 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<Resource, URI> resources(Session session, URI serverUri) throws AcmeException {
if (resourceMap.isEmpty()) {
public Map<String, Object> directory(Session session, URI serverUri) throws AcmeException {
Map<String, Object> result = directory.toMap();
if (result.isEmpty()) {
throw new UnsupportedOperationException();
}
return Collections.unmodifiableMap(resourceMap);
return result;
}
@Override

View File

@ -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"]}

View File

@ -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: