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.windcentrale.internal.api;
15 import static java.nio.charset.StandardCharsets.UTF_8;
16 import static org.eclipse.jetty.http.HttpMethod.POST;
17 import static org.openhab.binding.windcentrale.internal.dto.CognitoGson.GSON;
19 import java.math.BigInteger;
20 import java.security.InvalidKeyException;
21 import java.security.MessageDigest;
22 import java.security.NoSuchAlgorithmException;
23 import java.security.SecureRandom;
24 import java.time.Duration;
25 import java.time.Instant;
26 import java.time.ZoneId;
27 import java.time.format.DateTimeFormatter;
28 import java.util.Base64;
29 import java.util.Locale;
30 import java.util.Objects;
31 import java.util.concurrent.ExecutionException;
32 import java.util.concurrent.TimeUnit;
33 import java.util.concurrent.TimeoutException;
35 import javax.crypto.Mac;
36 import javax.crypto.SecretKey;
37 import javax.crypto.spec.SecretKeySpec;
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.eclipse.jetty.client.HttpClient;
42 import org.eclipse.jetty.client.api.ContentResponse;
43 import org.eclipse.jetty.client.util.StringContentProvider;
44 import org.openhab.binding.windcentrale.internal.dto.AuthenticationResultResponse;
45 import org.openhab.binding.windcentrale.internal.dto.ChallengeResponse;
46 import org.openhab.binding.windcentrale.internal.dto.CognitoError;
47 import org.openhab.binding.windcentrale.internal.dto.InitiateAuthRequest;
48 import org.openhab.binding.windcentrale.internal.dto.RespondToAuthChallengeRequest;
49 import org.openhab.binding.windcentrale.internal.exception.InvalidAccessTokenException;
50 import org.openhab.core.io.net.http.HttpClientFactory;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
55 * Helps with authenticating users to Amazon Cognito to get a JWT access token which can be used for retrieving
56 * information using the REST APIs.
58 * @see <a href="https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol">
59 * https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol</a>
60 * @see <a href="https://stackoverflow.com/questions/67528443/cognito-srp-using-aws-java-sdk-v2-x">
61 * https://stackoverflow.com/questions/67528443/cognito-srp-using-aws-java-sdk-v2-x</a>
63 * "https://github.com/aws-samples/aws-cognito-java-desktop-app/blob/master/src/main/java/com/amazonaws/sample/cognitoui/AuthenticationHelper.java">
64 * https://github.com/aws-samples/aws-cognito-java-desktop-app/blob/master/src/main/java/com/amazonaws/sample/cognitoui/AuthenticationHelper.java</a>
66 * @author Wouter Born - Initial contribution
69 public class AuthenticationHelper {
71 private final Logger logger = LoggerFactory.getLogger(AuthenticationHelper.class);
73 private static final String SRP_N_HEX = "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" //
74 + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" //
75 + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" //
76 + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" //
77 + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" //
78 + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" //
79 + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" //
80 + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" //
81 + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" //
82 + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" //
83 + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" //
84 + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" //
85 + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" //
86 + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" //
87 + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" //
88 + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF";
90 private static final BigInteger SRP_A;
91 private static final BigInteger SRP_A2;
92 private static final BigInteger SRP_G = BigInteger.valueOf(2);
93 private static final BigInteger SRP_K;
94 private static final BigInteger SRP_N = new BigInteger(SRP_N_HEX, 16);
96 private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter
97 .ofPattern("EEE MMM d HH:mm:ss z yyyy", Locale.US).withZone(ZoneId.of("UTC"));
98 private static final int DERIVED_KEY_SIZE = 16;
99 private static final int EPHEMERAL_KEY_LENGTH = 1024;
100 private static final String DERIVED_KEY_INFO = "Caldera Derived Key";
101 private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
103 private static final String COGNITO_URL_FORMAT = "https://cognito-idp.%s.amazonaws.com/";
104 private static final String INITIATE_AUTH_TARGET = "AWSCognitoIdentityProviderService.InitiateAuth";
105 private static final String RESPOND_TO_AUTH_TARGET = "AWSCognitoIdentityProviderService.RespondToAuthChallenge";
108 * Internal class for doing the HKDF calculations.
110 private static final class Hkdf {
111 private static final int MAX_KEY_SIZE = 255;
112 private final String algorithm;
113 private @Nullable SecretKey prk;
116 * @param algorithm The type of HMAC algorithm to be used
118 private Hkdf(String algorithm) {
119 if (!algorithm.startsWith("Hmac")) {
120 throw new IllegalArgumentException(
121 "Invalid algorithm " + algorithm + ". HKDF may only be used with HMAC algorithms.");
123 this.algorithm = algorithm;
127 * @param ikm the input key material
128 * @param salt random bytes for salt
130 private void init(byte[] ikm, byte[] salt) {
132 Mac mac = Mac.getInstance(algorithm);
133 byte[] realSalt = salt.length == 0 ? new byte[mac.getMacLength()] : salt.clone();
134 mac.init(new SecretKeySpec(realSalt, algorithm));
135 SecretKeySpec key = new SecretKeySpec(mac.doFinal(ikm), algorithm);
136 unsafeInitWithoutKeyExtraction(key);
137 } catch (InvalidKeyException | NoSuchAlgorithmException e) {
138 throw new IllegalStateException("Failed to initialize HKDF", e);
143 * @param rawKey current secret key
145 private void unsafeInitWithoutKeyExtraction(SecretKey rawKey) {
146 if (!rawKey.getAlgorithm().equals(algorithm)) {
147 throw new IllegalArgumentException(
148 "Algorithm for the provided key must match the algorithm for this HKDF. Expected " + algorithm
149 + " but found " + rawKey.getAlgorithm());
155 private byte[] deriveKey(String info, int length) {
157 throw new IllegalStateException("HKDF has not been initialized");
161 throw new IllegalArgumentException("Length must be a non-negative value");
164 Mac mac = createMac();
165 if (length > MAX_KEY_SIZE * mac.getMacLength()) {
166 throw new IllegalArgumentException(
167 "Requested keys may not be longer than 255 times the underlying HMAC length");
170 byte[] result = new byte[length];
171 byte[] bytes = info.getBytes(UTF_8);
175 for (byte i = 1; loc < length; ++i) {
181 for (int x = 0; x < t.length && loc < length; ++loc) {
191 * @return the generated message authentication code
193 private Mac createMac() {
195 Mac mac = Mac.getInstance(algorithm);
198 } catch (InvalidKeyException | NoSuchAlgorithmException e) {
199 throw new IllegalStateException("Could not create MAC implementing algorithm: " + algorithm, e);
205 // Initialize the SRP variables
207 SecureRandom sr = SecureRandom.getInstance("SHA1PRNG");
208 MessageDigest md = MessageDigest.getInstance("SHA-256");
209 md.update(SRP_N.toByteArray());
211 byte[] digest = md.digest(SRP_G.toByteArray());
212 SRP_K = new BigInteger(1, digest);
217 srpA2 = new BigInteger(EPHEMERAL_KEY_LENGTH, sr).mod(SRP_N);
218 srpA = SRP_G.modPow(srpA2, SRP_N);
219 } while (srpA.mod(SRP_N).equals(BigInteger.ZERO));
223 } catch (NoSuchAlgorithmException e) {
224 throw new IllegalStateException("SRP variables cannot be initialized due to missing algorithm", e);
228 private final HttpClient httpClient;
229 private final String userPoolId;
230 private final String clientId;
231 private final String region;
233 public AuthenticationHelper(HttpClientFactory httpClientFactory, String userPoolId, String clientId,
235 this.httpClient = httpClientFactory.getCommonHttpClient();
236 this.userPoolId = userPoolId;
237 this.clientId = clientId;
238 this.region = region;
242 * Method to orchestrate the SRP Authentication.
244 * @param username username for the SRP request
245 * @param password password for the SRP request
246 * @return JWT token if the request is successful
247 * @throws InvalidAccessTokenException when SRP authentication fails
249 public AuthenticationResultResponse performSrpAuthentication(String username, String password)
250 throws InvalidAccessTokenException {
251 InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.userSrpAuth(clientId, username,
254 ChallengeResponse challengeResponse = postInitiateAuthSrp(initiateAuthRequest);
255 if ("PASSWORD_VERIFIER".equals(challengeResponse.challengeName)) {
256 RespondToAuthChallengeRequest challengeRequest = createRespondToAuthChallengeRequest(challengeResponse,
258 return postRespondToAuthChallenge(challengeRequest);
260 throw new InvalidAccessTokenException(
261 "Unsupported authentication challenge: " + challengeResponse.challengeName);
263 } catch (IllegalStateException | InvalidKeyException | NoSuchAlgorithmException e) {
264 throw new InvalidAccessTokenException("SRP Authentication failed", e);
268 public AuthenticationResultResponse performTokenRefresh(String refreshToken) throws InvalidAccessTokenException {
269 InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.refreshTokenAuth(clientId, refreshToken);
271 return postInitiateAuthRefresh(initiateAuthRequest);
272 } catch (IllegalStateException e) {
273 throw new InvalidAccessTokenException("Token refresh failed", e);
278 * Creates a response request to the SRP authentication challenge from the user pool.
280 * @param challengeResponse authentication challenge returned from the Cognito user pool
281 * @param password password to be used to respond to the authentication challenge
282 * @return request created for the previous authentication challenge
284 private RespondToAuthChallengeRequest createRespondToAuthChallengeRequest(ChallengeResponse challengeResponse,
285 String password) throws InvalidKeyException, NoSuchAlgorithmException {
286 String salt = challengeResponse.getSalt();
287 String secretBlock = challengeResponse.getSecretBlock();
288 String userIdForSrp = challengeResponse.getUserIdForSrp();
289 String usernameInternal = challengeResponse.getUsername();
291 if (secretBlock.isEmpty() || userIdForSrp.isEmpty() || usernameInternal.isEmpty()) {
292 throw new IllegalArgumentException("Required authentication response challenge parameters are null");
295 BigInteger srpB = new BigInteger(challengeResponse.getSrpB(), 16);
296 if (srpB.mod(SRP_N).equals(BigInteger.ZERO)) {
297 throw new IllegalStateException("SRP error, B cannot be zero");
300 String timestamp = DATE_TIME_FORMATTER.format(Instant.now());
302 byte[] key = getPasswordAuthenticationKey(userIdForSrp, password, srpB, new BigInteger(salt, 16));
304 Mac mac = Mac.getInstance("HmacSHA256");
305 mac.init(new SecretKeySpec(key, "HmacSHA256"));
306 mac.update(userPoolId.split("_", 2)[1].getBytes(UTF_8));
307 mac.update(userIdForSrp.getBytes(UTF_8));
308 mac.update(Base64.getDecoder().decode(secretBlock));
309 byte[] hmac = mac.doFinal(timestamp.getBytes(UTF_8));
311 String signature = new String(Base64.getEncoder().encode(hmac), UTF_8);
313 return new RespondToAuthChallengeRequest(clientId, usernameInternal, secretBlock, signature, timestamp);
316 private byte[] getPasswordAuthenticationKey(String userId, String userPassword, BigInteger srpB, BigInteger salt) {
318 // Authenticate the password
319 // srpU = H(SRP_A, srpB)
320 MessageDigest md = MessageDigest.getInstance("SHA-256");
321 md.update(SRP_A.toByteArray());
323 BigInteger srpU = new BigInteger(1, md.digest(srpB.toByteArray()));
324 if (srpU.equals(BigInteger.ZERO)) {
325 throw new IllegalStateException("Hash of A and B cannot be zero");
328 // srpX = H(salt | H(poolName | userId | ":" | password))
330 md.update(userPoolId.split("_", 2)[1].getBytes(UTF_8));
331 md.update(userId.getBytes(UTF_8));
332 md.update(":".getBytes(UTF_8));
334 byte[] userIdHash = md.digest(userPassword.getBytes(UTF_8));
337 md.update(salt.toByteArray());
339 BigInteger srpX = new BigInteger(1, md.digest(userIdHash));
340 BigInteger srpS = (srpB.subtract(SRP_K.multiply(SRP_G.modPow(srpX, SRP_N)))
341 .modPow(SRP_A2.add(srpU.multiply(srpX)), SRP_N)).mod(SRP_N);
343 Hkdf hkdf = new Hkdf("HmacSHA256");
344 hkdf.init(srpS.toByteArray(), srpU.toByteArray());
345 return hkdf.deriveKey(DERIVED_KEY_INFO, DERIVED_KEY_SIZE);
346 } catch (NoSuchAlgorithmException e) {
347 throw new IllegalStateException(e.getMessage(), e);
351 private ChallengeResponse postInitiateAuthSrp(InitiateAuthRequest request) throws InvalidAccessTokenException {
352 String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request));
353 return Objects.requireNonNull(GSON.fromJson(responseContent, ChallengeResponse.class));
356 private AuthenticationResultResponse postInitiateAuthRefresh(InitiateAuthRequest request)
357 throws InvalidAccessTokenException {
358 String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request));
359 return Objects.requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class));
362 private AuthenticationResultResponse postRespondToAuthChallenge(RespondToAuthChallengeRequest request)
363 throws InvalidAccessTokenException {
364 String responseContent = postJson(RESPOND_TO_AUTH_TARGET, GSON.toJson(request));
365 return Objects.requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class));
368 private String postJson(String target, String requestContent) throws InvalidAccessTokenException {
370 String url = String.format(COGNITO_URL_FORMAT, region);
371 logger.debug("Posting JSON to: {}", url);
372 ContentResponse contentResponse = httpClient.newRequest(url) //
374 .header("x-amz-target", target) //
375 .content(new StringContentProvider(requestContent), "application/x-amz-json-1.1") //
376 .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS).send();
378 String response = contentResponse.getContentAsString();
379 if (contentResponse.getStatus() >= 400) {
380 logger.debug("Cognito API error: {}", response);
382 CognitoError error = GSON.fromJson(response, CognitoError.class);
384 if (error != null && !error.message.isBlank()) {
385 message = String.format("Cognito API error: %s (%s)", error.message, error.type);
387 message = String.format("Cognito API error: %s (HTTP %s)", contentResponse.getReason(),
388 contentResponse.getStatus());
390 throw new InvalidAccessTokenException(message);
392 logger.trace("Response: {}", response);
395 } catch (InterruptedException | TimeoutException | ExecutionException e) {
396 throw new InvalidAccessTokenException("Cognito API request failed: " + e.getMessage(), e);