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 WebRequestor.Request request = new WebRequestor.Request(String.format("%s&%s=%s", connectionPageUrl, 286 urlEncode(APP_SECRET_PROOF_PARAM_NAME), obtainAppSecretProof(accessToken, appSecret)), null); 287 connectionJson = makeRequestAndProcessResponse(() -> webRequestor.executeGet(request)); 288 } else { 289 connectionJson = makeRequestAndProcessResponse( 290 () -> webRequestor.executeGet(new WebRequestor.Request(connectionPageUrl, getHeaderAccessToken()))); 291 } 292 293 return new Connection<>(this, connectionJson, connectionType); 294 } 295 296 /** 297 * @see com.restfb.FacebookClient#fetchObject(java.lang.String, java.lang.Class, com.restfb.Parameter[]) 298 */ 299 @Override 300 public <T> T fetchObject(String object, Class<T> objectType, Parameter... parameters) { 301 verifyParameterPresence("object", object); 302 verifyParameterPresence("objectType", objectType); 303 return jsonMapper.toJavaObject(makeRequest(object, parameters), objectType); 304 } 305 306 @Override 307 public FacebookClient createClientWithAccessToken(String accessToken) { 308 return new DefaultFacebookClient(accessToken, this.appSecret, getWebRequestor(), getJsonMapper(), this.apiVersion); 309 } 310 311 /** 312 * @see com.restfb.FacebookClient#fetchObjects(java.util.List, java.lang.Class, com.restfb.Parameter[]) 313 */ 314 @Override 315 public <T> T fetchObjects(List<String> ids, Class<T> objectType, Parameter... parameters) { 316 verifyParameterPresence("ids", ids); 317 verifyParameterPresence(CONNECTION_TYPE, objectType); 318 requireNotEmpty(ids, "The list of IDs cannot be empty."); 319 320 if (Stream.of(parameters).anyMatch(p -> IDS_PARAM_NAME.equals(p.name))) { 321 throw new IllegalArgumentException("You cannot specify the '" + IDS_PARAM_NAME + "' URL parameter yourself - " 322 + "RestFB will populate this for you with the list of IDs you passed to this method."); 323 } 324 325 JsonArray idArray = new JsonArray(); 326 327 // Normalize the IDs 328 for (String id : ids) { 329 throwIAEonBlankId(id); 330 idArray.add(id.trim()); 331 } 332 333 try { 334 String jsonString = makeRequest("", 335 parametersWithAdditionalParameter(Parameter.with(IDS_PARAM_NAME, idArray.toString()), parameters)); 336 337 return jsonMapper.toJavaObject(jsonString, objectType); 338 } catch (ParseException e) { 339 throw new FacebookJsonMappingException("Unable to map connection JSON to Java objects", e); 340 } 341 } 342 343 private void throwIAEonBlankId(String id) { 344 if (StringUtils.isBlank(id)) { 345 throw new IllegalArgumentException("The list of IDs cannot contain blank strings."); 346 } 347 } 348 349 /** 350 * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.BinaryAttachment, 351 * com.restfb.Parameter[]) 352 */ 353 @Override 354 public <T> T publish(String connection, Class<T> objectType, List<BinaryAttachment> binaryAttachments, 355 Parameter... parameters) { 356 verifyParameterPresence(CONNECTION, connection); 357 358 return jsonMapper.toJavaObject(makeRequest(connection, true, false, binaryAttachments, parameters), objectType); 359 } 360 361 /** 362 * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.BinaryAttachment, 363 * com.restfb.Parameter[]) 364 */ 365 @Override 366 public <T> T publish(String connection, Class<T> objectType, BinaryAttachment binaryAttachment, 367 Parameter... parameters) { 368 List<BinaryAttachment> attachments = 369 Optional.ofNullable(binaryAttachment).map(Collections::singletonList).orElse(null); 370 return publish(connection, objectType, attachments, parameters); 371 } 372 373 /** 374 * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.Parameter[]) 375 */ 376 @Override 377 public <T> T publish(String connection, Class<T> objectType, Parameter... parameters) { 378 return publish(connection, objectType, (List<BinaryAttachment>) null, parameters); 379 } 380 381 @Override 382 public <T> T publish(String connection, Class<T> objectType, Body body, Parameter... parameters) { 383 verifyParameterPresence(CONNECTION, connection); 384 return jsonMapper.toJavaObject(makeRequest(connection, true, false, null, body, parameters), objectType); 385 } 386 387 @Override 388 public String getLogoutUrl(String next) { 389 String parameterString; 390 if (next != null) { 391 Parameter p = Parameter.with("next", next); 392 parameterString = toParameterString(false, p); 393 } else { 394 parameterString = toParameterString(false); 395 } 396 397 final String fullEndPoint = createEndpointForApiCall("logout.php", false, false); 398 return fullEndPoint + "?" + parameterString; 399 } 400 401 /** 402 * @see com.restfb.FacebookClient#executeBatch(com.restfb.batch.BatchRequest[]) 403 */ 404 @Override 405 public List<BatchResponse> executeBatch(BatchRequest... batchRequests) { 406 return executeBatch(asList(batchRequests), Collections.emptyList()); 407 } 408 409 /** 410 * @see com.restfb.FacebookClient#executeBatch(java.util.List) 411 */ 412 @Override 413 public List<BatchResponse> executeBatch(List<BatchRequest> batchRequests) { 414 return executeBatch(batchRequests, Collections.emptyList()); 415 } 416 417 /** 418 * @see com.restfb.FacebookClient#executeBatch(java.util.List, java.util.List) 419 */ 420 @Override 421 public List<BatchResponse> executeBatch(List<BatchRequest> batchRequests, List<BinaryAttachment> binaryAttachments) { 422 verifyParameterPresence("binaryAttachments", binaryAttachments); 423 requireNotEmpty(batchRequests, "You must specify at least one batch request."); 424 425 return jsonMapper.toJavaList( 426 makeRequest("", true, false, binaryAttachments, Parameter.with("batch", jsonMapper.toJson(batchRequests, true))), 427 BatchResponse.class); 428 } 429 430 /** 431 * @see com.restfb.FacebookClient#convertSessionKeysToAccessTokens(java.lang.String, java.lang.String, 432 * java.lang.String[]) 433 */ 434 @Override 435 public List<AccessToken> convertSessionKeysToAccessTokens(String appId, String secretKey, String... sessionKeys) { 436 verifyParameterPresence(APP_ID, appId); 437 verifyParameterPresence("secretKey", secretKey); 438 439 if (sessionKeys == null || sessionKeys.length == 0) { 440 return emptyList(); 441 } 442 443 String json = makeRequest("/oauth/exchange_sessions", true, false, null, Parameter.with(CLIENT_ID, appId), 444 Parameter.with(PARAM_CLIENT_SECRET, secretKey), Parameter.with("sessions", String.join(",", sessionKeys))); 445 446 return jsonMapper.toJavaList(json, AccessToken.class); 447 } 448 449 /** 450 * @see com.restfb.FacebookClient#obtainAppAccessToken(java.lang.String, java.lang.String) 451 */ 452 @Override 453 public AccessToken obtainAppAccessToken(String appId, String appSecret) { 454 verifyParameterPresence(APP_ID, appId); 455 verifyParameterPresence(APP_SECRET, appSecret); 456 457 String response = makeRequest(PATH_OAUTH_ACCESS_TOKEN, Parameter.with(GRANT_TYPE, "client_credentials"), 458 Parameter.with(CLIENT_ID, appId), Parameter.with(PARAM_CLIENT_SECRET, appSecret)); 459 460 try { 461 return getAccessTokenFromResponse(response); 462 } catch (Exception t) { 463 throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t); 464 } 465 } 466 467 @Override 468 public DeviceCode fetchDeviceCode(ScopeBuilder scope) { 469 verifyParameterPresence(SCOPE, scope); 470 ObjectUtil.requireNotNull(accessToken, 471 () -> new IllegalStateException("access token is required to fetch a device access token")); 472 473 String response = makeRequest("device/login", true, false, null, Parameter.with("type", "device_code"), 474 Parameter.with(SCOPE, scope.toString())); 475 return jsonMapper.toJavaObject(response, DeviceCode.class); 476 } 477 478 @Override 479 public AccessToken obtainDeviceAccessToken(String code) throws FacebookDeviceTokenCodeExpiredException, 480 FacebookDeviceTokenPendingException, FacebookDeviceTokenDeclinedException, FacebookDeviceTokenSlowdownException { 481 verifyParameterPresence(CODE, code); 482 483 ObjectUtil.requireNotNull(accessToken, 484 () -> new IllegalStateException("access token is required to fetch a device access token")); 485 486 try { 487 String response = makeRequest("device/login_status", true, false, null, Parameter.with("type", "device_token"), 488 Parameter.with(CODE, code)); 489 return getAccessTokenFromResponse(response); 490 } catch (FacebookOAuthException foae) { 491 DeviceTokenExceptionFactory.createFrom(foae); 492 return null; 493 } 494 } 495 496 /** 497 * @see com.restfb.FacebookClient#obtainUserAccessToken(java.lang.String, java.lang.String, java.lang.String, 498 * java.lang.String) 499 */ 500 @Override 501 public AccessToken obtainUserAccessToken(String appId, String appSecret, String redirectUri, 502 String verificationCode) { 503 verifyParameterPresence(APP_ID, appId); 504 verifyParameterPresence(APP_SECRET, appSecret); 505 verifyParameterPresence("verificationCode", verificationCode); 506 507 String response = makeRequest(PATH_OAUTH_ACCESS_TOKEN, Parameter.with(CLIENT_ID, appId), 508 Parameter.with(PARAM_CLIENT_SECRET, appSecret), Parameter.with(CODE, verificationCode), 509 Parameter.with(REDIRECT_URI, redirectUri)); 510 511 try { 512 return getAccessTokenFromResponse(response); 513 } catch (Exception t) { 514 throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t); 515 } 516 } 517 518 /** 519 * @see com.restfb.FacebookClient#obtainExtendedAccessToken(java.lang.String, java.lang.String) 520 */ 521 @Override 522 public AccessToken obtainExtendedAccessToken(String appId, String appSecret) { 523 ObjectUtil.requireNotNull(accessToken, 524 () -> new IllegalStateException( 525 format("You cannot call this method because you did not construct this instance of %s with an access token.", 526 getClass().getSimpleName()))); 527 528 return obtainExtendedAccessToken(appId, appSecret, accessToken); 529 } 530 531 @Override 532 public AccessToken obtainRefreshedExtendedAccessToken() { 533 throw new UnsupportedOperationException( 534 "obtaining a refreshed extended access token is not supported by this client"); 535 } 536 537 /** 538 * @see com.restfb.FacebookClient#obtainExtendedAccessToken(java.lang.String, java.lang.String, java.lang.String) 539 */ 540 @Override 541 public AccessToken obtainExtendedAccessToken(String appId, String appSecret, String accessToken) { 542 verifyParameterPresence(APP_ID, appId); 543 verifyParameterPresence(APP_SECRET, appSecret); 544 verifyParameterPresence("accessToken", accessToken); 545 546 String response = makeRequest("/oauth/access_token", false, false, null, // 547 Parameter.with(CLIENT_ID, appId), // 548 Parameter.with(PARAM_CLIENT_SECRET, appSecret), // 549 Parameter.with(GRANT_TYPE, "fb_exchange_token"), // 550 Parameter.with("fb_exchange_token", accessToken), // 551 Parameter.withFields("access_token,expires_in,token_type")); 552 553 try { 554 return getAccessTokenFromResponse(response); 555 } catch (Exception t) { 556 throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t); 557 } 558 } 559 560 protected AccessToken getAccessTokenFromResponse(String response) { 561 AccessToken token; 562 try { 563 token = getJsonMapper().toJavaObject(response, AccessToken.class); 564 } catch (FacebookJsonMappingException fjme) { 565 CLIENT_LOGGER.trace("could not map response to access token class try to fetch directly from String", fjme); 566 token = AccessToken.fromQueryString(response); 567 } 568 token.setClient(createClientWithAccessToken(token.getAccessToken())); 569 return token; 570 } 571 572 @Override 573 @SuppressWarnings("unchecked") 574 public <T> T parseSignedRequest(String signedRequest, String appSecret, Class<T> objectType) { 575 verifyParameterPresence("signedRequest", signedRequest); 576 verifyParameterPresence(APP_SECRET, appSecret); 577 verifyParameterPresence("objectType", objectType); 578 579 String[] signedRequestTokens = signedRequest.split("[.]"); 580 581 if (signedRequestTokens.length != 2) { 582 throw new FacebookSignedRequestParsingException(format( 583 "Signed request '%s' is expected to be signature and payload strings separated by a '.'", signedRequest)); 584 } 585 586 String encodedSignature = signedRequestTokens[0]; 587 String urlDecodedSignature = urlDecodeSignedRequestToken(encodedSignature); 588 byte[] signature = decodeBase64(urlDecodedSignature); 589 590 String encodedPayload = signedRequestTokens[1]; 591 String urlDecodedPayload = urlDecodeSignedRequestToken(encodedPayload); 592 String payload = StringUtils.toString(decodeBase64(urlDecodedPayload)); 593 594 // Convert payload to a JsonObject, so we can pull algorithm data out of it 595 JsonObject payloadObject = getJsonMapper().toJavaObject(payload, JsonObject.class); 596 597 if (!payloadObject.contains(ALGORITHM)) { 598 throw new FacebookSignedRequestParsingException("Unable to detect algorithm used for signed request"); 599 } 600 601 String algorithm = payloadObject.getString(ALGORITHM, null); 602 603 if (!verifySignedRequest(appSecret, algorithm, encodedPayload, signature)) { 604 throw new FacebookSignedRequestVerificationException( 605 "Signed request verification failed. Are you sure the request was made for the app identified by the app secret you provided?"); 606 } 607 608 // Marshal to the user's preferred type. 609 // If the user asked for a JsonObject, send back the one we already parsed. 610 return objectType.equals(JsonObject.class) ? (T) payloadObject : getJsonMapper().toJavaObject(payload, objectType); 611 } 612 613 /** 614 * Decodes a component of a signed request received from Facebook using FB's special URL-encoding strategy. 615 * 616 * @param signedRequestToken 617 * Token to decode. 618 * @return The decoded token. 619 */ 620 protected String urlDecodeSignedRequestToken(String signedRequestToken) { 621 verifyParameterPresence("signedRequestToken", signedRequestToken); 622 return signedRequestToken.replace("-", "+").replace("_", "/").trim(); 623 } 624 625 @Override 626 public String getLoginDialogUrl(String appId, String redirectUri, ScopeBuilder scope, String state, 627 Parameter... parameters) { 628 List<Parameter> parameterList = asList(parameters); 629 return getGenericLoginDialogUrl(appId, redirectUri, scope, 630 () -> getFacebookEndpointUrls().getFacebookEndpoint() + "/dialog/oauth", state, parameterList); 631 } 632 633 @Override 634 public String getLoginDialogUrl(String appId, String redirectUri, ScopeBuilder scope, Parameter... parameters) { 635 return getLoginDialogUrl(appId, redirectUri, scope, null, parameters); 636 } 637 638 @Override 639 public String getBusinessLoginDialogUrl(String appId, String redirectUri, String configId, String state, 640 Parameter... parameters) { 641 verifyParameterPresence("configId", configId); 642 643 List<Parameter> parameterList = new ArrayList<>(asList(parameters)); 644 parameterList.add(Parameter.with("config_id", configId)); 645 parameterList.add(Parameter.with("response_type", "code")); 646 parameterList.add(Parameter.with("override_default_response_type", true)); 647 648 return getGenericLoginDialogUrl(appId, redirectUri, new ScopeBuilder(true), 649 () -> getFacebookEndpointUrls().getFacebookEndpoint() + "/dialog/oauth", state, parameterList); 650 } 651 652 protected String getGenericLoginDialogUrl(String appId, String redirectUri, ScopeBuilder scope, 653 Supplier<String> endpointSupplier, String state, List<Parameter> parameters) { 654 verifyParameterPresence(APP_ID, appId); 655 verifyParameterPresence("redirectUri", redirectUri); 656 verifyParameterPresence(SCOPE, scope); 657 658 String dialogUrl = endpointSupplier.get(); 659 660 List<Parameter> parameterList = new ArrayList<>(); 661 parameterList.add(Parameter.with(CLIENT_ID, appId)); 662 parameterList.add(Parameter.with(REDIRECT_URI, redirectUri)); 663 if (!scope.toString().isEmpty()) { 664 parameterList.add(Parameter.with(SCOPE, scope.toString())); 665 } 666 667 if (StringUtils.isNotBlank(state)) { 668 parameterList.add(Parameter.with("state", state)); 669 } 670 671 // add optional parameters 672 parameterList.addAll(parameters); 673 return dialogUrl + "?" + toParameterString(false, parameterList.toArray(new Parameter[0])); 674 } 675 676 /** 677 * Verifies that the signed request is really from Facebook. 678 * 679 * @param appSecret 680 * The secret for the app that can verify this signed request. 681 * @param algorithm 682 * Signature algorithm specified by FB in the decoded payload. 683 * @param encodedPayload 684 * The encoded payload used to generate a signature for comparison against the provided {@code signature}. 685 * @param signature 686 * The decoded signature extracted from the signed request. Compared against a signature generated from 687 * {@code encodedPayload}. 688 * @return {@code true} if the signed request is verified, {@code false} if not. 689 */ 690 protected boolean verifySignedRequest(String appSecret, String algorithm, String encodedPayload, byte[] signature) { 691 verifyParameterPresence(APP_SECRET, appSecret); 692 verifyParameterPresence(ALGORITHM, algorithm); 693 verifyParameterPresence("encodedPayload", encodedPayload); 694 verifyParameterPresence("signature", signature); 695 696 // Normalize algorithm name...FB calls it differently than Java does 697 if ("HMAC-SHA256".equalsIgnoreCase(algorithm)) { 698 algorithm = "HMACSHA256"; 699 } 700 701 try { 702 Mac mac = Mac.getInstance(algorithm); 703 mac.init(new SecretKeySpec(toBytes(appSecret), algorithm)); 704 byte[] payloadSignature = mac.doFinal(toBytes(encodedPayload)); 705 return Arrays.equals(signature, payloadSignature); 706 } catch (Exception e) { 707 throw new FacebookSignedRequestVerificationException("Unable to perform signed request verification", e); 708 } 709 } 710 711 /** 712 * @see com.restfb.FacebookClient#debugToken(java.lang.String) 713 */ 714 @Override 715 public DebugTokenInfo debugToken(String inputToken) { 716 verifyParameterPresence("inputToken", inputToken); 717 String response = makeRequest("/debug_token", Parameter.with("input_token", inputToken)); 718 719 try { 720 JsonObject json = Json.parse(response).asObject(); 721 722 // FB sometimes returns an empty DebugTokenInfo and then the 'data' field is an array 723 if (json.contains("data") && json.get("data").isArray()) { 724 return null; 725 } 726 727 JsonObject data = json.get("data").asObject(); 728 return getJsonMapper().toJavaObject(data.toString(), DebugTokenInfo.class); 729 } catch (Exception t) { 730 throw new FacebookResponseContentException("Unable to parse JSON from response.", t); 731 } 732 } 733 734 /** 735 * @see com.restfb.FacebookClient#getJsonMapper() 736 */ 737 @Override 738 public JsonMapper getJsonMapper() { 739 return jsonMapper; 740 } 741 742 /** 743 * @see com.restfb.FacebookClient#getWebRequestor() 744 */ 745 @Override 746 public WebRequestor getWebRequestor() { 747 return webRequestor; 748 } 749 750 /** 751 * Coordinates the process of executing the API request GET/POST and processing the response we receive from the 752 * endpoint. 753 * 754 * @param endpoint 755 * Facebook Graph API endpoint. 756 * @param parameters 757 * Arbitrary number of parameters to send along to Facebook as part of the API call. 758 * @return The JSON returned by Facebook for the API call. 759 * @throws FacebookException 760 * If an error occurs while making the Facebook API POST or processing the response. 761 */ 762 protected String makeRequest(String endpoint, Parameter... parameters) { 763 return makeRequest(endpoint, false, false, null, parameters); 764 } 765 766 protected String makeRequest(String endpoint, final boolean executeAsPost, final boolean executeAsDelete, 767 final List<BinaryAttachment> binaryAttachments, Parameter... parameters) { 768 return makeRequest(endpoint, executeAsPost, executeAsDelete, binaryAttachments, null, parameters); 769 } 770 771 /** 772 * Coordinates the process of executing the API request GET/POST and processing the response we receive from the 773 * endpoint. 774 * 775 * @param endpoint 776 * Facebook Graph API endpoint. 777 * @param executeAsPost 778 * {@code true} to execute the web request as a {@code POST}, {@code false} to execute as a {@code GET}. 779 * @param executeAsDelete 780 * {@code true} to add a special 'treat this request as a {@code DELETE}' parameter. 781 * @param binaryAttachments 782 * A list of binary files to include in a {@code POST} request. Pass {@code null} if no attachment should be 783 * sent. 784 * @param parameters 785 * Arbitrary number of parameters to send along to Facebook as part of the API call. 786 * @return The JSON returned by Facebook for the API call. 787 * @throws FacebookException 788 * If an error occurs while making the Facebook API POST or processing the response. 789 */ 790 protected String makeRequest(String endpoint, final boolean executeAsPost, final boolean executeAsDelete, 791 final List<BinaryAttachment> binaryAttachments, Body body, Parameter... parameters) { 792 verifyParameterLegality(parameters); 793 794 if (executeAsDelete && isHttpDeleteFallback()) { 795 parameters = parametersWithAdditionalParameter(Parameter.with(METHOD_PARAM_NAME, "delete"), parameters); 796 } 797 798 if (!endpoint.startsWith("/")) { 799 endpoint = "/" + endpoint; 800 } 801 802 boolean hasAttachment = binaryAttachments != null && !binaryAttachments.isEmpty(); 803 boolean hasReel = hasAttachment && binaryAttachments.get(0).isFacebookReel(); 804 805 final String fullEndpoint = createEndpointForApiCall(endpoint, hasAttachment, hasReel); 806 final String parameterString = toParameterString(parameters); 807 808 String headerAccessToken = (hasReel) ? accessToken : getHeaderAccessToken(); 809 810 return makeRequestAndProcessResponse(() -> { 811 WebRequestor.Request request = new WebRequestor.Request(fullEndpoint, headerAccessToken, parameterString); 812 if (executeAsDelete && !isHttpDeleteFallback()) { 813 return webRequestor.executeDelete(request); 814 } 815 816 if (executeAsPost) { 817 request.setBinaryAttachments(binaryAttachments); 818 request.setBody(body); 819 return webRequestor.executePost(request); 820 } 821 822 return webRequestor.executeGet(request); 823 }); 824 } 825 826 private String getHeaderAccessToken() { 827 if (accessTokenInHeader) { 828 return this.accessToken; 829 } 830 831 return null; 832 } 833 834 /** 835 * @see com.restfb.FacebookClient#obtainAppSecretProof(java.lang.String, java.lang.String) 836 */ 837 @Override 838 public String obtainAppSecretProof(String accessToken, String appSecret) { 839 verifyParameterPresence("accessToken", accessToken); 840 verifyParameterPresence(APP_SECRET, appSecret); 841 return EncodingUtils.encodeAppSecretProof(appSecret, accessToken); 842 } 843 844 /** 845 * returns if the fallback post method (<code>true</code>) is used or the http delete (<code>false</code>) 846 * 847 * @return {@code true} if POST is used instead of HTTP DELETE (default) 848 */ 849 public boolean isHttpDeleteFallback() { 850 return httpDeleteFallback; 851 } 852 853 /** 854 * Set to <code>true</code> if the facebook http delete fallback should be used. Facebook allows to use the http POST 855 * with the parameter "method=delete" to override the post and use delete instead. This feature allow http client that 856 * do not support the whole http method set, to delete objects from facebook 857 * 858 * @param httpDeleteFallback 859 * <code>true</code> if the http Delete Fallback is used 860 */ 861 public void setHttpDeleteFallback(boolean httpDeleteFallback) { 862 this.httpDeleteFallback = httpDeleteFallback; 863 } 864 865 protected interface Requestor { 866 Response makeRequest() throws IOException; 867 } 868 869 protected String makeRequestAndProcessResponse(Requestor requestor) { 870 Response response; 871 872 // Perform a GET or POST to the API endpoint 873 try { 874 response = requestor.makeRequest(); 875 } catch (Exception t) { 876 throw new FacebookNetworkException(t); 877 } 878 879 // If we get any HTTP response code other than a 200 OK or 400 Bad Request 880 // or 401 Not Authorized or 403 Forbidden or 404 Not Found or 500 Internal 881 // Server Error or 302 Not Modified 882 // throw an exception. 883 if (HTTP_OK != response.getStatusCode() && HTTP_BAD_REQUEST != response.getStatusCode() 884 && HTTP_UNAUTHORIZED != response.getStatusCode() && HTTP_NOT_FOUND != response.getStatusCode() 885 && HTTP_INTERNAL_ERROR != response.getStatusCode() && HTTP_FORBIDDEN != response.getStatusCode() 886 && HTTP_NOT_MODIFIED != response.getStatusCode()) { 887 throw new FacebookNetworkException(response.getStatusCode()); 888 } 889 890 String json = response.getBody(); 891 892 try { 893 // If the response contained an error code, throw an exception. 894 getFacebookExceptionGenerator().throwFacebookResponseStatusExceptionIfNecessary(json, response.getStatusCode()); 895 } catch (FacebookErrorMessageException feme) { 896 Optional.ofNullable(getWebRequestor()).map(WebRequestor::getDebugHeaderInfo).ifPresent(feme::setDebugHeaderInfo); 897 throw feme; 898 } 899 900 // If there was no response error information and this was a 500 or 401 901 // error, something weird happened on Facebook's end. Bail. 902 if (HTTP_INTERNAL_ERROR == response.getStatusCode() || HTTP_UNAUTHORIZED == response.getStatusCode()) { 903 throw new FacebookNetworkException(response.getStatusCode()); 904 } 905 906 return json; 907 } 908 909 /** 910 * Generate the parameter string to be included in the Facebook API request. 911 * 912 * @param parameters 913 * Arbitrary number of extra parameters to include in the request. 914 * @return The parameter string to include in the Facebook API request. 915 * @throws FacebookJsonMappingException 916 * If an error occurs when building the parameter string. 917 */ 918 protected String toParameterString(Parameter... parameters) { 919 return toParameterString(true, parameters); 920 } 921 922 /** 923 * Generate the parameter string to be included in the Facebook API request. 924 * 925 * @param withJsonParameter 926 * add additional parameter format with type json 927 * @param parameters 928 * Arbitrary number of extra parameters to include in the request. 929 * @return The parameter string to include in the Facebook API request. 930 * @throws FacebookJsonMappingException 931 * If an error occurs when building the parameter string. 932 */ 933 protected String toParameterString(boolean withJsonParameter, Parameter... parameters) { 934 if (!isBlank(accessToken) && !accessTokenInHeader) { 935 parameters = parametersWithAdditionalParameter(Parameter.with(ACCESS_TOKEN_PARAM_NAME, accessToken), parameters); 936 } 937 938 if (!isBlank(accessToken) && !isBlank(appSecret)) { 939 parameters = parametersWithAdditionalParameter( 940 Parameter.with(APP_SECRET_PROOF_PARAM_NAME, obtainAppSecretProof(accessToken, appSecret)), parameters); 941 } 942 943 if (withJsonParameter) { 944 parameters = parametersWithAdditionalParameter(Parameter.with(FORMAT_PARAM_NAME, "json"), parameters); 945 } 946 947 return Stream.of(parameters).map(p -> urlEncode(p.name) + "=" + urlEncodedValueForParameterName(p.name, p.value)) 948 .collect(Collectors.joining("&")); 949 } 950 951 /** 952 * @see com.restfb.BaseFacebookClient#createEndpointForApiCall(java.lang.String,boolean,boolean) 953 */ 954 @Override 955 protected String createEndpointForApiCall(String apiCall, boolean hasAttachment, boolean hasReel) { 956 while (apiCall.startsWith("/")) { 957 apiCall = apiCall.substring(1); 958 } 959 960 String baseUrl = createBaseUrlForEndpoint(apiCall, hasAttachment, hasReel); 961 962 return format("%s/%s", baseUrl, apiCall); 963 } 964 965 protected String createBaseUrlForEndpoint(String apiCall, boolean hasAttachment, boolean hasReel) { 966 String baseUrl = getFacebookGraphEndpointUrl(); 967 if (hasAttachment && hasReel) { 968 baseUrl = getFacebookReelsUploadEndpointUrl(); 969 } else if (hasAttachment && (apiCall.endsWith("/videos") || apiCall.endsWith("/advideos"))) { 970 baseUrl = getFacebookGraphVideoEndpointUrl(); 971 } else if (apiCall.endsWith("logout.php")) { 972 baseUrl = getFacebookEndpointUrls().getFacebookEndpoint(); 973 } 974 return baseUrl; 975 } 976 977 /** 978 * Returns the base endpoint URL for the Graph API. 979 * 980 * @return The base endpoint URL for the Graph API. 981 */ 982 protected String getFacebookGraphEndpointUrl() { 983 if (apiVersion.isUrlElementRequired()) { 984 return getFacebookEndpointUrls().getGraphEndpoint() + '/' + apiVersion.getUrlElement(); 985 } else { 986 return getFacebookEndpointUrls().getGraphEndpoint(); 987 } 988 } 989 990 /** 991 * Returns the base endpoint URL for the Graph APIs video upload functionality. 992 * 993 * @return The base endpoint URL for the Graph APIs video upload functionality. 994 * @since 1.6.5 995 */ 996 protected String getFacebookGraphVideoEndpointUrl() { 997 if (apiVersion.isUrlElementRequired()) { 998 return getFacebookEndpointUrls().getGraphVideoEndpoint() + '/' + apiVersion.getUrlElement(); 999 } else { 1000 return getFacebookEndpointUrls().getGraphVideoEndpoint(); 1001 } 1002 } 1003 1004 /** 1005 * Returns the Facebook Reels Upload endpoint URL for handling the Reels Upload 1006 * 1007 * @return the Facebook Reels Upload endpoint URL 1008 */ 1009 protected String getFacebookReelsUploadEndpointUrl() { 1010 if (apiVersion.isUrlElementRequired()) { 1011 return getFacebookEndpointUrls().getReelUploadEndpoint() + "/" + apiVersion.getUrlElement(); 1012 } 1013 1014 return getFacebookEndpointUrls().getReelUploadEndpoint(); 1015 } 1016 1017 public FacebookEndpoints getFacebookEndpointUrls() { 1018 return facebookEndpointUrls; 1019 } 1020 1021 public void setFacebookEndpointUrls(FacebookEndpoints facebookEndpointUrls) { 1022 this.facebookEndpointUrls = facebookEndpointUrls; 1023 } 1024}