diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml index dbf7630e3..5b7217972 100644 --- a/samples/snippets/pom.xml +++ b/samples/snippets/pom.xml @@ -34,6 +34,13 @@ pom import + + software.amazon.awssdk + bom + 2.20.27 + pom + import + @@ -62,21 +69,14 @@ com.google.cloud google-cloud-storage - - - junit - junit - 4.13.2 - test + software.amazon.awssdk + auth - truth - com.google.truth - test - 1.4.4 + software.amazon.awssdk + regions - diff --git a/samples/snippets/src/main/java/CustomCredentialSupplierAwsWorkload.java b/samples/snippets/src/main/java/CustomCredentialSupplierAwsWorkload.java new file mode 100644 index 000000000..5da2a2628 --- /dev/null +++ b/samples/snippets/src/main/java/CustomCredentialSupplierAwsWorkload.java @@ -0,0 +1,162 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.google.auth.oauth2.AwsCredentials; +import com.google.auth.oauth2.AwsSecurityCredentials; +import com.google.auth.oauth2.AwsSecurityCredentialsSupplier; +import com.google.auth.oauth2.ExternalAccountSupplierContext; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.storage.Bucket; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; + +/** + * This sample demonstrates how to use a custom AWS security credentials supplier to authenticate to + * Google Cloud Storage. + */ +public class CustomCredentialSupplierAwsWorkload { + + public static void main(String[] args) { + // TODO(Developer): Set these environment variable values. + + // 1. AWS Credentials: + // If running on a local system, the user must set AWS_REGION, AWS_ACCESS_KEY_ID, and + // AWS_SECRET_ACCESS_KEY as environment variables. If running in an AWS environment (e.g., + // ECS, EKS), these variables will be auto-detected. + + // 2. GCP_WORKLOAD_AUDIENCE: + // The audience for the workload identity federation. This is the full resource name of the + // Workload Identity Pool Provider, in the following format: + // `//iam.googleapis.com/projects//locations/global/workloadIdentityPools//providers/` + String gcpWorkloadAudience = System.getenv("GCP_WORKLOAD_AUDIENCE"); + + // 3. GCP_SERVICE_ACCOUNT_IMPERSONATION_URL (optional): + // The service account impersonation URL. It should follow the format: + // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/:generateAccessToken + // If not provided, you should grant access to the GCP bucket to the principal directly. + String saImpersonationUrl = System.getenv("GCP_SERVICE_ACCOUNT_IMPERSONATION_URL"); + + // 4. GCS_BUCKET_NAME: + // The name of the bucket that you wish to fetch data for. + String gcsBucketName = System.getenv("GCS_BUCKET_NAME"); + + if (gcpWorkloadAudience == null || gcsBucketName == null) { + System.out.println( + "Missing required environment variables. Please check your environment settings. " + + "Required: GCP_WORKLOAD_AUDIENCE, GCS_BUCKET_NAME"); + return; + } + + customCredentialSupplierAwsWorkload(gcpWorkloadAudience, saImpersonationUrl, gcsBucketName); + } + + public static void customCredentialSupplierAwsWorkload( + String gcpWorkloadAudience, String saImpersonationUrl, String gcsBucketName) { + // 1. Instantiate the custom supplier. + CustomAwsSupplier customSupplier = new CustomAwsSupplier(); + + // 2. Configure the AwsCredentials options. + AwsCredentials.Builder credentialsBuilder = + AwsCredentials.newBuilder() + .setAudience(gcpWorkloadAudience) + // This token type indicates that the subject token is an AWS Signature Version 4 signed + // request. This is required for AWS Workload Identity Federation. + .setSubjectTokenType("urn:ietf:params:aws:token-type:aws4_request") + .setAwsSecurityCredentialsSupplier(customSupplier); + + if (saImpersonationUrl != null) { + credentialsBuilder.setServiceAccountImpersonationUrl(saImpersonationUrl); + } + + GoogleCredentials credentials = credentialsBuilder.build(); + + // 3. Use the credentials to make an authenticated request. + Storage storage = StorageOptions.newBuilder().setCredentials(credentials).build().getService(); + + System.out.println("Getting metadata for bucket: " + gcsBucketName + "..."); + Bucket bucket = storage.get(gcsBucketName); + System.out.println(" --- SUCCESS! ---"); + System.out.println("Successfully authenticated and retrieved bucket data:"); + System.out.println(bucket.toString()); + } + + /** + * Custom AWS Security Credentials Supplier. + * + *

This implementation resolves AWS credentials using the default provider chain from the AWS + * SDK. This allows fetching credentials from environment variables, shared credential files + * (~/.aws/credentials), or IAM roles for service accounts (IRSA) in EKS, etc. + */ + private static class CustomAwsSupplier implements AwsSecurityCredentialsSupplier { + + private final AwsCredentialsProvider awsCredentialsProvider; + private String region; + + public CustomAwsSupplier() { + // The AWS SDK handles memoization (caching) and proactive refreshing internally. + this.awsCredentialsProvider = DefaultCredentialsProvider.create(); + } + + /** + * Returns the AWS region. This is required for signing the AWS request. It resolves the region + * automatically by using the default AWS region provider chain, which searches for the region + * in the standard locations (environment variables, AWS config file, etc.). + */ + @Override + public String getRegion(ExternalAccountSupplierContext context) { + if (this.region == null) { + Region awsRegion = new DefaultAwsRegionProviderChain().getRegion(); + if (awsRegion != null) { + this.region = awsRegion.id(); + } + } + if (this.region == null) { + throw new IllegalStateException( + "CustomAwsSupplier: Unable to resolve AWS region. Please set the AWS_REGION " + + "environment variable or configure it in your ~/.aws/config file."); + } + return this.region; + } + + /** Retrieves AWS security credentials using the AWS SDK's default provider chain. */ + @Override + public AwsSecurityCredentials getCredentials(ExternalAccountSupplierContext context) { + software.amazon.awssdk.auth.credentials.AwsCredentials credentials = + this.awsCredentialsProvider.resolveCredentials(); + if (credentials == null + || credentials.accessKeyId() == null + || credentials.secretAccessKey() == null) { + throw new IllegalStateException( + "Unable to resolve AWS credentials from the default provider chain. " + + "Ensure your AWS CLI is configured, or AWS environment variables " + + "(like AWS_ACCESS_KEY_ID) are set."); + } + + String sessionToken = null; + if (credentials instanceof AwsSessionCredentials) { + sessionToken = ((AwsSessionCredentials) credentials).sessionToken(); + } + + return new com.google.auth.oauth2.AwsSecurityCredentials( + credentials.accessKeyId(), credentials.secretAccessKey(), sessionToken); + } + } +} diff --git a/samples/snippets/src/main/java/CustomCredentialSupplierOktaWorkload.java b/samples/snippets/src/main/java/CustomCredentialSupplierOktaWorkload.java new file mode 100644 index 000000000..4c53f2546 --- /dev/null +++ b/samples/snippets/src/main/java/CustomCredentialSupplierOktaWorkload.java @@ -0,0 +1,253 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.gson.GsonFactory; +import com.google.auth.oauth2.ExternalAccountSupplierContext; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.IdentityPoolCredentials; +import com.google.auth.oauth2.IdentityPoolSubjectTokenSupplier; +import com.google.cloud.storage.Bucket; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import java.time.Instant; +import java.io.BufferedReader; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * This sample demonstrates how to use a custom subject token supplier to authenticate to Google + * Cloud Storage, using Okta as the identity provider. + */ +public class CustomCredentialSupplierOktaWorkload { + + public static void main(String[] args) { + // TODO(Developer): Replace these variables with your actual values. + + // 1. GCP_WORKLOAD_AUDIENCE: + // The audience for the workload identity federation. This is the full resource name of the + // Workload Identity Pool Provider, in the following format: + // `//iam.googleapis.com/projects//locations/global/workloadIdentityPools//providers/` + String gcpWorkloadAudience = System.getenv("GCP_WORKLOAD_AUDIENCE"); + + // 2. GCP_SERVICE_ACCOUNT_IMPERSONATION_URL (optional): + // The service account impersonation URL. In the following format: + // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/:generateAccessToken + // If not provided, you should grant access to the GCP bucket to the principal directly. + String serviceAccountImpersonationUrl = System.getenv("GCP_SERVICE_ACCOUNT_IMPERSONATION_URL"); + + // 3. GCS_BUCKET_NAME: + // The name of the bucket that you wish to fetch data for. + String gcsBucketName = System.getenv("GCS_BUCKET_NAME"); + + // 4. Okta Configuration: + // To set up the Okta application for this flow, refer: + // https://developer.okta.com/docs/guides/implement-grant-type/clientcreds/main/ + // https://developer.okta.com/docs/guides/customize-authz-server/main/ + // + // Steps: + // a. In your Okta developer console, create a new Application of type "Machine-to-Machine + // (M2M)". + // b. Under the "General" tab, ensure that "Client Credentials" is an allowed grant type. + // c. Note the "Client ID" and "Client Secret" for your application. + // d. Navigate to "Security" > "API" and select your authorization server (e.g., "default"). + // e. Under the "Scopes" tab, add a custom scope (e.g., "gcp.test.read"). + // f. Ensure your M2M application is granted this scope. + // + // OKTA_DOMAIN: Your Okta organization URL (e.g., https://{yourOktaDomain}.okta.com) + String oktaDomain = System.getenv("OKTA_DOMAIN"); + // OKTA_CLIENT_ID: The Client ID of your Okta M2M application. + String oktaClientId = System.getenv("OKTA_CLIENT_ID"); + // OKTA_CLIENT_SECRET: The Client Secret of your Okta M2M application. + String oktaClientSecret = System.getenv("OKTA_CLIENT_SECRET"); + + if (gcpWorkloadAudience == null + || gcsBucketName == null + || oktaDomain == null + || oktaClientId == null + || oktaClientSecret == null) { + System.out.println( + "Missing required environment variables. Please check your environment settings. " + + "Required: GCP_WORKLOAD_AUDIENCE, " + + "GCS_BUCKET_NAME, OKTA_DOMAIN, OKTA_CLIENT_ID, OKTA_CLIENT_SECRET"); + return; + } + + customCredentialSupplierOktaWorkload( + gcpWorkloadAudience, + serviceAccountImpersonationUrl, + gcsBucketName, + oktaDomain, + oktaClientId, + oktaClientSecret); + } + + public static void customCredentialSupplierOktaWorkload( + String gcpWorkloadAudience, + String serviceAccountImpersonationUrl, + String gcsBucketName, + String oktaDomain, + String oktaClientId, + String oktaClientSecret) { + // 1. Instantiate our custom supplier with Okta credentials. + OktaClientCredentialsSupplier oktaSupplier = + new OktaClientCredentialsSupplier(oktaDomain, oktaClientId, oktaClientSecret); + + // 2. Instantiate an IdentityPoolCredentials with the required configuration. + IdentityPoolCredentials.Builder credentialsBuilder = + IdentityPoolCredentials.newBuilder() + .setAudience(gcpWorkloadAudience) + // This token type indicates that the subject token is a JSON Web Token (JWT). + // This is required for Workload Identity Federation with an OIDC provider like Okta. + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setSubjectTokenSupplier(oktaSupplier); + + if (serviceAccountImpersonationUrl != null) { + credentialsBuilder.setServiceAccountImpersonationUrl(serviceAccountImpersonationUrl); + } + + GoogleCredentials credentials = credentialsBuilder.build(); + + // 3. Use the credentials to make an authenticated request. + Storage storage = StorageOptions.newBuilder().setCredentials(credentials).build().getService(); + + System.out.println("[Test] Getting metadata for bucket: " + gcsBucketName + "..."); + Bucket bucket = storage.get(gcsBucketName); + System.out.println(" --- SUCCESS! ---"); + System.out.println("Successfully authenticated and retrieved bucket data:"); + System.out.println(bucket.toString()); + } + + /** + * A custom SubjectTokenSupplier that authenticates with Okta using the Client Credentials grant + * flow. + */ + private static class OktaClientCredentialsSupplier implements IdentityPoolSubjectTokenSupplier { + + private static final long TOKEN_REFRESH_BUFFER_SECONDS = 60; + + private final String oktaTokenUrl; + private final String clientId; + private final String clientSecret; + private String accessToken; + private Instant expiryTime; + + public OktaClientCredentialsSupplier(String domain, String clientId, String clientSecret) { + this.oktaTokenUrl = domain + "/oauth2/default/v1/token"; + this.clientId = clientId; + this.clientSecret = clientSecret; + System.out.println("OktaClientCredentialsSupplier initialized."); + } + + /** + * Main method called by the auth library. It will fetch a new token if one is not already + * cached. + */ + @Override + public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { + // Check if the current token is still valid (with a 60-second buffer). + boolean isTokenValid = + this.accessToken != null + && Instant.now().isBefore(this.expiryTime.minusSeconds(TOKEN_REFRESH_BUFFER_SECONDS)); + + if (isTokenValid) { + System.out.println("[Supplier] Returning cached Okta Access token."); + return this.accessToken; + } + + System.out.println( + "[Supplier] Token is missing or expired. Fetching new Okta Access token via Client " + + "Credentials grant..."); + fetchOktaAccessToken(); + return this.accessToken; + } + + /** + * Performs the Client Credentials grant flow by making a POST request to Okta's token endpoint. + */ + private void fetchOktaAccessToken() throws IOException { + URL url = new URL(this.oktaTokenUrl); + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + + // The client_id and client_secret are sent in a Basic Auth header, as required by the + // OAuth 2.0 Client Credentials grant specification. The credentials are Base64 encoded. + String auth = this.clientId + ":" + this.clientSecret; + String encodedAuth = + Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + conn.setRequestProperty("Authorization", "Basic " + encodedAuth); + + conn.setDoOutput(true); + try (DataOutputStream out = new DataOutputStream(conn.getOutputStream())) { + // For the Client Credentials grant, scopes are optional and define the permissions + // the access token will have. Replace "gcp.test.read" with the scopes defined in your + // Okta authorization server. Multiple scopes can be requested by space-separating them. + // In application/x-www-form-urlencoded, a space is represented by '+' or '%20'. + // e.g., "scope1%20scope2" or "scope1+scope2". + String params = "grant_type=client_credentials&scope=gcp.test.read%20gcp.bucket.read"; + out.writeBytes(params); + out.flush(); + } + + int responseCode = conn.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + try (BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = in.readLine()) != null) { + response.append(line); + } + + GenericJson jsonObject = + GsonFactory.getDefaultInstance() + .createJsonParser(response.toString()) + .parse(GenericJson.class); + + if (jsonObject.containsKey("access_token") && jsonObject.containsKey("expires_in")) { + this.accessToken = (String) jsonObject.get("access_token"); + Number expiresInNumber = (Number) jsonObject.get("expires_in"); + int expiresIn = expiresInNumber.intValue(); + this.expiryTime = Instant.now().plusSeconds(expiresIn); + System.out.println( + "[Supplier] Successfully received Access Token from Okta. Expires in " + + expiresIn + + " seconds."); + } else { + throw new IOException("Access token or expires_in not found in Okta response."); + } + } + } else { + throw new IOException( + "Failed to authenticate with Okta using Client Credentials grant. Response code: " + + responseCode); + } + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + } +}