2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.icloud.internal;
15 import java.io.IOException;
16 import java.util.ArrayList;
17 import java.util.HashMap;
18 import java.util.List;
20 import java.util.UUID;
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.openhab.binding.icloud.internal.utilities.JsonUtils;
25 import org.openhab.binding.icloud.internal.utilities.ListUtil;
26 import org.openhab.binding.icloud.internal.utilities.Pair;
27 import org.openhab.core.storage.Storage;
28 import org.slf4j.Logger;
29 import org.slf4j.LoggerFactory;
33 * Class to access Apple iCloud API.
35 * The implementation of this class is inspired by https://github.com/picklepete/pyicloud.
37 * @author Simon Spielmann - Initial contribution
40 public class ICloudService {
45 private static final String ICLOUD_CLIENT_ID = "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d";
47 private final Logger logger = LoggerFactory.getLogger(ICloudService.class);
49 private static final String AUTH_ENDPOINT = "https://idmsa.apple.com/appleauth/auth";
51 private static final String HOME_ENDPOINT = "https://www.icloud.com";
53 private static final String SETUP_ENDPOINT = "https://setup.icloud.com/setup/ws/1";
55 private String appleId;
57 private String password;
59 private String clientId;
61 private Map<String, Object> data = new HashMap<>();
63 private ICloudSession session;
69 * @param appleId Apple id (e-mail address) for authentication
70 * @param password Password used for authentication
71 * @param stateStorage Storage to save authentication state
73 public ICloudService(String appleId, String password, Storage<String> stateStorage) {
74 this.appleId = appleId;
75 this.password = password;
76 this.clientId = "auth-" + UUID.randomUUID().toString().toLowerCase();
78 this.session = new ICloudSession(stateStorage);
79 this.session.setDefaultHeaders(Pair.of("Origin", HOME_ENDPOINT), Pair.of("Referer", HOME_ENDPOINT + "/"));
83 * Initiate authentication
85 * @param forceRefresh Force a new authentication
86 * @return {@code true} if authentication was successful
87 * @throws IOException if I/O error occurred
88 * @throws InterruptedException if request was interrupted
90 public boolean authenticate(boolean forceRefresh) throws IOException, InterruptedException {
91 boolean loginSuccessful = false;
92 if (this.session.getSessionToken() != null && !forceRefresh) {
94 this.data = validateToken();
95 logger.debug("Token is valid.");
96 loginSuccessful = true;
97 } catch (ICloudApiResponseException ex) {
98 logger.debug("Token is not valid. Attemping new login.", ex);
102 if (!loginSuccessful) {
103 logger.debug("Authenticating as {}...", this.appleId);
105 Map<String, Object> requestBody = new HashMap<>();
106 requestBody.put("accountName", this.appleId);
107 requestBody.put("password", this.password);
108 requestBody.put("rememberMe", true);
109 if (session.hasToken()) {
110 requestBody.put("trustTokens", new String[] { this.session.getTrustToken() });
112 requestBody.put("trustTokens", new String[0]);
115 List<Pair<String, String>> headers = getAuthHeaders();
118 this.session.post(AUTH_ENDPOINT + "/signin?isRememberMeEnabled=true", JsonUtils.toJson(requestBody),
120 } catch (ICloudApiResponseException ex) {
124 return authenticateWithToken();
128 * Try authentication with stored session token. Returns {@code true} if authentication was successful.
130 * @return {@code true} if authentication was successful
132 * @throws IOException if I/O error occurred
133 * @throws InterruptedException if this request was interrupted
136 public boolean authenticateWithToken() throws IOException, InterruptedException {
137 Map<String, Object> requestBody = new HashMap<>();
139 String accountCountry = session.getAccountCountry();
140 if (accountCountry != null) {
141 requestBody.put("accountCountryCode", accountCountry);
144 String sessionToken = session.getSessionToken();
145 if (sessionToken != null) {
146 requestBody.put("dsWebAuthToken", sessionToken);
149 requestBody.put("extended_login", true);
151 if (session.hasToken()) {
152 String token = session.getTrustToken();
154 requestBody.put("trustToken", token);
157 requestBody.put("trustToken", "");
162 Map<String, Object> localSessionData = JsonUtils
163 .toMap(session.post(SETUP_ENDPOINT + "/accountLogin", JsonUtils.toJson(requestBody), null));
164 if (localSessionData != null) {
165 data = localSessionData;
167 } catch (ICloudApiResponseException ex) {
168 logger.debug("Invalid authentication.");
178 private List<Pair<String, String>> getAuthHeaders() {
179 return new ArrayList<>(List.of(Pair.of("Accept", "*/*"), Pair.of("Content-Type", "application/json"),
180 Pair.of("X-Apple-OAuth-Client-Id", ICLOUD_CLIENT_ID),
181 Pair.of("X-Apple-OAuth-Client-Type", "firstPartyAuth"),
182 Pair.of("X-Apple-OAuth-Redirect-URI", HOME_ENDPOINT),
183 Pair.of("X-Apple-OAuth-Require-Grant-Code", "true"),
184 Pair.of("X-Apple-OAuth-Response-Mode", "web_message"), Pair.of("X-Apple-OAuth-Response-Type", "code"),
185 Pair.of("X-Apple-OAuth-State", this.clientId), Pair.of("X-Apple-Widget-Key", ICLOUD_CLIENT_ID)));
188 private Map<String, Object> validateToken() throws IOException, InterruptedException, ICloudApiResponseException {
189 logger.debug("Checking session token validity");
190 String result = session.post(SETUP_ENDPOINT + "/validate", null, null);
191 logger.debug("Session token is still valid");
194 Map<String, Object> localSessionData = JsonUtils.toMap(result);
195 if (localSessionData == null) {
196 throw new IOException("Unable to create data object from json response");
198 return localSessionData;
202 * Checks if 2-FA authentication is required.
204 * @return {@code true} if 2-FA authentication ({@link #validate2faCode(String)}) is required.
206 public boolean requires2fa() {
207 if (this.data.containsKey("dsInfo")) {
208 @SuppressWarnings("unchecked")
209 Map<String, Object> dsInfo = (@Nullable Map<String, Object>) this.data.get("dsInfo");
210 if (dsInfo != null && ((Double) dsInfo.getOrDefault("hsaVersion", "0")) == 2.0) {
211 return (this.data.containsKey("hsaChallengeRequired")
212 && ((Boolean) this.data.getOrDefault("hsaChallengeRequired", Boolean.FALSE)
213 || !isTrustedSession()));
220 * Checks if session is trusted.
222 * @return {@code true} if session is trusted. Call {@link #trustSession()} if not.
224 public boolean isTrustedSession() {
225 return (Boolean) this.data.getOrDefault("hsaTrustedBrowser", Boolean.FALSE);
229 * Provides 2-FA code to establish trusted session.
231 * @param code Code given by user for 2-FA authentication.
232 * @return {@code true} if code was accepted
233 * @throws IOException if I/O error occurred
234 * @throws InterruptedException if this request was interrupted
235 * @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
237 public boolean validate2faCode(String code) throws IOException, InterruptedException, ICloudApiResponseException {
238 Map<String, Object> requestBody = Map.of("securityCode", Map.of("code", code));
240 List<Pair<String, String>> headers = ListUtil.replaceEntries(getAuthHeaders(),
241 List.of(Pair.of("Accept", "application/json")));
243 addSessionHeaders(headers);
246 this.session.post(AUTH_ENDPOINT + "/verify/trusteddevice/securitycode", JsonUtils.toJson(requestBody),
248 } catch (ICloudApiResponseException ex) {
249 logger.trace("Exception on code verification with HTTP status {}. Verification might still be successful.",
250 ex.getStatusCode(), ex);
251 // iCloud API returns different 4xx error codes even if validation is successful
252 // currently 400 seems to show that verification "really" failed.
253 if (ex.getStatusCode() == 400 || ex.getStatusCode() >= 500) {
254 this.logger.debug("Verification failed with HTTP status {}.", ex.getStatusCode());
259 logger.debug("Code verification successful.");
265 private void addSessionHeaders(List<Pair<String, String>> headers) {
266 String scnt = session.getScnt();
267 if (scnt != null && !scnt.isEmpty()) {
268 headers.add(Pair.of("scnt", scnt));
271 String sessionId = session.getSessionId();
272 if (sessionId != null && !sessionId.isEmpty()) {
273 headers.add(Pair.of("X-Apple-ID-Session-Id", sessionId));
277 private @Nullable String getWebserviceUrl(String wsKey) {
279 @SuppressWarnings("unchecked")
280 Map<String, Object> webservices = (@Nullable Map<String, Object>) data.get("webservices");
281 if (webservices == null) {
284 if (webservices.get(wsKey) instanceof Map) {
285 @SuppressWarnings("unchecked")
286 Map<String, ?> wsMap = (@Nullable Map<String, ?>) webservices.get(wsKey);
288 logger.error("Webservices result map has not expected format.");
291 return (String) wsMap.get("url");
293 logger.error("Webservices result map has not expected format.");
296 } catch (ClassCastException e) {
297 logger.error("ClassCastException, map has not expected format.", e);
303 * Establish trust for current session.
305 * @return {@code true} if successful.
307 * @throws IOException if I/O error occurred
308 * @throws InterruptedException if this request was interrupted
309 * @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
312 public boolean trustSession() throws IOException, InterruptedException, ICloudApiResponseException {
313 List<Pair<String, String>> headers = getAuthHeaders();
315 addSessionHeaders(headers);
316 this.session.get(AUTH_ENDPOINT + "/2sv/trust", headers);
317 return authenticateWithToken();
321 * Get access to find my iPhone service.
323 * @return Instance of {@link FindMyIPhoneServiceManager} for this session.
324 * @throws IOException if I/O error occurred
325 * @throws InterruptedException if this request was interrupted
327 public FindMyIPhoneServiceManager getDevices() throws IOException, InterruptedException {
328 String webserviceUrl = getWebserviceUrl("findme");
329 if (webserviceUrl != null) {
330 return new FindMyIPhoneServiceManager(this.session, webserviceUrl);
332 throw new IllegalStateException("Webservice URLs not set. Need to authenticate first.");