diff --git a/.eslintrc.json b/.eslintrc.json index 7e5a1dd078..44510807ab 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,6 +12,7 @@ "node/no-unsupported-features/es-syntax": ["off"] }, "parserOptions": { + "ecmaVersion": 2020, "sourceType": "module" } } diff --git a/auth/.eslintrc.json b/auth/.eslintrc.json new file mode 100644 index 0000000000..95f214a816 --- /dev/null +++ b/auth/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": "../.eslintrc.json", + "rules": { + "no-unused-vars": "off" + } +} diff --git a/auth/README.md b/auth/README.md index 636015860f..a08eb49384 100644 --- a/auth/README.md +++ b/auth/README.md @@ -60,8 +60,79 @@ information](https://developers.google.com/identity/protocols/application-defaul $ npm run test:downscoping +## Custom Credential Suppliers + +If you want to use external credentials (like AWS or Okta) that require custom retrieval logic not supported natively by the library, you can provide a custom supplier implementation. + +### Custom AWS Credential Supplier + +This sample demonstrates how to use the AWS SDK for Node.js as a custom `AwsSecurityCredentialsSupplier` to bridge AWS credentials—from sources like EKS IRSA, ECS, or local profiles—to Google Cloud Workload Identity. + +#### 1. Set Environment Variables + +```bash +# AWS Credentials (or use ~/.aws/credentials) +export AWS_ACCESS_KEY_ID="YOUR_AWS_ACCESS_KEY_ID" +export AWS_SECRET_ACCESS_KEY="YOUR_AWS_SECRET_ACCESS_KEY" +export AWS_REGION="us-east-1" + +# Google Cloud Config +# Format: //iam.googleapis.com/projects//locations/global/workloadIdentityPools//providers/ +export GCP_WORKLOAD_AUDIENCE="//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-aws-provider" +export GCS_BUCKET_NAME="your-bucket-name" + +# Optional: Service Account Impersonation +# export GCP_SERVICE_ACCOUNT_IMPERSONATION_URL="https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/my-sa@my-project.iam.gserviceaccount.com:generateAccessToken" +``` + +#### 2. Run the Sample + +```bash +node custom-credential-supplier-aws.js +``` + +#### Running in Kubernetes (EKS) + +To run this in an EKS cluster using IAM Roles for Service Accounts (IRSA): + +1. **Configure IRSA:** Associate an AWS IAM Role with your Kubernetes Service Account. +2. **Configure GCP:** Allow the AWS IAM Role ARN to impersonate your Workload Identity Pool. +3. **Deploy:** When deploying your Node.js application, ensure the Pod uses the annotated Service Account. The AWS SDK in the sample will automatically detect the credentials injected by the EKS OIDC webhook. + +--- + +### Custom Okta Credential Supplier + +This sample demonstrates how to use a custom `SubjectTokenSupplier` to fetch an OIDC token from **Okta** using the Client Credentials flow and exchange it for Google Cloud credentials via Workload Identity Federation. + +#### 1. Okta Configuration + +Ensure you have an Okta Machine-to-Machine (M2M) application set up with "Client Credentials" grant type enabled. You will need the Domain, Client ID, and Client Secret. + +#### 2. Set Environment Variables + +```bash +# Okta Configuration +export OKTA_DOMAIN="https://your-okta-domain.okta.com" +export OKTA_CLIENT_ID="your-okta-client-id" +export OKTA_CLIENT_SECRET="your-okta-client-secret" + +# Google Cloud Config +export GCP_WORKLOAD_AUDIENCE="//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-oidc-provider" +export GCS_BUCKET_NAME="your-bucket-name" + +# Optional: Service Account Impersonation +# export GCP_SERVICE_ACCOUNT_IMPERSONATION_URL="https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/my-sa@my-project.iam.gserviceaccount.com:generateAccessToken" +``` + +#### 3. Run the Sample + +```bash +node custom-credential-supplier-okta.js +``` + ### Additional resources For more information on downscoped credentials you can visit: -> https://github.com/googleapis/google-auth-library-nodejs \ No newline at end of file +> https://github.com/googleapis/google-auth-library-nodejs diff --git a/auth/customCredentialSupplierAws.js b/auth/customCredentialSupplierAws.js new file mode 100644 index 0000000000..8662f16708 --- /dev/null +++ b/auth/customCredentialSupplierAws.js @@ -0,0 +1,150 @@ +// 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. + +// [START auth_custom_credential_supplier_aws] +const {AwsClient} = require('google-auth-library'); +const {fromNodeProviderChain} = require('@aws-sdk/credential-providers'); +const {STSClient} = require('@aws-sdk/client-sts'); + +/** + * Custom AWS Security Credentials Supplier. + * + * This implementation resolves AWS credentials using the default Node 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. + */ +class CustomAwsSupplier { + constructor() { + // Will be cached upon first resolution. + this.region = null; + + // Initialize the AWS credential provider. + // The AWS SDK handles memoization (caching) and proactive refreshing internally. + this.awsCredentialsProvider = fromNodeProviderChain(); + } + + /** + * 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.). + */ + async getAwsRegion(_context) { + if (this.region) { + return this.region; + } + + const client = new STSClient({}); + this.region = await client.config.region(); + + if (!this.region) { + throw new Error( + '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. + */ + async getAwsSecurityCredentials(_context) { + // Call the initialized provider. It will return cached creds or refresh if needed. + const awsCredentials = await this.awsCredentialsProvider(); + + if (!awsCredentials.accessKeyId || !awsCredentials.secretAccessKey) { + throw new Error( + 'Unable to resolve AWS credentials from the node provider chain. ' + + 'Ensure your AWS CLI is configured, or AWS environment variables (like AWS_ACCESS_KEY_ID) are set.' + ); + } + + // Map the AWS SDK format to the google-auth-library format. + return { + accessKeyId: awsCredentials.accessKeyId, + secretAccessKey: awsCredentials.secretAccessKey, + token: awsCredentials.sessionToken, + }; + } +} + +/** + * Authenticates with Google Cloud using AWS credentials and retrieves bucket metadata. + * + * @param {string} bucketName The name of the bucket to retrieve. + * @param {string} audience The Workload Identity Pool audience. + * @param {string} [impersonationUrl] Optional Service Account impersonation URL. + */ +async function authenticateWithAwsCredentials( + bucketName, + audience, + impersonationUrl +) { + // 1. Instantiate the custom supplier. + const customSupplier = new CustomAwsSupplier(); + + // 2. Configure the AwsClient options. + const clientOptions = { + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + service_account_impersonation_url: impersonationUrl, + aws_security_credentials_supplier: customSupplier, + }; + + // 3. Create the auth client + const client = new AwsClient(clientOptions); + + // 4. Make an authenticated request to GCS. + const bucketUrl = `https://storage.googleapis.com/storage/v1/b/${bucketName}`; + const res = await client.request({url: bucketUrl}); + return res.data; +} +// [END auth_custom_credential_supplier_aws] + +async function main() { + require('dotenv').config(); + const gcpAudience = process.env.GCP_WORKLOAD_AUDIENCE; + const saImpersonationUrl = process.env.GCP_SERVICE_ACCOUNT_IMPERSONATION_URL; + const gcsBucketName = process.env.GCS_BUCKET_NAME; + + if (!gcpAudience || !gcsBucketName) { + throw new Error( + 'Missing required environment variables: GCP_WORKLOAD_AUDIENCE, GCS_BUCKET_NAME' + ); + } + + try { + console.log(`Retrieving metadata for bucket: ${gcsBucketName}...`); + const bucketMetadata = await authenticateWithAwsCredentials( + gcsBucketName, + gcpAudience, + saImpersonationUrl + ); + console.log('\n--- SUCCESS! ---'); + console.log('Bucket Name:', bucketMetadata.name); + console.log('Bucket Location:', bucketMetadata.location); + } catch (error) { + console.error('\n--- FAILED ---'); + console.error(error.response?.data || error); + process.exitCode = 1; + } +} + +if (require.main === module) { + main(); +} + +exports.authenticateWithAwsCredentials = authenticateWithAwsCredentials; diff --git a/auth/customCredentialSupplierOkta.js b/auth/customCredentialSupplierOkta.js new file mode 100644 index 0000000000..17d4609b96 --- /dev/null +++ b/auth/customCredentialSupplierOkta.js @@ -0,0 +1,176 @@ +// 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. + +// [START auth_custom_credential_supplier_okta] +const {IdentityPoolClient} = require('google-auth-library'); +const {Gaxios} = require('gaxios'); + +/** + * A custom SubjectTokenSupplier that authenticates with Okta using the + * Client Credentials grant flow. + */ +class OktaClientCredentialsSupplier { + constructor(domain, clientId, clientSecret) { + this.oktaTokenUrl = `${domain}/oauth2/default/v1/token`; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.accessToken = null; + this.expiryTime = 0; + this.gaxios = new Gaxios(); + } + + /** + * Main method called by the auth library. It will fetch a new token if one + * is not already cached. + * @returns {Promise} A promise that resolves with the Okta Access token. + */ + async getSubjectToken() { + // Check if the current token is still valid (with a 60-second buffer). + const isTokenValid = + this.accessToken && Date.now() < this.expiryTime - 60 * 1000; + + if (isTokenValid) { + return this.accessToken; + } + + const {accessToken, expiresIn} = await this.fetchOktaAccessToken(); + this.accessToken = accessToken; + this.expiryTime = Date.now() + expiresIn * 1000; + return this.accessToken; + } + + /** + * Performs the Client Credentials grant flow with Okta. + */ + async fetchOktaAccessToken() { + const params = new URLSearchParams(); + params.append('grant_type', 'client_credentials'); + params.append('scope', 'gcp.test.read'); + + const authHeader = + 'Basic ' + + Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'); + + try { + const response = await this.gaxios.request({ + url: this.oktaTokenUrl, + method: 'POST', + headers: { + Authorization: authHeader, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: params.toString(), + }); + + const {access_token, expires_in} = response.data; + if (access_token && expires_in) { + return {accessToken: access_token, expiresIn: expires_in}; + } else { + throw new Error( + 'Access token or expires_in not found in Okta response.' + ); + } + } catch (error) { + throw new Error( + `Failed to authenticate with Okta: ${error.response?.data || error.message}` + ); + } + } +} + +/** + * Authenticates with Google Cloud using Okta credentials and retrieves bucket metadata. + * + * @param {string} bucketName The name of the bucket to retrieve. + * @param {string} audience The Workload Identity Pool audience. + * @param {string} domain The Okta domain. + * @param {string} clientId The Okta client ID. + * @param {string} clientSecret The Okta client secret. + * @param {string} [impersonationUrl] Optional Service Account impersonation URL. + */ +async function authenticateWithOktaCredentials( + bucketName, + audience, + domain, + clientId, + clientSecret, + impersonationUrl +) { + // 1. Instantiate the custom supplier. + const oktaSupplier = new OktaClientCredentialsSupplier( + domain, + clientId, + clientSecret + ); + + // 2. Instantiate an IdentityPoolClient with the required configuration. + const client = new IdentityPoolClient({ + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + subject_token_supplier: oktaSupplier, + service_account_impersonation_url: impersonationUrl, + }); + + // 3. Make an authenticated request. + const bucketUrl = `https://storage.googleapis.com/storage/v1/b/${bucketName}`; + const res = await client.request({url: bucketUrl}); + return res.data; +} +// [END auth_custom_credential_supplier_okta] + +async function main() { + require('dotenv').config(); + const gcpAudience = process.env.GCP_WORKLOAD_AUDIENCE; + const saImpersonationUrl = process.env.GCP_SERVICE_ACCOUNT_IMPERSONATION_URL; + const gcsBucketName = process.env.GCS_BUCKET_NAME; + const oktaDomain = process.env.OKTA_DOMAIN; + const oktaClientId = process.env.OKTA_CLIENT_ID; + const oktaClientSecret = process.env.OKTA_CLIENT_SECRET; + + if ( + !gcpAudience || + !gcsBucketName || + !oktaDomain || + !oktaClientId || + !oktaClientSecret + ) { + throw new Error('Missing required environment variables for Okta/GCP.'); + } + + try { + console.log(`Retrieving metadata for bucket: ${gcsBucketName}...`); + const bucketMetadata = await authenticateWithOktaCredentials( + gcsBucketName, + gcpAudience, + oktaDomain, + oktaClientId, + oktaClientSecret, + saImpersonationUrl + ); + console.log('\n--- SUCCESS! ---'); + console.log('Bucket Name:', bucketMetadata.name); + console.log('Bucket Location:', bucketMetadata.location); + } catch (error) { + console.error('\n--- FAILED ---'); + console.error(error.response?.data || error); + process.exitCode = 1; + } +} + +if (require.main === module) { + main(); +} + +exports.authenticateWithOktaCredentials = authenticateWithOktaCredentials; diff --git a/auth/package.json b/auth/package.json index 71988b2b5f..3826539aa9 100644 --- a/auth/package.json +++ b/auth/package.json @@ -19,6 +19,10 @@ "system-test": "c8 mocha -p -j 2 system-test/*.test.js --timeout=30000" }, "dependencies": { + "@aws-sdk/client-sts": "^3.58.0", + "@aws-sdk/credential-providers": "^3.0.0", + "dotenv": "^17.0.0", + "gaxios": "^6.0.0", "@google-cloud/storage": "^7.0.0", "fix": "0.0.6", "google-auth-library": "^9.0.0", diff --git a/auth/system-test/customCredentialSupplierAws.test.js b/auth/system-test/customCredentialSupplierAws.test.js new file mode 100644 index 0000000000..cba05aff03 --- /dev/null +++ b/auth/system-test/customCredentialSupplierAws.test.js @@ -0,0 +1,48 @@ +// 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. + +'use strict'; + +const assert = require('assert'); +const { + authenticateWithAwsCredentials, +} = require('../customCredentialSupplierAws'); + +describe('Custom Credential Supplier AWS', () => { + const audience = process.env.GCP_WORKLOAD_AUDIENCE; + const bucketName = process.env.GCS_BUCKET_NAME; + const impersonationUrl = process.env.GCP_SERVICE_ACCOUNT_IMPERSONATION_URL; + + it('should authenticate using AWS credentials', async function () { + // Skip system tests if required environment variables are missing + if ( + !process.env.AWS_ACCESS_KEY_ID || + !process.env.AWS_SECRET_ACCESS_KEY || + !process.env.AWS_REGION || + !audience || + !bucketName + ) { + this.skip(); + } + + const metadata = await authenticateWithAwsCredentials( + bucketName, + audience, + impersonationUrl + ); + + assert.strictEqual(metadata.name, bucketName); + assert.ok(metadata.location); + }); +}); diff --git a/auth/system-test/customCredentialSupplierOkta.test.js b/auth/system-test/customCredentialSupplierOkta.test.js new file mode 100644 index 0000000000..244f5a87a5 --- /dev/null +++ b/auth/system-test/customCredentialSupplierOkta.test.js @@ -0,0 +1,51 @@ +// 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. + +'use strict'; + +const assert = require('assert'); +const { + authenticateWithOktaCredentials, +} = require('../customCredentialSupplierOkta'); + +describe('Custom Credential Supplier Okta', () => { + const audience = process.env.GCP_WORKLOAD_AUDIENCE; + const bucketName = process.env.GCS_BUCKET_NAME; + const impersonationUrl = process.env.GCP_SERVICE_ACCOUNT_IMPERSONATION_URL; + + it('should authenticate using Okta credentials', async function () { + // Skip system tests if required environment variables are missing + if ( + !process.env.OKTA_DOMAIN || + !process.env.OKTA_CLIENT_ID || + !process.env.OKTA_CLIENT_SECRET || + !audience || + !bucketName + ) { + this.skip(); + } + + const metadata = await authenticateWithOktaCredentials( + bucketName, + audience, + process.env.OKTA_DOMAIN, + process.env.OKTA_CLIENT_ID, + process.env.OKTA_CLIENT_SECRET, + impersonationUrl + ); + + assert.strictEqual(metadata.name, bucketName); + assert.ok(metadata.location); + }); +});