Skip to content

Commit fce828c

Browse files
authored
chore: Custom Credential Supplier Documentation (#2132)
* chore: Custom Credential Supplier Documentation * Made some comment changes. * Included changes for Client Credentials Grant for Okta workforce. * Changed AwsWorkload to include caching. * Lint and package.json changes * Impersonation not needed. * Included changes to get region from sdk for customCredentialSupplierAwsWorkload. Got rid of samples/readme changes. * Removed customCredentialSupplier for workload. * Implemented OktaWorkload with client credential grant flow. AWS now leverages AWS-SDK level caching. * Reverted back to using gaxios to fetch okta access token
1 parent 1d9054a commit fce828c

File tree

5 files changed

+329
-1
lines changed

5 files changed

+329
-1
lines changed

.readme-partials.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,8 @@ body: |-
379379
380380
Note that the client does not cache the returned AWS security credentials, so caching logic should be implemented in the supplier to prevent multiple requests for the same resources.
381381
382+
For a sample on how to access Google Cloud resources from AWS with a custom credential supplier, see [samples/customCredentialSupplierAwsWorkload.js](https://github.com/googleapis/google-auth-library-nodejs/blob/main/samples/customCredentialSupplierAwsWorkload.js).
383+
382384
```ts
383385
import { AwsClient, AwsSecurityCredentials, AwsSecurityCredentialsSupplier, ExternalAccountSupplierContext } from 'google-auth-library';
384386
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
@@ -1059,6 +1061,8 @@ body: |-
10591061
10601062
The values for audience, service account impersonation URL, and any other builder field can also be found by generating a [credential configuration file with the gcloud CLI](https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#use_configuration_files_for_sign-in).
10611063
1064+
For a sample on how to access Google Cloud resources from an Okta identity provider with a custom credential supplier, see [samples/customCredentialSupplierOktaWorkforce.js](https://github.com/googleapis/google-auth-library-nodejs/blob/main/samples/customCredentialSupplierOktaWorkforce.js).
1065+
10621066
### Using External Identities
10631067
10641068
External identities (AWS, Azure and OIDC-based providers) can be used with `Application Default Credentials`.

samples/.eslintrc.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
---
2+
parserOptions:
3+
ecmaVersion: 2023
24
rules:
35
no-console: off
46
node/no-missing-require: off
57
node/no-unpublished-require: off
8+
no-unused-vars:
9+
- error
10+
- argsIgnorePattern: '^_'
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright 2025 Google LLC
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
('use strict');
15+
require('dotenv').config();
16+
17+
const {AwsClient} = require('google-auth-library');
18+
const {fromNodeProviderChain} = require('@aws-sdk/credential-providers');
19+
const {STSClient} = require('@aws-sdk/client-sts');
20+
21+
/**
22+
* Custom AWS Security Credentials Supplier.
23+
*
24+
* This implementation resolves AWS credentials using the default Node provider
25+
* chain from the AWS SDK. This allows fetching credentials from environment
26+
* variables, shared credential files (~/.aws/credentials), or IAM roles
27+
* for service accounts (IRSA) in EKS, etc.
28+
*/
29+
class CustomAwsSupplier {
30+
constructor() {
31+
// Will be cached upon first resolution.
32+
this.region = null;
33+
34+
// Initialize the AWS credential provider.
35+
// The AWS SDK handles memoization (caching) and proactive refreshing internally.
36+
this.awsCredentialsProvider = fromNodeProviderChain();
37+
}
38+
39+
/**
40+
* Returns the AWS region. This is required for signing the AWS request.
41+
* It resolves the region automatically by using the default AWS region
42+
* provider chain, which searches for the region in the standard locations
43+
* (environment variables, AWS config file, etc.).
44+
*/
45+
async getAwsRegion(_context) {
46+
if (this.region) {
47+
return this.region;
48+
}
49+
50+
const client = new STSClient({});
51+
this.region = await client.config.region();
52+
53+
if (!this.region) {
54+
throw new Error(
55+
'CustomAwsSupplier: Unable to resolve AWS region. Please set the AWS_REGION environment variable or configure it in your ~/.aws/config file.',
56+
);
57+
}
58+
59+
return this.region;
60+
}
61+
62+
/**
63+
* Retrieves AWS security credentials using the AWS SDK's default provider chain.
64+
*/
65+
async getAwsSecurityCredentials(_context) {
66+
// Call the initialized provider. It will return cached creds or refresh if needed.
67+
const awsCredentials = await this.awsCredentialsProvider();
68+
69+
// This check is often redundant as the SDK provider throws on failure,
70+
// but serves as an extra safeguard.
71+
if (!awsCredentials.accessKeyId || !awsCredentials.secretAccessKey) {
72+
throw new Error(
73+
'Unable to resolve AWS credentials from the node provider chain. ' +
74+
'Ensure your AWS CLI is configured, or AWS environment variables (like AWS_ACCESS_KEY_ID) are set.',
75+
);
76+
}
77+
78+
// Map the AWS SDK format to the google-auth-library format.
79+
const awsSecurityCredentials = {
80+
accessKeyId: awsCredentials.accessKeyId,
81+
secretAccessKey: awsCredentials.secretAccessKey,
82+
token: awsCredentials.sessionToken,
83+
};
84+
85+
return awsSecurityCredentials;
86+
}
87+
}
88+
89+
async function main() {
90+
const gcpAudience = process.env.GCP_WORKLOAD_AUDIENCE;
91+
const saImpersonationUrl = process.env.GCP_SERVICE_ACCOUNT_IMPERSONATION_URL;
92+
const gcsBucketName = process.env.GCS_BUCKET_NAME;
93+
94+
if (!gcpAudience || !saImpersonationUrl || !gcsBucketName) {
95+
throw new Error(
96+
'Missing required environment variables. Please check your .env file or environment settings. Required: GCP_WORKLOAD_AUDIENCE, GCP_SERVICE_ACCOUNT_IMPERSONATION_URL, GCS_BUCKET_NAME',
97+
);
98+
}
99+
100+
// 1. Instantiate the custom supplier.
101+
const customSupplier = new CustomAwsSupplier();
102+
103+
// 2. Configure the AwsClient options using the constants.
104+
const clientOptions = {
105+
audience: gcpAudience,
106+
subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request',
107+
service_account_impersonation_url: saImpersonationUrl,
108+
aws_security_credentials_supplier: customSupplier,
109+
};
110+
111+
// 3. Create the auth client
112+
const client = new AwsClient(clientOptions);
113+
114+
// 4. Construct the URL for the Cloud Storage JSON API to get bucket metadata.
115+
116+
const bucketUrl = `https://storage.googleapis.com/storage/v1/b/${gcsBucketName}`;
117+
console.log(`[Test] Getting metadata for bucket: ${gcsBucketName}...`);
118+
console.log(`[Test] Request URL: ${bucketUrl}`);
119+
120+
// 5. Use the client to make an authenticated request.
121+
const res = await client.request({url: bucketUrl});
122+
123+
console.log('\n--- SUCCESS! ---');
124+
console.log('Successfully authenticated and retrieved bucket data:');
125+
console.log(JSON.stringify(res.data, null, 2));
126+
}
127+
128+
// Execute the test.
129+
main().catch(error => {
130+
console.error('\n--- FAILED ---');
131+
const fullError = error.response?.data || error;
132+
console.error(JSON.stringify(fullError, null, 2));
133+
process.exitCode = 1;
134+
});
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright 2025 Google LLC
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
'use strict';
15+
16+
const {IdentityPoolClient} = require('google-auth-library');
17+
const {Gaxios} = require('gaxios');
18+
require('dotenv').config();
19+
20+
// Workload Identity Pool Configuration
21+
const gcpWorkloadAudience = process.env.GCP_WORKLOAD_AUDIENCE;
22+
const serviceAccountImpersonationUrl =
23+
process.env.GCP_SERVICE_ACCOUNT_IMPERSONATION_URL;
24+
const gcsBucketName = process.env.GCS_BUCKET_NAME;
25+
26+
// Okta Configuration
27+
const oktaDomain = process.env.OKTA_DOMAIN; // e.g., 'https://dev-12345.okta.com'
28+
const oktaClientId = process.env.OKTA_CLIENT_ID; // The Client ID of your Okta M2M application
29+
const oktaClientSecret = process.env.OKTA_CLIENT_SECRET; // The Client Secret of your Okta M2M application
30+
31+
// Constants for the authentication flow
32+
const TOKEN_URL = 'https://sts.googleapis.com/v1/token';
33+
const SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:jwt';
34+
35+
/**
36+
* A custom SubjectTokenSupplier that authenticates with Okta using the
37+
* Client Credentials grant flow.
38+
*
39+
* This flow is designed for machine-to-machine (M2M) authentication and
40+
* exchanges the application'''s client_id and client_secret for an access token.
41+
*/
42+
class OktaClientCredentialsSupplier {
43+
constructor(domain, clientId, clientSecret) {
44+
this.oktaTokenUrl = `${domain}/oauth2/default/v1/token`;
45+
this.clientId = clientId;
46+
this.clientSecret = clientSecret;
47+
this.accessToken = null;
48+
this.expiryTime = 0;
49+
this.gaxios = new Gaxios();
50+
console.log('OktaClientCredentialsSupplier initialized.');
51+
}
52+
53+
/**
54+
* Main method called by the auth library. It will fetch a new token if one
55+
* is not already cached.
56+
* @returns {Promise<string>} A promise that resolves with the Okta Access token.
57+
*/
58+
async getSubjectToken() {
59+
// Check if the current token is still valid (with a 60-second buffer).
60+
const isTokenValid =
61+
this.accessToken && Date.now() < this.expiryTime - 60 * 1000;
62+
63+
if (isTokenValid) {
64+
console.log('[Supplier] Returning cached Okta Access token.');
65+
return this.accessToken;
66+
}
67+
68+
console.log(
69+
'[Supplier] Token is missing or expired. Fetching new Okta Access token via Client Credentials grant...',
70+
);
71+
const {accessToken, expiresIn} = await this.fetchOktaAccessToken();
72+
this.accessToken = accessToken;
73+
// Calculate the absolute expiry time in milliseconds.
74+
this.expiryTime = Date.now() + expiresIn * 1000;
75+
return this.accessToken;
76+
}
77+
78+
/**
79+
* Performs the Client Credentials grant flow by making a POST request to Okta'''s token endpoint.
80+
* @returns {Promise<{accessToken: string, expiresIn: number}>} A promise that resolves with the Access Token and expiry from Okta.
81+
*/
82+
async fetchOktaAccessToken() {
83+
const params = new URLSearchParams();
84+
params.append('grant_type', 'client_credentials');
85+
86+
// For Client Credentials, scopes are optional and define the permissions
87+
// the token will have. If you have custom scopes, add them here.
88+
params.append('scope', 'gcp.test.read');
89+
90+
// The client_id and client_secret are sent in a Basic Auth header.
91+
const authHeader =
92+
'Basic ' +
93+
Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
94+
95+
try {
96+
const response = await this.gaxios.request({
97+
url: this.oktaTokenUrl,
98+
method: 'POST',
99+
headers: {
100+
Authorization: authHeader,
101+
'Content-Type': 'application/x-www-form-urlencoded',
102+
},
103+
data: params.toString(),
104+
});
105+
106+
const {access_token, expires_in} = response.data;
107+
108+
if (access_token && expires_in) {
109+
console.log(
110+
`[Supplier] Successfully received Access Token from Okta. Expires in ${expires_in} seconds.`,
111+
);
112+
return {accessToken: access_token, expiresIn: expires_in};
113+
} else {
114+
throw new Error(
115+
'Access token or expires_in not found in Okta response.',
116+
);
117+
}
118+
} catch (error) {
119+
console.error(
120+
'[Supplier] Error fetching token from Okta:',
121+
error.response?.data || error.message,
122+
);
123+
throw new Error(
124+
'Failed to authenticate with Okta using Client Credentials grant.',
125+
);
126+
}
127+
}
128+
}
129+
130+
/**
131+
* Main function to demonstrate the custom supplier.
132+
*/
133+
async function main() {
134+
if (
135+
!gcpWorkloadAudience ||
136+
!gcsBucketName ||
137+
!oktaDomain ||
138+
!oktaClientId ||
139+
!oktaClientSecret
140+
) {
141+
throw new Error(
142+
'Missing required environment variables. Please check your .env file.',
143+
);
144+
}
145+
146+
// 1. Instantiate our custom supplier with Okta credentials.
147+
const oktaSupplier = new OktaClientCredentialsSupplier(
148+
oktaDomain,
149+
oktaClientId,
150+
oktaClientSecret,
151+
);
152+
153+
// 2. Instantiate an IdentityPoolClient directly with the required configuration.
154+
// This client is specialized for workload identity federation flows.
155+
const client = new IdentityPoolClient({
156+
audience: gcpWorkloadAudience,
157+
subject_token_type: SUBJECT_TOKEN_TYPE,
158+
token_url: TOKEN_URL,
159+
subject_token_supplier: oktaSupplier,
160+
service_account_impersonation_url: serviceAccountImpersonationUrl,
161+
});
162+
163+
// 3. Construct the URL for the Cloud Storage JSON API to get bucket metadata.
164+
const bucketUrl = `https://storage.googleapis.com/storage/v1/b/${gcsBucketName}`;
165+
console.log(`[Test] Getting metadata for bucket: ${gcsBucketName}...`);
166+
console.log(`[Test] Request URL: ${bucketUrl}`);
167+
168+
// 4. Use the client to make an authenticated request.
169+
const res = await client.request({url: bucketUrl});
170+
171+
console.log('--- SUCCESS! ---');
172+
console.log('Successfully authenticated and retrieved bucket data:');
173+
console.log(JSON.stringify(res.data, null, 2));
174+
}
175+
176+
main().catch(error => {
177+
console.error('--- FAILED ---');
178+
const fullError = error.response?.data || error;
179+
console.error(JSON.stringify(fullError, null, 2));
180+
process.exitCode = 1;
181+
});

samples/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@
1515
"dependencies": {
1616
"@google-cloud/language": "^7.0.0",
1717
"@google-cloud/storage": "^7.0.0",
18+
"@aws-sdk/credential-providers": "^3.58.0",
1819
"@googleapis/iam": "^32.0.0",
1920
"google-auth-library": "^10.4.0",
21+
"dotenv": "^16.3.1",
22+
"gaxios": "^7.0.0",
2023
"node-fetch": "^2.3.0",
2124
"open": "^9.0.0",
22-
"server-destroy": "^1.0.1"
25+
"server-destroy": "^1.0.1",
26+
"@aws-sdk/client-sts": "^3.58.0"
2327
},
2428
"devDependencies": {
2529
"chai": "^4.2.0",

0 commit comments

Comments
 (0)