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 https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol
59 * @see https://stackoverflow.com/questions/67528443/cognito-srp-using-aws-java-sdk-v2-x
60 * @see https://github.com/aws-samples/aws-cognito-java-desktop-app/blob/master/src/main/java/com/amazonaws/sample/cognitoui/AuthenticationHelper.java
62 * @author Wouter Born - Initial contribution
65 public class AuthenticationHelper {
67 private final Logger logger = LoggerFactory.getLogger(AuthenticationHelper.class);
69 private static final String SRP_N_HEX = "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" //
70 + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" //
71 + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" //
72 + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" //
73 + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" //
74 + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" //
75 + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" //
76 + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" //
77 + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" //
78 + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" //
79 + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" //
80 + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" //
81 + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" //
82 + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" //
83 + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" //
84 + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF";
86 private static final BigInteger SRP_A;
87 private static final BigInteger SRP_A2;
88 private static final BigInteger SRP_G = BigInteger.valueOf(2);
89 private static final BigInteger SRP_K;
90 private static final BigInteger SRP_N = new BigInteger(SRP_N_HEX, 16);
92 private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter
93 .ofPattern("EEE MMM d HH:mm:ss z yyyy", Locale.US).withZone(ZoneId.of("UTC"));
94 private static final int DERIVED_KEY_SIZE = 16;
95 private static final int EPHEMERAL_KEY_LENGTH = 1024;
96 private static final String DERIVED_KEY_INFO = "Caldera Derived Key";
97 private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
99 private static final String COGNITO_URL_FORMAT = "https://cognito-idp.%s.amazonaws.com/";
100 private static final String INITIATE_AUTH_TARGET = "AWSCognitoIdentityProviderService.InitiateAuth";
101 private static final String RESPOND_TO_AUTH_TARGET = "AWSCognitoIdentityProviderService.RespondToAuthChallenge";
104 * Internal class for doing the HKDF calculations.
106 private static final class Hkdf {
107 private static final int MAX_KEY_SIZE = 255;
108 private final String algorithm;
109 private @Nullable SecretKey prk;
112 * @param algorithm The type of HMAC algorithm to be used
114 private Hkdf(String algorithm) {
115 if (!algorithm.startsWith("Hmac")) {
116 throw new IllegalArgumentException(
117 "Invalid algorithm " + algorithm + ". HKDF may only be used with HMAC algorithms.");
119 this.algorithm = algorithm;
123 * @param ikm the input key material
124 * @param salt random bytes for salt
126 private void init(byte[] ikm, byte[] salt) {
128 Mac mac = Mac.getInstance(algorithm);
129 byte[] realSalt = salt.length == 0 ? new byte[mac.getMacLength()] : salt.clone();
130 mac.init(new SecretKeySpec(realSalt, algorithm));
131 SecretKeySpec key = new SecretKeySpec(mac.doFinal(ikm), algorithm);
132 unsafeInitWithoutKeyExtraction(key);
133 } catch (InvalidKeyException | NoSuchAlgorithmException e) {
134 throw new IllegalStateException("Failed to initialize HKDF", e);
139 * @param rawKey current secret key
141 private void unsafeInitWithoutKeyExtraction(SecretKey rawKey) {
142 if (!rawKey.getAlgorithm().equals(algorithm)) {
143 throw new IllegalArgumentException(
144 "Algorithm for the provided key must match the algorithm for this HKDF. Expected " + algorithm
145 + " but found " + rawKey.getAlgorithm());
151 private byte[] deriveKey(String info, int length) {
153 throw new IllegalStateException("HKDF has not been initialized");
157 throw new IllegalArgumentException("Length must be a non-negative value");
160 Mac mac = createMac();
161 if (length > MAX_KEY_SIZE * mac.getMacLength()) {
162 throw new IllegalArgumentException(
163 "Requested keys may not be longer than 255 times the underlying HMAC length");
166 byte[] result = new byte[length];
167 byte[] bytes = info.getBytes(UTF_8);
171 for (byte i = 1; loc < length; ++i) {
177 for (int x = 0; x < t.length && loc < length; ++loc) {
187 * @return the generated message authentication code
189 private Mac createMac() {
191 Mac mac = Mac.getInstance(algorithm);
194 } catch (InvalidKeyException | NoSuchAlgorithmException e) {
195 throw new IllegalStateException("Could not create MAC implementing algorithm: " + algorithm, e);
201 // Initialize the SRP variables
203 SecureRandom sr = SecureRandom.getInstance("SHA1PRNG");
204 MessageDigest md = MessageDigest.getInstance("SHA-256");
205 md.update(SRP_N.toByteArray());
207 byte[] digest = md.digest(SRP_G.toByteArray());
208 SRP_K = new BigInteger(1, digest);
213 srpA2 = new BigInteger(EPHEMERAL_KEY_LENGTH, sr).mod(SRP_N);
214 srpA = SRP_G.modPow(srpA2, SRP_N);
215 } while (srpA.mod(SRP_N).equals(BigInteger.ZERO));
219 } catch (NoSuchAlgorithmException e) {
220 throw new IllegalStateException("SRP variables cannot be initialized due to missing algorithm", e);
224 private final HttpClient httpClient;
225 private final String userPoolId;
226 private final String clientId;
227 private final String region;
229 public AuthenticationHelper(HttpClientFactory httpClientFactory, String userPoolId, String clientId,
231 this.httpClient = httpClientFactory.getCommonHttpClient();
232 this.userPoolId = userPoolId;
233 this.clientId = clientId;
234 this.region = region;
238 * Method to orchestrate the SRP Authentication.
240 * @param username username for the SRP request
241 * @param password password for the SRP request
242 * @return JWT token if the request is successful
243 * @throws InvalidAccessTokenException when SRP authentication fails
245 public AuthenticationResultResponse performSrpAuthentication(String username, String password)
246 throws InvalidAccessTokenException {
247 InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.userSrpAuth(clientId, username,
250 ChallengeResponse challengeResponse = postInitiateAuthSrp(initiateAuthRequest);
251 if ("PASSWORD_VERIFIER".equals(challengeResponse.challengeName)) {
252 RespondToAuthChallengeRequest challengeRequest = createRespondToAuthChallengeRequest(challengeResponse,
254 return postRespondToAuthChallenge(challengeRequest);
256 throw new InvalidAccessTokenException(
257 "Unsupported authentication challenge: " + challengeResponse.challengeName);
259 } catch (IllegalStateException | InvalidKeyException | NoSuchAlgorithmException e) {
260 throw new InvalidAccessTokenException("SRP Authentication failed", e);
264 public AuthenticationResultResponse performTokenRefresh(String refreshToken) throws InvalidAccessTokenException {
265 InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.refreshTokenAuth(clientId, refreshToken);
267 return postInitiateAuthRefresh(initiateAuthRequest);
268 } catch (IllegalStateException e) {
269 throw new InvalidAccessTokenException("Token refresh failed", e);
274 * Creates a response request to the SRP authentication challenge from the user pool.
276 * @param challengeResponse authentication challenge returned from the Cognito user pool
277 * @param password password to be used to respond to the authentication challenge
278 * @return request created for the previous authentication challenge
280 private RespondToAuthChallengeRequest createRespondToAuthChallengeRequest(ChallengeResponse challengeResponse,
281 String password) throws InvalidKeyException, NoSuchAlgorithmException {
282 String salt = challengeResponse.getSalt();
283 String secretBlock = challengeResponse.getSecretBlock();
284 String userIdForSrp = challengeResponse.getUserIdForSrp();
285 String usernameInternal = challengeResponse.getUsername();
287 if (secretBlock.isEmpty() || userIdForSrp.isEmpty() || usernameInternal.isEmpty()) {
288 throw new IllegalArgumentException("Required authentication response challenge parameters are null");
291 BigInteger srpB = new BigInteger(challengeResponse.getSrpB(), 16);
292 if (srpB.mod(SRP_N).equals(BigInteger.ZERO)) {
293 throw new IllegalStateException("SRP error, B cannot be zero");
296 String timestamp = DATE_TIME_FORMATTER.format(Instant.now());
298 byte[] key = getPasswordAuthenticationKey(userIdForSrp, password, srpB, new BigInteger(salt, 16));
300 Mac mac = Mac.getInstance("HmacSHA256");
301 mac.init(new SecretKeySpec(key, "HmacSHA256"));
302 mac.update(userPoolId.split("_", 2)[1].getBytes(UTF_8));
303 mac.update(userIdForSrp.getBytes(UTF_8));
304 mac.update(Base64.getDecoder().decode(secretBlock));
305 byte[] hmac = mac.doFinal(timestamp.getBytes(UTF_8));
307 String signature = new String(Base64.getEncoder().encode(hmac), UTF_8);
309 return new RespondToAuthChallengeRequest(clientId, usernameInternal, secretBlock, signature, timestamp);
312 private byte[] getPasswordAuthenticationKey(String userId, String userPassword, BigInteger srpB, BigInteger salt) {
314 // Authenticate the password
315 // srpU = H(SRP_A, srpB)
316 MessageDigest md = MessageDigest.getInstance("SHA-256");
317 md.update(SRP_A.toByteArray());
319 BigInteger srpU = new BigInteger(1, md.digest(srpB.toByteArray()));
320 if (srpU.equals(BigInteger.ZERO)) {
321 throw new IllegalStateException("Hash of A and B cannot be zero");
324 // srpX = H(salt | H(poolName | userId | ":" | password))
326 md.update(userPoolId.split("_", 2)[1].getBytes(UTF_8));
327 md.update(userId.getBytes(UTF_8));
328 md.update(":".getBytes(UTF_8));
330 byte[] userIdHash = md.digest(userPassword.getBytes(UTF_8));
333 md.update(salt.toByteArray());
335 BigInteger srpX = new BigInteger(1, md.digest(userIdHash));
336 BigInteger srpS = (srpB.subtract(SRP_K.multiply(SRP_G.modPow(srpX, SRP_N)))
337 .modPow(SRP_A2.add(srpU.multiply(srpX)), SRP_N)).mod(SRP_N);
339 Hkdf hkdf = new Hkdf("HmacSHA256");
340 hkdf.init(srpS.toByteArray(), srpU.toByteArray());
341 return hkdf.deriveKey(DERIVED_KEY_INFO, DERIVED_KEY_SIZE);
342 } catch (NoSuchAlgorithmException e) {
343 throw new IllegalStateException(e.getMessage(), e);
347 private ChallengeResponse postInitiateAuthSrp(InitiateAuthRequest request) throws InvalidAccessTokenException {
348 String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request));
349 return Objects.requireNonNull(GSON.fromJson(responseContent, ChallengeResponse.class));
352 private AuthenticationResultResponse postInitiateAuthRefresh(InitiateAuthRequest request)
353 throws InvalidAccessTokenException {
354 String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request));
355 return Objects.requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class));
358 private AuthenticationResultResponse postRespondToAuthChallenge(RespondToAuthChallengeRequest request)
359 throws InvalidAccessTokenException {
360 String responseContent = postJson(RESPOND_TO_AUTH_TARGET, GSON.toJson(request));
361 return Objects.requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class));
364 private String postJson(String target, String requestContent) throws InvalidAccessTokenException {
366 String url = String.format(COGNITO_URL_FORMAT, region);
367 logger.debug("Posting JSON to: {}", url);
368 ContentResponse contentResponse = httpClient.newRequest(url) //
370 .header("x-amz-target", target) //
371 .content(new StringContentProvider(requestContent), "application/x-amz-json-1.1") //
372 .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS).send();
374 String response = contentResponse.getContentAsString();
375 if (contentResponse.getStatus() >= 400) {
376 logger.debug("Cognito API error: {}", response);
378 CognitoError error = GSON.fromJson(response, CognitoError.class);
380 if (error != null && !error.message.isBlank()) {
381 message = String.format("Cognito API error: %s (%s)", error.message, error.type);
383 message = String.format("Cognito API error: %s (HTTP %s)", contentResponse.getReason(),
384 contentResponse.getStatus());
386 throw new InvalidAccessTokenException(message);
388 logger.trace("Response: {}", response);
391 } catch (InterruptedException | TimeoutException | ExecutionException e) {
392 throw new InvalidAccessTokenException("Cognito API request failed: " + e.getMessage(), e);