/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.goanna.background.fxa; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; import java.security.GeneralSecurityException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.concurrent.Executor; import javax.crypto.Mac; import org.json.simple.JSONObject; import org.mozilla.goanna.background.fxa.FxAccountClientException.FxAccountClientMalformedResponseException; import org.mozilla.goanna.background.fxa.FxAccountClientException.FxAccountClientRemoteException; import org.mozilla.goanna.fxa.FxAccountConstants; import org.mozilla.goanna.sync.ExtendedJSONObject; import org.mozilla.goanna.sync.Utils; import org.mozilla.goanna.sync.crypto.HKDF; import org.mozilla.goanna.sync.net.AuthHeaderProvider; import org.mozilla.goanna.sync.net.BaseResource; import org.mozilla.goanna.sync.net.BaseResourceDelegate; import org.mozilla.goanna.sync.net.HawkAuthHeaderProvider; import org.mozilla.goanna.sync.net.Resource; import org.mozilla.goanna.sync.net.SyncResponse; import org.mozilla.goanna.sync.net.SyncStorageResponse; import ch.boye.httpclientandroidlib.HttpEntity; import ch.boye.httpclientandroidlib.HttpHeaders; import ch.boye.httpclientandroidlib.HttpResponse; import ch.boye.httpclientandroidlib.client.ClientProtocolException; import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; /** * An HTTP client for talking to an FxAccount server. *

* The reference server is developed at * https://github.com/mozilla/picl-idp. * This implementation was developed against * https://github.com/mozilla/picl-idp/commit/c7a02a0cbbb43f332058dc060bd84a21e56ec208. *

* The delegate structure used is a little different from the rest of the code * base. We add a RequestDelegate layer that processes a typed * value extracted from the body of a successful response. *

* Further, we add internal CreateDelegate and * AuthDelegate delegates to make it easier to modify the request * bodies sent to the /create and /auth endpoints. */ public class FxAccountClient10 { protected static final String LOG_TAG = FxAccountClient10.class.getSimpleName(); protected static final String ACCEPT_HEADER = "application/json;charset=utf-8"; public static final String JSON_KEY_EMAIL = "email"; public static final String JSON_KEY_KEYFETCHTOKEN = "keyFetchToken"; public static final String JSON_KEY_SESSIONTOKEN = "sessionToken"; public static final String JSON_KEY_UID = "uid"; public static final String JSON_KEY_VERIFIED = "verified"; public static final String JSON_KEY_ERROR = "error"; public static final String JSON_KEY_MESSAGE = "message"; public static final String JSON_KEY_INFO = "info"; public static final String JSON_KEY_CODE = "code"; public static final String JSON_KEY_ERRNO = "errno"; protected static final String[] requiredErrorStringFields = { JSON_KEY_ERROR, JSON_KEY_MESSAGE, JSON_KEY_INFO }; protected static final String[] requiredErrorLongFields = { JSON_KEY_CODE, JSON_KEY_ERRNO }; /** * The server's URI. *

* We assume throughout that this ends with a trailing slash (and guarantee as * much in the constructor). */ protected final String serverURI; protected final Executor executor; public FxAccountClient10(String serverURI, Executor executor) { if (serverURI == null) { throw new IllegalArgumentException("Must provide a server URI."); } if (executor == null) { throw new IllegalArgumentException("Must provide a non-null executor."); } this.serverURI = serverURI.endsWith("/") ? serverURI : serverURI + "/"; if (!this.serverURI.endsWith("/")) { throw new IllegalArgumentException("Constructed serverURI must end with a trailing slash: " + this.serverURI); } this.executor = executor; } protected BaseResource getBaseResource(String path, Map queryParameters) throws UnsupportedEncodingException, URISyntaxException { if (queryParameters == null || queryParameters.isEmpty()) { return getBaseResource(path); } final String[] array = new String[2 * queryParameters.size()]; int i = 0; for (Entry entry : queryParameters.entrySet()) { array[i++] = entry.getKey(); array[i++] = entry.getValue(); } return getBaseResource(path, array); } /** * Create BaseResource, encoding query parameters carefully. *

* This is equivalent to android.net.Uri.Builder, which is not * present in our JUnit 4 tests. * * @param path fragment. * @param queryParameters list of key/value query parameter pairs. Must be even length! * @return BaseResource * @throws URISyntaxException * @throws UnsupportedEncodingException */ protected BaseResource getBaseResource(String path, String... queryParameters) throws URISyntaxException, UnsupportedEncodingException { final StringBuilder sb = new StringBuilder(serverURI); sb.append(path); if (queryParameters != null) { int i = 0; while (i < queryParameters.length) { sb.append(i > 0 ? "&" : "?"); final String key = queryParameters[i++]; final String val = queryParameters[i++]; sb.append(URLEncoder.encode(key, "UTF-8")); sb.append("="); sb.append(URLEncoder.encode(val, "UTF-8")); } } return new BaseResource(new URI(sb.toString())); } /** * Process a typed value extracted from a successful response (in an * endpoint-dependent way). */ public interface RequestDelegate { public void handleError(Exception e); public void handleFailure(FxAccountClientRemoteException e); public void handleSuccess(T result); } /** * A CreateDelegate produces the body of a /create request. */ public interface CreateDelegate { public JSONObject getCreateBody() throws FxAccountClientException; } /** * A AuthDelegate produces the bodies of an /auth/{start,finish} * request pair and exposes state generated by a successful response. */ public interface AuthDelegate { public JSONObject getAuthStartBody() throws FxAccountClientException; public void onAuthStartResponse(ExtendedJSONObject body) throws FxAccountClientException; public JSONObject getAuthFinishBody() throws FxAccountClientException; public byte[] getSharedBytes() throws FxAccountClientException; } /** * Thin container for two access tokens. */ public static class TwoTokens { public final byte[] keyFetchToken; public final byte[] sessionToken; public TwoTokens(byte[] keyFetchToken, byte[] sessionToken) { this.keyFetchToken = keyFetchToken; this.sessionToken = sessionToken; } } /** * Thin container for two cryptographic keys. */ public static class TwoKeys { public final byte[] kA; public final byte[] wrapkB; public TwoKeys(byte[] kA, byte[] wrapkB) { this.kA = kA; this.wrapkB = wrapkB; } } protected void invokeHandleError(final RequestDelegate delegate, final Exception e) { executor.execute(new Runnable() { @Override public void run() { delegate.handleError(e); } }); } /** * Translate resource callbacks into request callbacks invoked on the provided * executor. *

* Override handleSuccess to parse the body of the resource * request and call the request callback. handleSuccess is * invoked via the executor, so you don't need to delegate further. */ protected abstract class ResourceDelegate extends BaseResourceDelegate { protected abstract void handleSuccess(final int status, HttpResponse response, final ExtendedJSONObject body); protected final RequestDelegate delegate; protected final byte[] tokenId; protected final byte[] reqHMACKey; protected final SkewHandler skewHandler; /** * Create a delegate for an un-authenticated resource. */ public ResourceDelegate(final Resource resource, final RequestDelegate delegate) { this(resource, delegate, null, null); } /** * Create a delegate for a Hawk-authenticated resource. *

* Every Hawk request that encloses an entity (PATCH, POST, and PUT) will * include the payload verification hash. */ public ResourceDelegate(final Resource resource, final RequestDelegate delegate, final byte[] tokenId, final byte[] reqHMACKey) { super(resource); this.delegate = delegate; this.reqHMACKey = reqHMACKey; this.tokenId = tokenId; this.skewHandler = SkewHandler.getSkewHandlerForResource(resource); } @Override public AuthHeaderProvider getAuthHeaderProvider() { if (tokenId != null && reqHMACKey != null) { // We always include the payload verification hash for FxA Hawk-authenticated requests. final boolean includePayloadVerificationHash = true; return new HawkAuthHeaderProvider(Utils.byte2Hex(tokenId), reqHMACKey, includePayloadVerificationHash, skewHandler.getSkewInSeconds()); } return super.getAuthHeaderProvider(); } @Override public String getUserAgent() { return FxAccountConstants.USER_AGENT; } @Override public void handleHttpResponse(HttpResponse response) { try { final int status = validateResponse(response); skewHandler.updateSkew(response, now()); invokeHandleSuccess(status, response); } catch (FxAccountClientRemoteException e) { if (!skewHandler.updateSkew(response, now())) { // If we couldn't update skew, but we got a failure, let's try clearing the skew. skewHandler.resetSkew(); } invokeHandleFailure(e); } } protected void invokeHandleFailure(final FxAccountClientRemoteException e) { executor.execute(new Runnable() { @Override public void run() { delegate.handleFailure(e); } }); } protected void invokeHandleSuccess(final int status, final HttpResponse response) { executor.execute(new Runnable() { @Override public void run() { try { ExtendedJSONObject body = new SyncResponse(response).jsonObjectBody(); ResourceDelegate.this.handleSuccess(status, response, body); } catch (Exception e) { delegate.handleError(e); } } }); } @Override public void handleHttpProtocolException(final ClientProtocolException e) { invokeHandleError(delegate, e); } @Override public void handleHttpIOException(IOException e) { invokeHandleError(delegate, e); } @Override public void handleTransportException(GeneralSecurityException e) { invokeHandleError(delegate, e); } @Override public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { super.addHeaders(request, client); // The basics. final Locale locale = Locale.getDefault(); request.addHeader(HttpHeaders.ACCEPT_LANGUAGE, Utils.getLanguageTag(locale)); request.addHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER); } } protected void post(BaseResource resource, final JSONObject requestBody, final RequestDelegate delegate) { try { if (requestBody == null) { resource.post((HttpEntity) null); } else { resource.post(requestBody); } } catch (UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } } @SuppressWarnings("static-method") public long now() { return System.currentTimeMillis(); } /** * Intepret a response from the auth server. *

* Throw an appropriate exception on errors; otherwise, return the response's * status code. * * @return response's HTTP status code. * @throws FxAccountClientException */ public static int validateResponse(HttpResponse response) throws FxAccountClientRemoteException { final int status = response.getStatusLine().getStatusCode(); if (status == 200) { return status; } int code; int errno; String error; String message; String info; ExtendedJSONObject body; try { body = new SyncStorageResponse(response).jsonObjectBody(); body.throwIfFieldsMissingOrMisTyped(requiredErrorStringFields, String.class); body.throwIfFieldsMissingOrMisTyped(requiredErrorLongFields, Long.class); code = body.getLong(JSON_KEY_CODE).intValue(); errno = body.getLong(JSON_KEY_ERRNO).intValue(); error = body.getString(JSON_KEY_ERROR); message = body.getString(JSON_KEY_MESSAGE); info = body.getString(JSON_KEY_INFO); } catch (Exception e) { throw new FxAccountClientMalformedResponseException(response); } throw new FxAccountClientRemoteException(response, code, errno, error, message, info, body); } public void createAccount(final String email, final byte[] stretchedPWBytes, final String srpSalt, final String mainSalt, final RequestDelegate delegate) { try { createAccount(new FxAccount10CreateDelegate(email, stretchedPWBytes, srpSalt, mainSalt), delegate); } catch (final Exception e) { invokeHandleError(delegate, e); return; } } protected void createAccount(final CreateDelegate createDelegate, final RequestDelegate delegate) { JSONObject body = null; try { body = createDelegate.getCreateBody(); } catch (FxAccountClientException e) { invokeHandleError(delegate, e); return; } BaseResource resource; try { resource = getBaseResource("account/create"); } catch (URISyntaxException | UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } resource.delegate = new ResourceDelegate(resource, delegate) { @Override public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { String uid = body.getString("uid"); if (uid == null) { delegate.handleError(new FxAccountClientException("uid must be a non-null string")); return; } delegate.handleSuccess(uid); } }; post(resource, body, delegate); } protected void authStart(final AuthDelegate authDelegate, final RequestDelegate delegate) { JSONObject body; try { body = authDelegate.getAuthStartBody(); } catch (FxAccountClientException e) { invokeHandleError(delegate, e); return; } BaseResource resource; try { resource = getBaseResource("auth/start"); } catch (URISyntaxException | UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } resource.delegate = new ResourceDelegate(resource, delegate) { @Override public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { try { authDelegate.onAuthStartResponse(body); delegate.handleSuccess(authDelegate); } catch (Exception e) { delegate.handleError(e); return; } } }; post(resource, body, delegate); } protected void authFinish(final AuthDelegate authDelegate, RequestDelegate delegate) { JSONObject body; try { body = authDelegate.getAuthFinishBody(); } catch (FxAccountClientException e) { invokeHandleError(delegate, e); return; } BaseResource resource; try { resource = getBaseResource("auth/finish"); } catch (URISyntaxException | UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } resource.delegate = new ResourceDelegate(resource, delegate) { @Override public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { try { byte[] authToken = new byte[32]; unbundleBody(body, authDelegate.getSharedBytes(), FxAccountUtils.KW("auth/finish"), authToken); delegate.handleSuccess(authToken); } catch (Exception e) { delegate.handleError(e); return; } } }; post(resource, body, delegate); } public void login(final String email, final byte[] stretchedPWBytes, final RequestDelegate delegate) { login(new FxAccount10AuthDelegate(email, stretchedPWBytes), delegate); } protected void login(final AuthDelegate authDelegate, final RequestDelegate delegate) { authStart(authDelegate, new RequestDelegate() { @Override public void handleSuccess(AuthDelegate srpSession) { authFinish(srpSession, delegate); } @Override public void handleError(final Exception e) { invokeHandleError(delegate, e); return; } @Override public void handleFailure(final FxAccountClientRemoteException e) { executor.execute(new Runnable() { @Override public void run() { delegate.handleFailure(e); } }); } }); } public void sessionCreate(byte[] authToken, final RequestDelegate delegate) { final byte[] tokenId = new byte[32]; final byte[] reqHMACKey = new byte[32]; final byte[] requestKey = new byte[32]; try { HKDF.deriveMany(authToken, new byte[0], FxAccountUtils.KW("authToken"), tokenId, reqHMACKey, requestKey); } catch (Exception e) { invokeHandleError(delegate, e); return; } BaseResource resource; try { resource = getBaseResource("session/create"); } catch (URISyntaxException | UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } resource.delegate = new ResourceDelegate(resource, delegate, tokenId, reqHMACKey) { @Override public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { try { byte[] keyFetchToken = new byte[32]; byte[] sessionToken = new byte[32]; unbundleBody(body, requestKey, FxAccountUtils.KW("session/create"), keyFetchToken, sessionToken); delegate.handleSuccess(new TwoTokens(keyFetchToken, sessionToken)); return; } catch (Exception e) { delegate.handleError(e); return; } } }; post(resource, null, delegate); } public void sessionDestroy(byte[] sessionToken, final RequestDelegate delegate) { final byte[] tokenId = new byte[32]; final byte[] reqHMACKey = new byte[32]; try { HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey); } catch (Exception e) { invokeHandleError(delegate, e); return; } BaseResource resource; try { resource = getBaseResource("session/destroy"); } catch (URISyntaxException | UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } resource.delegate = new ResourceDelegate(resource, delegate, tokenId, reqHMACKey) { @Override public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { delegate.handleSuccess(null); } }; post(resource, null, delegate); } /** * Don't call this directly. Use unbundleBody instead. */ protected void unbundleBytes(byte[] bundleBytes, byte[] respHMACKey, byte[] respXORKey, byte[]... rest) throws InvalidKeyException, NoSuchAlgorithmException, FxAccountClientException { if (bundleBytes.length < 32) { throw new IllegalArgumentException("input bundle must include HMAC"); } int len = respXORKey.length; if (bundleBytes.length != len + 32) { throw new IllegalArgumentException("input bundle and XOR key with HMAC have different lengths"); } int left = len; for (byte[] array : rest) { left -= array.length; } if (left != 0) { throw new IllegalArgumentException("XOR key and total output arrays have different lengths"); } byte[] ciphertext = new byte[len]; byte[] HMAC = new byte[32]; System.arraycopy(bundleBytes, 0, ciphertext, 0, len); System.arraycopy(bundleBytes, len, HMAC, 0, 32); Mac hmacHasher = HKDF.makeHMACHasher(respHMACKey); byte[] computedHMAC = hmacHasher.doFinal(ciphertext); if (!Arrays.equals(computedHMAC, HMAC)) { throw new FxAccountClientException("Bad message HMAC"); } int offset = 0; for (byte[] array : rest) { for (int i = 0; i < array.length; i++) { array[i] = (byte) (respXORKey[offset + i] ^ ciphertext[offset + i]); } offset += array.length; } } protected void unbundleBody(ExtendedJSONObject body, byte[] requestKey, byte[] ctxInfo, byte[]... rest) throws Exception { int length = 0; for (byte[] array : rest) { length += array.length; } if (body == null) { throw new FxAccountClientException("body must be non-null"); } String bundle = body.getString("bundle"); if (bundle == null) { throw new FxAccountClientException("bundle must be a non-null string"); } byte[] bundleBytes = Utils.hex2Byte(bundle); final byte[] respHMACKey = new byte[32]; final byte[] respXORKey = new byte[length]; HKDF.deriveMany(requestKey, new byte[0], ctxInfo, respHMACKey, respXORKey); unbundleBytes(bundleBytes, respHMACKey, respXORKey, rest); } public void keys(byte[] keyFetchToken, final RequestDelegate delegate) { final byte[] tokenId = new byte[32]; final byte[] reqHMACKey = new byte[32]; final byte[] requestKey = new byte[32]; try { HKDF.deriveMany(keyFetchToken, new byte[0], FxAccountUtils.KW("keyFetchToken"), tokenId, reqHMACKey, requestKey); } catch (Exception e) { invokeHandleError(delegate, e); return; } BaseResource resource; try { resource = getBaseResource("account/keys"); } catch (URISyntaxException | UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } resource.delegate = new ResourceDelegate(resource, delegate, tokenId, reqHMACKey) { @Override public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { try { byte[] kA = new byte[FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES]; byte[] wrapkB = new byte[FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES]; unbundleBody(body, requestKey, FxAccountUtils.KW("account/keys"), kA, wrapkB); delegate.handleSuccess(new TwoKeys(kA, wrapkB)); return; } catch (Exception e) { delegate.handleError(e); return; } } }; resource.get(); } /** * Thin container for status response. */ public static class StatusResponse { public final String email; public final boolean verified; public StatusResponse(String email, boolean verified) { this.email = email; this.verified = verified; } } /** * Query the status of an account given a valid session token. *

* This API is a little odd: the auth server returns the email and * verification state of the account that corresponds to the (opaque) session * token. It might fail if the session token is unknown (or invalid, or * revoked). * * @param sessionToken * to query. * @param delegate * to invoke callbacks. */ public void status(byte[] sessionToken, final RequestDelegate delegate) { final byte[] tokenId = new byte[32]; final byte[] reqHMACKey = new byte[32]; final byte[] requestKey = new byte[32]; try { HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey); } catch (Exception e) { invokeHandleError(delegate, e); return; } BaseResource resource; try { resource = getBaseResource("recovery_email/status"); } catch (URISyntaxException | UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } resource.delegate = new ResourceDelegate(resource, delegate, tokenId, reqHMACKey) { @Override public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { try { String[] requiredStringFields = new String[] { JSON_KEY_EMAIL }; body.throwIfFieldsMissingOrMisTyped(requiredStringFields, String.class); String email = body.getString(JSON_KEY_EMAIL); Boolean verified = body.getBoolean(JSON_KEY_VERIFIED); delegate.handleSuccess(new StatusResponse(email, verified)); return; } catch (Exception e) { delegate.handleError(e); return; } } }; resource.get(); } @SuppressWarnings("unchecked") public void sign(final byte[] sessionToken, final ExtendedJSONObject publicKey, long durationInMilliseconds, final RequestDelegate delegate) { final JSONObject body = new JSONObject(); body.put("publicKey", publicKey); body.put("duration", durationInMilliseconds); final byte[] tokenId = new byte[32]; final byte[] reqHMACKey = new byte[32]; try { HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey); } catch (Exception e) { invokeHandleError(delegate, e); return; } BaseResource resource; try { resource = getBaseResource("certificate/sign"); } catch (URISyntaxException | UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } resource.delegate = new ResourceDelegate(resource, delegate, tokenId, reqHMACKey) { @Override public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { String cert = body.getString("cert"); if (cert == null) { delegate.handleError(new FxAccountClientException("cert must be a non-null string")); return; } delegate.handleSuccess(cert); } }; post(resource, body, delegate); } /** * Request a verification link be sent to the account email, given a valid session token. * * @param sessionToken * to authenticate with. * @param delegate * to invoke callbacks. */ public void resendCode(byte[] sessionToken, final RequestDelegate delegate) { final byte[] tokenId = new byte[32]; final byte[] reqHMACKey = new byte[32]; final byte[] requestKey = new byte[32]; try { HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey); } catch (Exception e) { invokeHandleError(delegate, e); return; } BaseResource resource; try { resource = getBaseResource("recovery_email/resend_code"); } catch (URISyntaxException | UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } resource.delegate = new ResourceDelegate(resource, delegate, tokenId, reqHMACKey) { @Override public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { try { delegate.handleSuccess(null); return; } catch (Exception e) { delegate.handleError(e); return; } } }; post(resource, new JSONObject(), delegate); } /** * Request a fresh unlock code be sent to the account email. *

* Since the account can be locked before the device can connect to it, the * only reasonable identifier is the account email. Since the account is * locked out, this request is un-authenticated. * * @param emailUTF8 * identifying account. * @param delegate * to invoke callbacks. */ @SuppressWarnings("unchecked") public void resendUnlockCode(final byte[] emailUTF8, final RequestDelegate delegate) { final BaseResource resource; final JSONObject body = new JSONObject(); try { resource = getBaseResource("account/unlock/resend_code"); body.put("email", new String(emailUTF8, "UTF-8")); } catch (URISyntaxException e) { invokeHandleError(delegate, e); return; } catch (UnsupportedEncodingException e) { invokeHandleError(delegate, e); return; } resource.delegate = new ResourceDelegate(resource, delegate) { @Override public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) { try { delegate.handleSuccess(null); return; } catch (Exception e) { delegate.handleError(e); return; } } }; post(resource, body, delegate); } }