diff --git a/pom.xml b/pom.xml index 655462e..646c55b 100644 --- a/pom.xml +++ b/pom.xml @@ -55,7 +55,11 @@ 1.8.3 - + + com.fasterxml.jackson.core + jackson-core + 2.17.1 + com.fasterxml.jackson.core jackson-databind @@ -70,7 +74,7 @@ org.projectlombok lombok - 1.18.30 + 1.18.42 provided @@ -119,6 +123,13 @@ ${gson-fire-version} + + + software.amazon.awssdk + auth + 2.34.8 + true + org.junit.jupiter @@ -126,9 +137,14 @@ 5.9.1 test + + org.junit.jupiter + junit-jupiter-params + 5.9.1 + test + - @@ -171,7 +187,7 @@ org.projectlombok lombok - 1.18.30 + 1.18.42 diff --git a/src/main/java/com/infisical/sdk/auth/AwsAuthProvider.java b/src/main/java/com/infisical/sdk/auth/AwsAuthProvider.java new file mode 100644 index 0000000..aa6a337 --- /dev/null +++ b/src/main/java/com/infisical/sdk/auth/AwsAuthProvider.java @@ -0,0 +1,159 @@ +package com.infisical.sdk.auth; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.infisical.sdk.models.AwsAuthParameters; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.http.ContentStreamProvider; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4FamilyHttpSigner; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; +import software.amazon.awssdk.http.auth.spi.signer.HttpSigner; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; + +@Data +@Builder +public class AwsAuthProvider { + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @NonNull @Builder.Default private final String serviceName = "sts"; + @NonNull @Builder.Default private final SdkHttpMethod httpMethod = SdkHttpMethod.POST; + @NonNull @Builder.Default private final String endpointTemplate = "https://sts.%s.amazonaws.com"; + + @NonNull @Builder.Default + private final String contentType = "application/x-www-form-urlencoded; charset=utf-8"; + + @NonNull @Builder.Default + private final Map> params = + Map.ofEntries( + Map.entry("Action", List.of("GetCallerIdentity")), + Map.entry("Version", List.of("2011-06-15"))); + + private final Instant overrideInstant; + + /** + * Create AwsAuthLoginInput from given AWS credentials. + * + * @param region region of AWS identity + * @param credentials AWS credentials for creating the login input + * @param sessionToken Session token for creating the login input + * @return the AwsAuthLoginInput created from the given credentials for exchanging access token + */ + public AwsAuthParameters fromCredentials( + String region, AwsCredentials credentials, String sessionToken) { + final AwsV4HttpSigner signer = AwsV4HttpSigner.create(); + final String iamRequestURL = endpointTemplate.formatted(region); + final String iamRequestBody = encodeParameters(params); + final SdkHttpFullRequest.Builder requestBuilder = + SdkHttpFullRequest.builder() + .uri(URI.create(iamRequestURL)) + .method(httpMethod) + .appendHeader("Content-Type", contentType); + if (sessionToken != null) { + requestBuilder.appendHeader("X-Amz-Security-Token", sessionToken); + } + final SdkHttpFullRequest request = requestBuilder.build(); + final SdkHttpRequest signedRequest = + signer + .sign( + signingRequest -> { + var req = + signingRequest + .request(request) + .identity(credentials) + .payload( + ContentStreamProvider.fromByteArray( + iamRequestBody.getBytes(StandardCharsets.UTF_8))) + .putProperty(AwsV4FamilyHttpSigner.SERVICE_SIGNING_NAME, serviceName) + .putProperty(AwsV4HttpSigner.REGION_NAME, region); + if (overrideInstant != null) { + req.putProperty( + HttpSigner.SIGNING_CLOCK, Clock.fixed(overrideInstant, ZoneOffset.UTC)); + } + }) + .request(); + final Map requestHeaders = + signedRequest.headers().entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), entry.getValue().getFirst())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + requestHeaders.put("Content-Length", String.valueOf(iamRequestBody.length())); + final String encodedHeader; + try { + encodedHeader = + Base64.getEncoder() + .encodeToString( + objectMapper.writeValueAsString(requestHeaders).getBytes(StandardCharsets.UTF_8)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + final String encodedBody = + Base64.getEncoder().encodeToString(iamRequestBody.getBytes(StandardCharsets.UTF_8)); + return AwsAuthParameters.builder() + .iamHttpRequestMethod(httpMethod.name()) + .iamRequestHeaders(encodedHeader) + .iamRequestBody(encodedBody) + .build(); + } + + /** + * Create AwsAuthLoginInput from the instance profile in the current environment. + * + * @return the AwsAuthLoginInput created from the current instance profile for exchanging access + * token + */ + public AwsAuthParameters fromInstanceProfile() { + try (InstanceProfileCredentialsProvider provider = + InstanceProfileCredentialsProvider.create()) { + final AwsSessionCredentials credentials = + (AwsSessionCredentials) provider.resolveCredentials(); + final DefaultAwsRegionProviderChain regionProvider = + DefaultAwsRegionProviderChain.builder().build(); + final Region region = regionProvider.getRegion(); + final String sessionToken = credentials.sessionToken(); + return fromCredentials(region.id(), credentials, sessionToken); + } + } + + /** + * Encode given parameters with URL encoding for the body of form posting request. + * + * @param params parameters mapping key to values to encode + * @return URL-encoded string of the parameters + */ + public static String encodeParameters(Map> params) { + return params.entrySet().stream() + .flatMap(entry -> entry.getValue().stream().map(item -> Map.entry(entry.getKey(), item))) + // Notice: this is not really needed for real world usage, but it makes the + // body encoded in a deterministic order, so that unit test is much easier + .sorted(Map.Entry.comparingByKey()) + .map( + entry -> + String.format( + "%s=%s", + URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8), + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))) + .collect(Collectors.joining("&")); + } + + public static AwsAuthProvider defaultProvider() { + return builder().build(); + } +} diff --git a/src/main/java/com/infisical/sdk/models/AwsAuthLoginInput.java b/src/main/java/com/infisical/sdk/models/AwsAuthLoginInput.java new file mode 100644 index 0000000..60401f9 --- /dev/null +++ b/src/main/java/com/infisical/sdk/models/AwsAuthLoginInput.java @@ -0,0 +1,34 @@ +package com.infisical.sdk.models; + +import com.infisical.sdk.util.Helper; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +@Data +@Builder(toBuilder = true) +public class AwsAuthLoginInput { + @NonNull private final String identityId; + @NonNull private final String iamHttpRequestMethod; + @NonNull private final String iamRequestHeaders; + @NonNull private final String iamRequestBody; + + public String validate() { + if (Helper.isNullOrEmpty(identityId)) { + return "Identity ID is required"; + } + + if (Helper.isNullOrEmpty(iamHttpRequestMethod)) { + return "IamHttpRequestMethod is required"; + } + + if (Helper.isNullOrEmpty(iamRequestHeaders)) { + return "IamRequestHeaders is required"; + } + + if (Helper.isNullOrEmpty(iamRequestBody)) { + return "IamRequestBody is required"; + } + return null; + } +} diff --git a/src/main/java/com/infisical/sdk/models/AwsAuthParameters.java b/src/main/java/com/infisical/sdk/models/AwsAuthParameters.java new file mode 100644 index 0000000..5b0a511 --- /dev/null +++ b/src/main/java/com/infisical/sdk/models/AwsAuthParameters.java @@ -0,0 +1,22 @@ +package com.infisical.sdk.models; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +@Data +@Builder(toBuilder = true) +public class AwsAuthParameters { + @NonNull private final String iamHttpRequestMethod; + @NonNull private final String iamRequestHeaders; + @NonNull private final String iamRequestBody; + + public AwsAuthLoginInput toLoginInput(String identityId) { + return AwsAuthLoginInput.builder() + .identityId(identityId) + .iamRequestHeaders(iamRequestHeaders) + .iamHttpRequestMethod(iamHttpRequestMethod) + .iamRequestBody(iamRequestBody) + .build(); + } +} diff --git a/src/main/java/com/infisical/sdk/resources/AuthClient.java b/src/main/java/com/infisical/sdk/resources/AuthClient.java index 73851e2..bf334df 100644 --- a/src/main/java/com/infisical/sdk/resources/AuthClient.java +++ b/src/main/java/com/infisical/sdk/resources/AuthClient.java @@ -1,12 +1,13 @@ package com.infisical.sdk.resources; +import com.infisical.sdk.api.ApiClient; +import com.infisical.sdk.auth.AwsAuthProvider; +import com.infisical.sdk.models.AwsAuthLoginInput; import com.infisical.sdk.models.LdapAuthLoginInput; import com.infisical.sdk.models.MachineIdentityCredential; +import com.infisical.sdk.models.UniversalAuthLoginInput; import com.infisical.sdk.util.InfisicalException; import java.util.function.Consumer; -import com.infisical.sdk.api.ApiClient; - -import com.infisical.sdk.models.UniversalAuthLoginInput; public class AuthClient { private final ApiClient apiClient; @@ -18,29 +19,44 @@ public AuthClient(ApiClient apiClient, Consumer onAuthenticate) { } public void UniversalAuthLogin(String clientId, String clientSecret) throws InfisicalException { - var params = UniversalAuthLoginInput.builder() - .clientId(clientId) - .clientSecret(clientSecret) - .build(); - - var url = String.format("%s%s", this.apiClient.GetBaseUrl(), "/api/v1/auth/universal-auth/login"); - var credential = this.apiClient.post(url, params, MachineIdentityCredential.class); - this.onAuthenticate.accept(credential.getAccessToken()); + var params = + UniversalAuthLoginInput.builder().clientId(clientId).clientSecret(clientSecret).build(); + + var url = + String.format("%s%s", this.apiClient.GetBaseUrl(), "/api/v1/auth/universal-auth/login"); + var credential = this.apiClient.post(url, params, MachineIdentityCredential.class); + this.onAuthenticate.accept(credential.getAccessToken()); } public void LdapAuthLogin(LdapAuthLoginInput input) throws InfisicalException { - var validationMsg = input.validate(); + var validationMsg = input.validate(); + + if (validationMsg != null) { + throw new InfisicalException(validationMsg); + } + + var url = String.format("%s%s", this.apiClient.GetBaseUrl(), "/api/v1/auth/ldap-auth/login"); + var credential = this.apiClient.post(url, input, MachineIdentityCredential.class); + this.onAuthenticate.accept(credential.getAccessToken()); + } + + public void AwsAuthLogin(String identityId) throws InfisicalException { + AwsAuthLogin(AwsAuthProvider.defaultProvider().fromInstanceProfile().toLoginInput(identityId)); + } + + public void AwsAuthLogin(AwsAuthLoginInput input) throws InfisicalException { + var validationMsg = input.validate(); - if (validationMsg != null) { - throw new InfisicalException(validationMsg); - } + if (validationMsg != null) { + throw new InfisicalException(validationMsg); + } - var url = String.format("%s%s", this.apiClient.GetBaseUrl(), "/api/v1/auth/ldap-auth/login"); - var credential = this.apiClient.post(url, input, MachineIdentityCredential.class); - this.onAuthenticate.accept(credential.getAccessToken()); + var url = String.format("%s%s", this.apiClient.GetBaseUrl(), "/api/v1/auth/aws-auth/login"); + var credential = this.apiClient.post(url, input, MachineIdentityCredential.class); + this.onAuthenticate.accept(credential.getAccessToken()); } public void SetAccessToken(String accessToken) { this.onAuthenticate.accept(accessToken); } -} \ No newline at end of file +} diff --git a/src/test/java/com/infisical/sdk/auth/AwsAuthProviderTest.java b/src/test/java/com/infisical/sdk/auth/AwsAuthProviderTest.java new file mode 100644 index 0000000..a124a9b --- /dev/null +++ b/src/test/java/com/infisical/sdk/auth/AwsAuthProviderTest.java @@ -0,0 +1,99 @@ +package com.infisical.sdk.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.infisical.sdk.models.AwsAuthParameters; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; + +class AwsAuthProviderTest { + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void testFromCredentials() throws JsonProcessingException { + final AwsAuthProvider provider = + AwsAuthProvider.builder().overrideInstant(Instant.ofEpochSecond(1759446719)).build(); + final AwsAuthParameters loginInput = + provider.fromCredentials( + "us-west-2", + AwsBasicCredentials.create("MOCK_ACCESS_KEY", "MOCK_SECRET_KEY"), + "MOCK_SESSION_TOKEN"); + assertEquals("POST", loginInput.getIamHttpRequestMethod()); + + final String decodedBody = + new String( + Base64.getDecoder().decode(loginInput.getIamRequestBody()), StandardCharsets.UTF_8); + final Map> bodyParams = + Arrays.stream(decodedBody.split("&")) + .map( + item -> { + final String[] parts = + URLDecoder.decode(item, StandardCharsets.UTF_8).split("=", 2); + return Map.entry(parts[0], List.of(parts[1])); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + assertEquals(provider.getParams(), bodyParams); + + final String decodedHeaders = + new String( + Base64.getDecoder().decode(loginInput.getIamRequestHeaders()), StandardCharsets.UTF_8); + final Map actualHeaders = + objectMapper.readValue(decodedHeaders, new TypeReference<>() {}); + assertEquals( + Map.ofEntries( + Map.entry("Host", "sts.us-west-2.amazonaws.com"), + Map.entry("Content-Type", "application/x-www-form-urlencoded; charset=utf-8"), + Map.entry("Content-Length", "43"), + Map.entry( + "x-amz-content-sha256", + "ab821ae955788b0e33ebd34c208442ccfc2d406e2edc5e7a39bd6458fbb4f843"), + Map.entry("X-Amz-Security-Token", "MOCK_SESSION_TOKEN"), + Map.entry("X-Amz-Date", "20251002T231159Z"), + Map.entry( + "Authorization", + "AWS4-HMAC-SHA256 Credential=MOCK_ACCESS_KEY/20251002/us-west-2/sts/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=9b1b93454bea36297168ed67a861df12d17136f47cbdf5d23b1daa0fe704742b")), + actualHeaders); + } + + static Stream encodeParametersCases() { + return Stream.of( + // empty + Arguments.of(Map.of(), ""), + // simple + Arguments.of( + Map.ofEntries(Map.entry("a", List.of("123")), Map.entry("b", List.of("456"))), + "a=123&b=456"), + // sorting the key + Arguments.of( + Map.ofEntries( + Map.entry("d", List.of("3")), + Map.entry("a", List.of("0")), + Map.entry("c", List.of("2")), + Map.entry("b", List.of("1"))), + "a=0&b=1&c=2&d=3"), + Arguments.of( + Map.ofEntries(Map.entry("a", List.of("!@#$%^&*(){}[]"))), + "a=%21%40%23%24%25%5E%26*%28%29%7B%7D%5B%5D")); + } + + @ParameterizedTest + @MethodSource("encodeParametersCases") + void testEncodeParameters(Map> params, String expected) { + assertEquals(expected, AwsAuthProvider.encodeParameters(params)); + } +}