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}