001/*
002 * Copyright (c) 2010-2025 Mark Allen, Norbert Bartels.
003 *
004 * Permission is hereby granted, free of charge, to any person obtaining a copy
005 * of this software and associated documentation files (the "Software"), to deal
006 * in the Software without restriction, including without limitation the rights
007 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
008 * copies of the Software, and to permit persons to whom the Software is
009 * furnished to do so, subject to the following conditions:
010 *
011 * The above copyright notice and this permission notice shall be included in
012 * all copies or substantial portions of the Software.
013 *
014 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
015 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
016 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
017 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
018 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
019 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
020 * THE SOFTWARE.
021 */
022package com.restfb;
023
024import static com.restfb.logging.RestFBLogger.CLIENT_LOGGER;
025import static com.restfb.util.EncodingUtils.decodeBase64;
026import static com.restfb.util.ObjectUtil.requireNotEmpty;
027import static com.restfb.util.ObjectUtil.verifyParameterPresence;
028import static com.restfb.util.StringUtils.*;
029import static com.restfb.util.UrlUtils.urlEncode;
030import static java.lang.String.format;
031import static java.net.HttpURLConnection.*;
032import static java.util.Arrays.asList;
033import static java.util.Collections.emptyList;
034
035import java.io.IOException;
036import java.util.*;
037import java.util.function.Supplier;
038import java.util.stream.Collectors;
039import java.util.stream.Stream;
040
041import javax.crypto.Mac;
042import javax.crypto.spec.SecretKeySpec;
043
044import com.restfb.WebRequestor.Response;
045import com.restfb.batch.BatchRequest;
046import com.restfb.batch.BatchResponse;
047import com.restfb.exception.*;
048import com.restfb.exception.devicetoken.*;
049import com.restfb.exception.generator.DefaultFacebookExceptionGenerator;
050import com.restfb.exception.generator.FacebookExceptionGenerator;
051import com.restfb.json.*;
052import com.restfb.scope.ScopeBuilder;
053import com.restfb.types.DebugTokenInfo;
054import com.restfb.types.DeviceCode;
055import com.restfb.util.EncodingUtils;
056import com.restfb.util.ObjectUtil;
057import com.restfb.util.StringUtils;
058
059/**
060 * Default implementation of a <a href="http://developers.facebook.com/docs/api">Facebook Graph API</a> client.
061 * 
062 * @author <a href="http://restfb.com">Mark Allen</a>
063 */
064public class DefaultFacebookClient extends BaseFacebookClient implements FacebookClient {
065  public static final String CLIENT_ID = "client_id";
066  public static final String APP_ID = "appId";
067  public static final String APP_SECRET = "appSecret";
068  public static final String SCOPE = "scope";
069  public static final String CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE = "Unable to extract access token from response.";
070  public static final String PARAM_CLIENT_SECRET = "client_secret";
071
072  public static final String CONNECTION = "connection";
073  public static final String CONNECTION_TYPE = "connectionType";
074  public static final String ALGORITHM = "algorithm";
075  public static final String PATH_OAUTH_ACCESS_TOKEN = "oauth/access_token";
076  public static final String REDIRECT_URI = "redirect_uri";
077  public static final String GRANT_TYPE = "grant_type";
078  public static final String CODE = "code";
079  /**
080   * Graph API access token.
081   */
082  protected String accessToken;
083
084  /**
085   * Graph API app secret.
086   */
087  protected String appSecret;
088
089  /**
090   * facebook exception generator to convert Facebook error json into java exceptions
091   */
092  private FacebookExceptionGenerator graphFacebookExceptionGenerator;
093
094  /**
095   * holds the Facebook endpoint urls
096   */
097  private FacebookEndpoints facebookEndpointUrls = new FacebookEndpoints() {};
098
099  /**
100   * Reserved "multiple IDs" parameter name.
101   */
102  protected static final String IDS_PARAM_NAME = "ids";
103
104  /**
105   * Version of API endpoint.
106   */
107  protected Version apiVersion;
108
109  /**
110   * By default, this is <code>false</code>, so real http DELETE is used
111   */
112  protected boolean httpDeleteFallback;
113
114  protected boolean accessTokenInHeader;
115
116  protected DefaultFacebookClient() {
117    this(Version.LATEST);
118  }
119
120  /**
121   * Creates a Facebook Graph API client with the given {@code apiVersion}.
122   * 
123   * @param apiVersion
124   *          Version of the api endpoint
125   */
126  public DefaultFacebookClient(Version apiVersion) {
127    this(null, null, new DefaultWebRequestor(), new DefaultJsonMapper(), apiVersion);
128  }
129
130  /**
131   * Creates a Facebook Graph API client with the given {@code accessToken}.
132   * 
133   * @param accessToken
134   *          A Facebook OAuth access token.
135   * @param apiVersion
136   *          Version of the api endpoint
137   * @since 1.6.14
138   */
139  public DefaultFacebookClient(String accessToken, Version apiVersion) {
140    this(accessToken, null, new DefaultWebRequestor(), new DefaultJsonMapper(), apiVersion);
141  }
142
143  /**
144   * Creates a Facebook Graph API client with the given {@code accessToken}.
145   * 
146   * @param accessToken
147   *          A Facebook OAuth access token.
148   * @param appSecret
149   *          A Facebook application secret.
150   * @param apiVersion
151   *          Version of the api endpoint
152   * @since 1.6.14
153   */
154  public DefaultFacebookClient(String accessToken, String appSecret, Version apiVersion) {
155    this(accessToken, appSecret, new DefaultWebRequestor(), new DefaultJsonMapper(), apiVersion);
156  }
157
158  /**
159   * Creates a Facebook Graph API client with the given {@code accessToken}.
160   * 
161   * @param accessToken
162   *          A Facebook OAuth access token.
163   * @param webRequestor
164   *          The {@link WebRequestor} implementation to use for sending requests to the API endpoint.
165   * @param jsonMapper
166   *          The {@link JsonMapper} implementation to use for mapping API response JSON to Java objects.
167   * @param apiVersion
168   *          Version of the api endpoint
169   * @throws NullPointerException
170   *           If {@code jsonMapper} or {@code webRequestor} is {@code null}.
171   * @since 1.6.14
172   */
173  public DefaultFacebookClient(String accessToken, WebRequestor webRequestor, JsonMapper jsonMapper,
174      Version apiVersion) {
175    this(accessToken, null, webRequestor, jsonMapper, apiVersion);
176  }
177
178  /**
179   * Creates a Facebook Graph API client with the given {@code accessToken}, {@code webRequestor}, and
180   * {@code jsonMapper}.
181   * 
182   * @param accessToken
183   *          A Facebook OAuth access token.
184   * @param appSecret
185   *          A Facebook application secret.
186   * @param webRequestor
187   *          The {@link WebRequestor} implementation to use for sending requests to the API endpoint.
188   * @param jsonMapper
189   *          The {@link JsonMapper} implementation to use for mapping API response JSON to Java objects.
190   * @param apiVersion
191   *          Version of the api endpoint
192   * @throws NullPointerException
193   *           If {@code jsonMapper} or {@code webRequestor} is {@code null}.
194   */
195  public DefaultFacebookClient(String accessToken, String appSecret, WebRequestor webRequestor, JsonMapper jsonMapper,
196      Version apiVersion) {
197    super();
198
199    verifyParameterPresence("jsonMapper", jsonMapper);
200    verifyParameterPresence("webRequestor", webRequestor);
201
202    this.accessToken = trimToNull(accessToken);
203    this.appSecret = trimToNull(appSecret);
204
205    this.webRequestor = webRequestor;
206    this.jsonMapper = jsonMapper;
207    this.jsonMapper.setFacebookClient(this);
208    this.apiVersion = Optional.ofNullable(apiVersion).orElse(Version.UNVERSIONED);
209    graphFacebookExceptionGenerator = new DefaultFacebookExceptionGenerator();
210  }
211
212  /**
213   * Switch between access token in header and access token in query parameters (default)
214   * 
215   * @param accessTokenInHttpHeader
216   *          <code>true</code> use access token as header field, <code>false</code> use access token as query parameter
217   *          (default)
218   */
219  public void setHeaderAuthorization(boolean accessTokenInHttpHeader) {
220    this.accessTokenInHeader = accessTokenInHttpHeader;
221  }
222
223  /**
224   * override the default facebook exception generator to provide a custom handling for the facebook error objects
225   * 
226   * @param exceptionGenerator
227   *          the custom exception generator implementing the {@link FacebookExceptionGenerator} interface
228   */
229  public void setFacebookExceptionGenerator(FacebookExceptionGenerator exceptionGenerator) {
230    graphFacebookExceptionGenerator = exceptionGenerator;
231  }
232
233  /**
234   * fetch the current facebook exception generator implementing the {@link FacebookExceptionGenerator} interface
235   * 
236   * @return the current facebook exception generator
237   */
238  public FacebookExceptionGenerator getFacebookExceptionGenerator() {
239    return graphFacebookExceptionGenerator;
240  }
241
242  @Override
243  public boolean deleteObject(String object, Parameter... parameters) {
244    verifyParameterPresence("object", object);
245
246    String responseString = makeRequest(object, true, true, null, parameters);
247
248    try {
249      JsonValue jObj = Json.parse(responseString);
250      boolean success = false;
251      if (jObj.isObject()) {
252        if (jObj.asObject().contains("success")) {
253          success = jObj.asObject().get("success").asBoolean();
254        }
255        if (jObj.asObject().contains("result")) {
256          success = jObj.asObject().get("result").asString().contains("Successfully deleted");
257        }
258      } else {
259        success = jObj.asBoolean();
260      }
261      return success;
262    } catch (ParseException jex) {
263      CLIENT_LOGGER.trace("no valid JSON returned while deleting a object, using returned String instead", jex);
264      return "true".equals(responseString);
265    }
266  }
267
268  /**
269   * @see com.restfb.FacebookClient#fetchConnection(java.lang.String, java.lang.Class, com.restfb.Parameter[])
270   */
271  @Override
272  public <T> Connection<T> fetchConnection(String connection, Class<T> connectionType, Parameter... parameters) {
273    verifyParameterPresence(CONNECTION, connection);
274    verifyParameterPresence(CONNECTION_TYPE, connectionType);
275    return new Connection<>(this, makeRequest(connection, parameters), connectionType);
276  }
277
278  /**
279   * @see com.restfb.FacebookClient#fetchConnectionPage(java.lang.String, java.lang.Class)
280   */
281  @Override
282  public <T> Connection<T> fetchConnectionPage(final String connectionPageUrl, Class<T> connectionType) {
283    String connectionJson;
284    if (!isBlank(accessToken) && !isBlank(appSecret)) {
285      if (isAppSecretProofWithTime()) {
286        long now = System.currentTimeMillis() / 1000;
287        WebRequestor.Request request = new WebRequestor.Request(String.format("%s&%s=%s&%s=%s", connectionPageUrl,
288                urlEncode(APP_SECRET_PROOF_TIME_PARAM_NAME), now,
289                urlEncode(APP_SECRET_PROOF_PARAM_NAME), obtainAppSecretProof(accessToken + "|" + now, appSecret)), null);
290        connectionJson = makeRequestAndProcessResponse(() -> webRequestor.executeGet(request));
291      } else {
292        WebRequestor.Request request = new WebRequestor.Request(String.format("%s&%s=%s", connectionPageUrl,
293                urlEncode(APP_SECRET_PROOF_PARAM_NAME), obtainAppSecretProof(accessToken, appSecret)), null);
294        connectionJson = makeRequestAndProcessResponse(() -> webRequestor.executeGet(request));
295      }
296    } else {
297      connectionJson = makeRequestAndProcessResponse(
298        () -> webRequestor.executeGet(new WebRequestor.Request(connectionPageUrl, getHeaderAccessToken())));
299    }
300
301    return new Connection<>(this, connectionJson, connectionType);
302  }
303
304  /**
305   * @see com.restfb.FacebookClient#fetchObject(java.lang.String, java.lang.Class, com.restfb.Parameter[])
306   */
307  @Override
308  public <T> T fetchObject(String object, Class<T> objectType, Parameter... parameters) {
309    verifyParameterPresence("object", object);
310    verifyParameterPresence("objectType", objectType);
311    return jsonMapper.toJavaObject(makeRequest(object, parameters), objectType);
312  }
313
314  @Override
315  public FacebookClient createClientWithAccessToken(String accessToken) {
316    return new DefaultFacebookClient(accessToken, this.appSecret, getWebRequestor(), getJsonMapper(), this.apiVersion);
317  }
318
319  /**
320   * @see com.restfb.FacebookClient#fetchObjects(java.util.List, java.lang.Class, com.restfb.Parameter[])
321   */
322  @Override
323  public <T> T fetchObjects(List<String> ids, Class<T> objectType, Parameter... parameters) {
324    verifyParameterPresence("ids", ids);
325    verifyParameterPresence(CONNECTION_TYPE, objectType);
326    requireNotEmpty(ids, "The list of IDs cannot be empty.");
327
328    if (Stream.of(parameters).anyMatch(p -> IDS_PARAM_NAME.equals(p.name))) {
329      throw new IllegalArgumentException("You cannot specify the '" + IDS_PARAM_NAME + "' URL parameter yourself - "
330          + "RestFB will populate this for you with the list of IDs you passed to this method.");
331    }
332
333    JsonArray idArray = new JsonArray();
334
335    // Normalize the IDs
336    for (String id : ids) {
337      throwIAEonBlankId(id);
338      idArray.add(id.trim());
339    }
340
341    try {
342      String jsonString = makeRequest("",
343        parametersWithAdditionalParameter(Parameter.with(IDS_PARAM_NAME, idArray.toString()), parameters));
344
345      return jsonMapper.toJavaObject(jsonString, objectType);
346    } catch (ParseException e) {
347      throw new FacebookJsonMappingException("Unable to map connection JSON to Java objects", e);
348    }
349  }
350
351  private void throwIAEonBlankId(String id) {
352    if (StringUtils.isBlank(id)) {
353      throw new IllegalArgumentException("The list of IDs cannot contain blank strings.");
354    }
355  }
356
357  /**
358   * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.BinaryAttachment,
359   *      com.restfb.Parameter[])
360   */
361  @Override
362  public <T> T publish(String connection, Class<T> objectType, List<BinaryAttachment> binaryAttachments,
363      Parameter... parameters) {
364    verifyParameterPresence(CONNECTION, connection);
365
366    return jsonMapper.toJavaObject(makeRequest(connection, true, false, binaryAttachments, parameters), objectType);
367  }
368
369  /**
370   * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.BinaryAttachment,
371   *      com.restfb.Parameter[])
372   */
373  @Override
374  public <T> T publish(String connection, Class<T> objectType, BinaryAttachment binaryAttachment,
375      Parameter... parameters) {
376    List<BinaryAttachment> attachments =
377        Optional.ofNullable(binaryAttachment).map(Collections::singletonList).orElse(null);
378    return publish(connection, objectType, attachments, parameters);
379  }
380
381  /**
382   * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.Parameter[])
383   */
384  @Override
385  public <T> T publish(String connection, Class<T> objectType, Parameter... parameters) {
386    return publish(connection, objectType, (List<BinaryAttachment>) null, parameters);
387  }
388
389  @Override
390  public <T> T publish(String connection, Class<T> objectType, Body body, Parameter... parameters) {
391    verifyParameterPresence(CONNECTION, connection);
392    return jsonMapper.toJavaObject(makeRequest(connection, true, false, null, body, parameters), objectType);
393  }
394
395  @Override
396  public String getLogoutUrl(String next) {
397    String parameterString;
398    if (next != null) {
399      Parameter p = Parameter.with("next", next);
400      parameterString = toParameterString(false, p);
401    } else {
402      parameterString = toParameterString(false);
403    }
404
405    final String fullEndPoint = createEndpointForApiCall("logout.php", false, false);
406    return fullEndPoint + "?" + parameterString;
407  }
408
409  /**
410   * @see com.restfb.FacebookClient#executeBatch(com.restfb.batch.BatchRequest[])
411   */
412  @Override
413  public List<BatchResponse> executeBatch(BatchRequest... batchRequests) {
414    return executeBatch(asList(batchRequests), Collections.emptyList());
415  }
416
417  /**
418   * @see com.restfb.FacebookClient#executeBatch(java.util.List)
419   */
420  @Override
421  public List<BatchResponse> executeBatch(List<BatchRequest> batchRequests) {
422    return executeBatch(batchRequests, Collections.emptyList());
423  }
424
425  /**
426   * @see com.restfb.FacebookClient#executeBatch(java.util.List, java.util.List)
427   */
428  @Override
429  public List<BatchResponse> executeBatch(List<BatchRequest> batchRequests, List<BinaryAttachment> binaryAttachments) {
430    verifyParameterPresence("binaryAttachments", binaryAttachments);
431    requireNotEmpty(batchRequests, "You must specify at least one batch request.");
432
433    return jsonMapper.toJavaList(
434      makeRequest("", true, false, binaryAttachments, Parameter.with("batch", jsonMapper.toJson(batchRequests, true))),
435      BatchResponse.class);
436  }
437
438  /**
439   * @see com.restfb.FacebookClient#convertSessionKeysToAccessTokens(java.lang.String, java.lang.String,
440   *      java.lang.String[])
441   */
442  @Override
443  public List<AccessToken> convertSessionKeysToAccessTokens(String appId, String secretKey, String... sessionKeys) {
444    verifyParameterPresence(APP_ID, appId);
445    verifyParameterPresence("secretKey", secretKey);
446
447    if (sessionKeys == null || sessionKeys.length == 0) {
448      return emptyList();
449    }
450
451    String json = makeRequest("/oauth/exchange_sessions", true, false, null, Parameter.with(CLIENT_ID, appId),
452      Parameter.with(PARAM_CLIENT_SECRET, secretKey), Parameter.with("sessions", String.join(",", sessionKeys)));
453
454    return jsonMapper.toJavaList(json, AccessToken.class);
455  }
456
457  /**
458   * @see com.restfb.FacebookClient#obtainAppAccessToken(java.lang.String, java.lang.String)
459   */
460  @Override
461  public AccessToken obtainAppAccessToken(String appId, String appSecret) {
462    verifyParameterPresence(APP_ID, appId);
463    verifyParameterPresence(APP_SECRET, appSecret);
464
465    String response = makeRequest(PATH_OAUTH_ACCESS_TOKEN, Parameter.with(GRANT_TYPE, "client_credentials"),
466      Parameter.with(CLIENT_ID, appId), Parameter.with(PARAM_CLIENT_SECRET, appSecret));
467
468    try {
469      return getAccessTokenFromResponse(response);
470    } catch (Exception t) {
471      throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t);
472    }
473  }
474
475  @Override
476  public DeviceCode fetchDeviceCode(ScopeBuilder scope) {
477    verifyParameterPresence(SCOPE, scope);
478    ObjectUtil.requireNotNull(accessToken,
479      () -> new IllegalStateException("access token is required to fetch a device access token"));
480
481    String response = makeRequest("device/login", true, false, null, Parameter.with("type", "device_code"),
482      Parameter.with(SCOPE, scope.toString()));
483    return jsonMapper.toJavaObject(response, DeviceCode.class);
484  }
485
486  @Override
487  public AccessToken obtainDeviceAccessToken(String code) throws FacebookDeviceTokenCodeExpiredException,
488      FacebookDeviceTokenPendingException, FacebookDeviceTokenDeclinedException, FacebookDeviceTokenSlowdownException {
489    verifyParameterPresence(CODE, code);
490
491    ObjectUtil.requireNotNull(accessToken,
492      () -> new IllegalStateException("access token is required to fetch a device access token"));
493
494    try {
495      String response = makeRequest("device/login_status", true, false, null, Parameter.with("type", "device_token"),
496        Parameter.with(CODE, code));
497      return getAccessTokenFromResponse(response);
498    } catch (FacebookOAuthException foae) {
499      DeviceTokenExceptionFactory.createFrom(foae);
500      return null;
501    }
502  }
503
504  /**
505   * @see com.restfb.FacebookClient#obtainUserAccessToken(java.lang.String, java.lang.String, java.lang.String,
506   *      java.lang.String)
507   */
508  @Override
509  public AccessToken obtainUserAccessToken(String appId, String appSecret, String redirectUri,
510      String verificationCode) {
511    verifyParameterPresence(APP_ID, appId);
512    verifyParameterPresence(APP_SECRET, appSecret);
513    verifyParameterPresence("verificationCode", verificationCode);
514
515    String response = makeRequest(PATH_OAUTH_ACCESS_TOKEN, Parameter.with(CLIENT_ID, appId),
516      Parameter.with(PARAM_CLIENT_SECRET, appSecret), Parameter.with(CODE, verificationCode),
517      Parameter.with(REDIRECT_URI, redirectUri));
518
519    try {
520      return getAccessTokenFromResponse(response);
521    } catch (Exception t) {
522      throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t);
523    }
524  }
525
526  /**
527   * @see com.restfb.FacebookClient#obtainExtendedAccessToken(java.lang.String, java.lang.String)
528   */
529  @Override
530  public AccessToken obtainExtendedAccessToken(String appId, String appSecret) {
531    ObjectUtil.requireNotNull(accessToken,
532      () -> new IllegalStateException(
533        format("You cannot call this method because you did not construct this instance of %s with an access token.",
534          getClass().getSimpleName())));
535
536    return obtainExtendedAccessToken(appId, appSecret, accessToken);
537  }
538
539  @Override
540  public AccessToken obtainRefreshedExtendedAccessToken() {
541    throw new UnsupportedOperationException(
542      "obtaining a refreshed extended access token is not supported by this client");
543  }
544
545  /**
546   * @see com.restfb.FacebookClient#obtainExtendedAccessToken(java.lang.String, java.lang.String, java.lang.String)
547   */
548  @Override
549  public AccessToken obtainExtendedAccessToken(String appId, String appSecret, String accessToken) {
550    verifyParameterPresence(APP_ID, appId);
551    verifyParameterPresence(APP_SECRET, appSecret);
552    verifyParameterPresence("accessToken", accessToken);
553
554    String response = makeRequest("/oauth/access_token", false, false, null, //
555      Parameter.with(CLIENT_ID, appId), //
556      Parameter.with(PARAM_CLIENT_SECRET, appSecret), //
557      Parameter.with(GRANT_TYPE, "fb_exchange_token"), //
558      Parameter.with("fb_exchange_token", accessToken), //
559      Parameter.withFields("access_token,expires_in,token_type"));
560
561    try {
562      return getAccessTokenFromResponse(response);
563    } catch (Exception t) {
564      throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t);
565    }
566  }
567
568  protected AccessToken getAccessTokenFromResponse(String response) {
569    AccessToken token;
570    try {
571      token = getJsonMapper().toJavaObject(response, AccessToken.class);
572    } catch (FacebookJsonMappingException fjme) {
573      CLIENT_LOGGER.trace("could not map response to access token class try to fetch directly from String", fjme);
574      token = AccessToken.fromQueryString(response);
575    }
576    token.setClient(createClientWithAccessToken(token.getAccessToken()));
577    return token;
578  }
579
580  @Override
581  @SuppressWarnings("unchecked")
582  public <T> T parseSignedRequest(String signedRequest, String appSecret, Class<T> objectType) {
583    verifyParameterPresence("signedRequest", signedRequest);
584    verifyParameterPresence(APP_SECRET, appSecret);
585    verifyParameterPresence("objectType", objectType);
586
587    String[] signedRequestTokens = signedRequest.split("[.]");
588
589    if (signedRequestTokens.length != 2) {
590      throw new FacebookSignedRequestParsingException(format(
591        "Signed request '%s' is expected to be signature and payload strings separated by a '.'", signedRequest));
592    }
593
594    String encodedSignature = signedRequestTokens[0];
595    String urlDecodedSignature = urlDecodeSignedRequestToken(encodedSignature);
596    byte[] signature = decodeBase64(urlDecodedSignature);
597
598    String encodedPayload = signedRequestTokens[1];
599    String urlDecodedPayload = urlDecodeSignedRequestToken(encodedPayload);
600    String payload = StringUtils.toString(decodeBase64(urlDecodedPayload));
601
602    // Convert payload to a JsonObject, so we can pull algorithm data out of it
603    JsonObject payloadObject = getJsonMapper().toJavaObject(payload, JsonObject.class);
604
605    if (!payloadObject.contains(ALGORITHM)) {
606      throw new FacebookSignedRequestParsingException("Unable to detect algorithm used for signed request");
607    }
608
609    String algorithm = payloadObject.getString(ALGORITHM, null);
610
611    if (!verifySignedRequest(appSecret, algorithm, encodedPayload, signature)) {
612      throw new FacebookSignedRequestVerificationException(
613        "Signed request verification failed. Are you sure the request was made for the app identified by the app secret you provided?");
614    }
615
616    // Marshal to the user's preferred type.
617    // If the user asked for a JsonObject, send back the one we already parsed.
618    return objectType.equals(JsonObject.class) ? (T) payloadObject : getJsonMapper().toJavaObject(payload, objectType);
619  }
620
621  /**
622   * Decodes a component of a signed request received from Facebook using FB's special URL-encoding strategy.
623   * 
624   * @param signedRequestToken
625   *          Token to decode.
626   * @return The decoded token.
627   */
628  protected String urlDecodeSignedRequestToken(String signedRequestToken) {
629    verifyParameterPresence("signedRequestToken", signedRequestToken);
630    return signedRequestToken.replace("-", "+").replace("_", "/").trim();
631  }
632
633  @Override
634  public String getLoginDialogUrl(String appId, String redirectUri, ScopeBuilder scope, String state,
635      Parameter... parameters) {
636    List<Parameter> parameterList = asList(parameters);
637    return getGenericLoginDialogUrl(appId, redirectUri, scope,
638      () -> getFacebookEndpointUrls().getFacebookEndpoint() + "/dialog/oauth", state, parameterList);
639  }
640
641  @Override
642  public String getLoginDialogUrl(String appId, String redirectUri, ScopeBuilder scope, Parameter... parameters) {
643    return getLoginDialogUrl(appId, redirectUri, scope, null, parameters);
644  }
645
646  @Override
647  public String getBusinessLoginDialogUrl(String appId, String redirectUri, String configId, String state,
648                                          Parameter... parameters) {
649    verifyParameterPresence("configId", configId);
650
651    List<Parameter> parameterList = new ArrayList<>(asList(parameters));
652    parameterList.add(Parameter.with("config_id", configId));
653    parameterList.add(Parameter.with("response_type", "code"));
654    parameterList.add(Parameter.with("override_default_response_type", true));
655
656    return getGenericLoginDialogUrl(appId, redirectUri, new ScopeBuilder(true),
657            () -> getFacebookEndpointUrls().getFacebookEndpoint() + "/dialog/oauth", state, parameterList);
658  }
659
660  protected String getGenericLoginDialogUrl(String appId, String redirectUri, ScopeBuilder scope,
661      Supplier<String> endpointSupplier, String state, List<Parameter> parameters) {
662    verifyParameterPresence(APP_ID, appId);
663    verifyParameterPresence("redirectUri", redirectUri);
664    verifyParameterPresence(SCOPE, scope);
665
666    String dialogUrl = endpointSupplier.get();
667
668    List<Parameter> parameterList = new ArrayList<>();
669    parameterList.add(Parameter.with(CLIENT_ID, appId));
670    parameterList.add(Parameter.with(REDIRECT_URI, redirectUri));
671    if (!scope.toString().isEmpty()) {
672      parameterList.add(Parameter.with(SCOPE, scope.toString()));
673    }
674
675    if (StringUtils.isNotBlank(state)) {
676      parameterList.add(Parameter.with("state", state));
677    }
678
679    // add optional parameters
680    parameterList.addAll(parameters);
681    return dialogUrl + "?" + toParameterString(false, parameterList.toArray(new Parameter[0]));
682  }
683
684  /**
685   * Verifies that the signed request is really from Facebook.
686   * 
687   * @param appSecret
688   *          The secret for the app that can verify this signed request.
689   * @param algorithm
690   *          Signature algorithm specified by FB in the decoded payload.
691   * @param encodedPayload
692   *          The encoded payload used to generate a signature for comparison against the provided {@code signature}.
693   * @param signature
694   *          The decoded signature extracted from the signed request. Compared against a signature generated from
695   *          {@code encodedPayload}.
696   * @return {@code true} if the signed request is verified, {@code false} if not.
697   */
698  protected boolean verifySignedRequest(String appSecret, String algorithm, String encodedPayload, byte[] signature) {
699    verifyParameterPresence(APP_SECRET, appSecret);
700    verifyParameterPresence(ALGORITHM, algorithm);
701    verifyParameterPresence("encodedPayload", encodedPayload);
702    verifyParameterPresence("signature", signature);
703
704    // Normalize algorithm name...FB calls it differently than Java does
705    if ("HMAC-SHA256".equalsIgnoreCase(algorithm)) {
706      algorithm = "HMACSHA256";
707    }
708
709    try {
710      Mac mac = Mac.getInstance(algorithm);
711      mac.init(new SecretKeySpec(toBytes(appSecret), algorithm));
712      byte[] payloadSignature = mac.doFinal(toBytes(encodedPayload));
713      return Arrays.equals(signature, payloadSignature);
714    } catch (Exception e) {
715      throw new FacebookSignedRequestVerificationException("Unable to perform signed request verification", e);
716    }
717  }
718
719  /**
720   * @see com.restfb.FacebookClient#debugToken(java.lang.String)
721   */
722  @Override
723  public DebugTokenInfo debugToken(String inputToken) {
724    verifyParameterPresence("inputToken", inputToken);
725    String response = makeRequest("/debug_token", Parameter.with("input_token", inputToken));
726
727    try {
728      JsonObject json = Json.parse(response).asObject();
729
730      // FB sometimes returns an empty DebugTokenInfo and then the 'data' field is an array
731      if (json.contains("data") && json.get("data").isArray()) {
732        return null;
733      }
734
735      JsonObject data = json.get("data").asObject();
736      return getJsonMapper().toJavaObject(data.toString(), DebugTokenInfo.class);
737    } catch (Exception t) {
738      throw new FacebookResponseContentException("Unable to parse JSON from response.", t);
739    }
740  }
741
742  /**
743   * @see com.restfb.FacebookClient#getJsonMapper()
744   */
745  @Override
746  public JsonMapper getJsonMapper() {
747    return jsonMapper;
748  }
749
750  /**
751   * @see com.restfb.FacebookClient#getWebRequestor()
752   */
753  @Override
754  public WebRequestor getWebRequestor() {
755    return webRequestor;
756  }
757
758  /**
759   * Coordinates the process of executing the API request GET/POST and processing the response we receive from the
760   * endpoint.
761   * 
762   * @param endpoint
763   *          Facebook Graph API endpoint.
764   * @param parameters
765   *          Arbitrary number of parameters to send along to Facebook as part of the API call.
766   * @return The JSON returned by Facebook for the API call.
767   * @throws FacebookException
768   *           If an error occurs while making the Facebook API POST or processing the response.
769   */
770  protected String makeRequest(String endpoint, Parameter... parameters) {
771    return makeRequest(endpoint, false, false, null, parameters);
772  }
773
774  protected String makeRequest(String endpoint, final boolean executeAsPost, final boolean executeAsDelete,
775      final List<BinaryAttachment> binaryAttachments, Parameter... parameters) {
776    return makeRequest(endpoint, executeAsPost, executeAsDelete, binaryAttachments, null, parameters);
777  }
778
779  /**
780   * Coordinates the process of executing the API request GET/POST and processing the response we receive from the
781   * endpoint.
782   * 
783   * @param endpoint
784   *          Facebook Graph API endpoint.
785   * @param executeAsPost
786   *          {@code true} to execute the web request as a {@code POST}, {@code false} to execute as a {@code GET}.
787   * @param executeAsDelete
788   *          {@code true} to add a special 'treat this request as a {@code DELETE}' parameter.
789   * @param binaryAttachments
790   *          A list of binary files to include in a {@code POST} request. Pass {@code null} if no attachment should be
791   *          sent.
792   * @param parameters
793   *          Arbitrary number of parameters to send along to Facebook as part of the API call.
794   * @return The JSON returned by Facebook for the API call.
795   * @throws FacebookException
796   *           If an error occurs while making the Facebook API POST or processing the response.
797   */
798  protected String makeRequest(String endpoint, final boolean executeAsPost, final boolean executeAsDelete,
799      final List<BinaryAttachment> binaryAttachments, Body body, Parameter... parameters) {
800    verifyParameterLegality(parameters);
801
802    if (executeAsDelete && isHttpDeleteFallback()) {
803      parameters = parametersWithAdditionalParameter(Parameter.with(METHOD_PARAM_NAME, "delete"), parameters);
804    }
805
806    if (!endpoint.startsWith("/")) {
807      endpoint = "/" + endpoint;
808    }
809
810    boolean hasAttachment = binaryAttachments != null && !binaryAttachments.isEmpty();
811    boolean hasReel = hasAttachment && binaryAttachments.get(0).isFacebookReel();
812
813    final String fullEndpoint = createEndpointForApiCall(endpoint, hasAttachment, hasReel);
814    final String parameterString = toParameterString(parameters);
815
816    String headerAccessToken = (hasReel) ? accessToken : getHeaderAccessToken();
817
818    return makeRequestAndProcessResponse(() -> {
819      WebRequestor.Request request = new WebRequestor.Request(fullEndpoint, headerAccessToken, parameterString);
820      if (executeAsDelete && !isHttpDeleteFallback()) {
821        return webRequestor.executeDelete(request);
822      }
823
824      if (executeAsPost) {
825        request.setBinaryAttachments(binaryAttachments);
826        request.setBody(body);
827        return webRequestor.executePost(request);
828      }
829
830      return webRequestor.executeGet(request);
831    });
832  }
833
834  private String getHeaderAccessToken() {
835    if (accessTokenInHeader) {
836      return this.accessToken;
837    }
838
839    return null;
840  }
841
842  /**
843   * @see com.restfb.FacebookClient#obtainAppSecretProof(java.lang.String, java.lang.String)
844   */
845  @Override
846  public String obtainAppSecretProof(String accessToken, String appSecret) {
847    verifyParameterPresence("accessToken", accessToken);
848    verifyParameterPresence(APP_SECRET, appSecret);
849    return EncodingUtils.encodeAppSecretProof(appSecret, accessToken);
850  }
851
852  /**
853   * returns if the fallback post method (<code>true</code>) is used or the http delete (<code>false</code>)
854   * 
855   * @return {@code true} if POST is used instead of HTTP DELETE (default)
856   */
857  public boolean isHttpDeleteFallback() {
858    return httpDeleteFallback;
859  }
860
861  /**
862   * Set to <code>true</code> if the facebook http delete fallback should be used. Facebook allows to use the http POST
863   * with the parameter "method=delete" to override the post and use delete instead. This feature allow http client that
864   * do not support the whole http method set, to delete objects from facebook
865   * 
866   * @param httpDeleteFallback
867   *          <code>true</code> if the http Delete Fallback is used
868   */
869  public void setHttpDeleteFallback(boolean httpDeleteFallback) {
870    this.httpDeleteFallback = httpDeleteFallback;
871  }
872
873  protected interface Requestor {
874    Response makeRequest() throws IOException;
875  }
876
877  protected String makeRequestAndProcessResponse(Requestor requestor) {
878    Response response;
879
880    // Perform a GET or POST to the API endpoint
881    try {
882      response = requestor.makeRequest();
883    } catch (Exception t) {
884      throw new FacebookNetworkException(t);
885    }
886
887    // If we get any HTTP response code other than a 200 OK or 400 Bad Request
888    // or 401 Not Authorized or 403 Forbidden or 404 Not Found or 500 Internal
889    // Server Error or 302 Not Modified
890    // throw an exception.
891    if (HTTP_OK != response.getStatusCode() && HTTP_BAD_REQUEST != response.getStatusCode()
892        && HTTP_UNAUTHORIZED != response.getStatusCode() && HTTP_NOT_FOUND != response.getStatusCode()
893        && HTTP_INTERNAL_ERROR != response.getStatusCode() && HTTP_FORBIDDEN != response.getStatusCode()
894        && HTTP_NOT_MODIFIED != response.getStatusCode()) {
895      throw new FacebookNetworkException(response.getStatusCode());
896    }
897
898    String json = response.getBody();
899
900    try {
901      // If the response contained an error code, throw an exception.
902      getFacebookExceptionGenerator().throwFacebookResponseStatusExceptionIfNecessary(json, response.getStatusCode());
903    } catch (FacebookErrorMessageException feme) {
904      Optional.ofNullable(getWebRequestor()).map(WebRequestor::getDebugHeaderInfo).ifPresent(feme::setDebugHeaderInfo);
905      throw feme;
906    }
907
908    // If there was no response error information and this was a 500 or 401
909    // error, something weird happened on Facebook's end. Bail.
910    if (HTTP_INTERNAL_ERROR == response.getStatusCode() || HTTP_UNAUTHORIZED == response.getStatusCode()) {
911      throw new FacebookNetworkException(response.getStatusCode());
912    }
913
914    return json;
915  }
916
917  /**
918   * Generate the parameter string to be included in the Facebook API request.
919   * 
920   * @param parameters
921   *          Arbitrary number of extra parameters to include in the request.
922   * @return The parameter string to include in the Facebook API request.
923   * @throws FacebookJsonMappingException
924   *           If an error occurs when building the parameter string.
925   */
926  protected String toParameterString(Parameter... parameters) {
927    return toParameterString(true, parameters);
928  }
929
930  /**
931   * Generate the parameter string to be included in the Facebook API request.
932   * 
933   * @param withJsonParameter
934   *          add additional parameter format with type json
935   * @param parameters
936   *          Arbitrary number of extra parameters to include in the request.
937   * @return The parameter string to include in the Facebook API request.
938   * @throws FacebookJsonMappingException
939   *           If an error occurs when building the parameter string.
940   */
941  protected String toParameterString(boolean withJsonParameter, Parameter... parameters) {
942    if (!isBlank(accessToken) && !accessTokenInHeader) {
943      parameters = parametersWithAdditionalParameter(Parameter.with(ACCESS_TOKEN_PARAM_NAME, accessToken), parameters);
944    }
945
946    if (!isBlank(accessToken) && !isBlank(appSecret)) {
947      if (isAppSecretProofWithTime()) {
948        long now = System.currentTimeMillis() / 1000;
949        parameters = parametersWithAdditionalParameter(
950                Parameter.with(APP_SECRET_PROOF_TIME_PARAM_NAME, String.valueOf(now)), parameters);
951        parameters = parametersWithAdditionalParameter(
952                Parameter.with(APP_SECRET_PROOF_PARAM_NAME, obtainAppSecretProof(accessToken + "|" + now, appSecret)), parameters);
953      } else {
954        parameters = parametersWithAdditionalParameter(
955                Parameter.with(APP_SECRET_PROOF_PARAM_NAME, obtainAppSecretProof(accessToken, appSecret)), parameters);
956      }
957    }
958
959    if (withJsonParameter) {
960      parameters = parametersWithAdditionalParameter(Parameter.with(FORMAT_PARAM_NAME, "json"), parameters);
961    }
962
963    return Stream.of(parameters).map(p -> urlEncode(p.name) + "=" + urlEncodedValueForParameterName(p.name, p.value))
964      .collect(Collectors.joining("&"));
965  }
966
967  /**
968   * @see com.restfb.BaseFacebookClient#createEndpointForApiCall(java.lang.String,boolean,boolean)
969   */
970  @Override
971  protected String createEndpointForApiCall(String apiCall, boolean hasAttachment, boolean hasReel) {
972    while (apiCall.startsWith("/")) {
973      apiCall = apiCall.substring(1);
974    }
975
976    String baseUrl = createBaseUrlForEndpoint(apiCall, hasAttachment, hasReel);
977
978    return format("%s/%s", baseUrl, apiCall);
979  }
980
981  protected String createBaseUrlForEndpoint(String apiCall, boolean hasAttachment, boolean hasReel) {
982    String baseUrl = getFacebookGraphEndpointUrl();
983    if (hasAttachment && hasReel) {
984      baseUrl = getFacebookReelsUploadEndpointUrl();
985    } else if (hasAttachment && (apiCall.endsWith("/videos") || apiCall.endsWith("/advideos"))) {
986      baseUrl = getFacebookGraphVideoEndpointUrl();
987    } else if (apiCall.endsWith("logout.php")) {
988      baseUrl = getFacebookEndpointUrls().getFacebookEndpoint();
989    }
990    return baseUrl;
991  }
992
993  /**
994   * Returns the base endpoint URL for the Graph API.
995   * 
996   * @return The base endpoint URL for the Graph API.
997   */
998  protected String getFacebookGraphEndpointUrl() {
999    if (apiVersion.isUrlElementRequired()) {
1000      return getFacebookEndpointUrls().getGraphEndpoint() + '/' + apiVersion.getUrlElement();
1001    } else {
1002      return getFacebookEndpointUrls().getGraphEndpoint();
1003    }
1004  }
1005
1006  /**
1007   * Returns the base endpoint URL for the Graph APIs video upload functionality.
1008   * 
1009   * @return The base endpoint URL for the Graph APIs video upload functionality.
1010   * @since 1.6.5
1011   */
1012  protected String getFacebookGraphVideoEndpointUrl() {
1013    if (apiVersion.isUrlElementRequired()) {
1014      return getFacebookEndpointUrls().getGraphVideoEndpoint() + '/' + apiVersion.getUrlElement();
1015    } else {
1016      return getFacebookEndpointUrls().getGraphVideoEndpoint();
1017    }
1018  }
1019
1020  /**
1021   * Returns the Facebook Reels Upload endpoint URL for handling the Reels Upload
1022   * 
1023   * @return the Facebook Reels Upload endpoint URL
1024   */
1025  protected String getFacebookReelsUploadEndpointUrl() {
1026    if (apiVersion.isUrlElementRequired()) {
1027      return getFacebookEndpointUrls().getReelUploadEndpoint() + "/" + apiVersion.getUrlElement();
1028    }
1029
1030    return getFacebookEndpointUrls().getReelUploadEndpoint();
1031  }
1032
1033  public FacebookEndpoints getFacebookEndpointUrls() {
1034    return facebookEndpointUrls;
1035  }
1036
1037  public void setFacebookEndpointUrls(FacebookEndpoints facebookEndpointUrls) {
1038    this.facebookEndpointUrls = facebookEndpointUrls;
1039  }
1040}