Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Request Revamp #1938

Merged
merged 11 commits into from
Feb 15, 2025
2 changes: 1 addition & 1 deletion .readme-partials.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1185,7 +1185,7 @@ body: |-

// Get impersonated credentials:
const authHeaders = await targetClient.getRequestHeaders();
// Do something with `authHeaders.Authorization`.
// Do something with `authHeaders.get('Authorization')`.

// Use impersonated credentials:
const url = 'https://www.googleapis.com/storage/v1/b?project=anotherProjectID'
Expand Down
4 changes: 2 additions & 2 deletions browser-test/test.oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import {CertificateFormat} from '../src/auth/oauth2client';
import {JwkCertificate} from '../src/crypto/crypto';

const CLIENT_ID = 'CLIENT_ID';

Check failure

Code scanning / CodeQL

Hard-coded credentials Critical test

The hard-coded value "CLIENT_ID" is used as
authorization header
.
const CLIENT_SECRET = 'CLIENT_SECRET';
const REDIRECT_URI = 'REDIRECT';
const ACCESS_TYPE = 'offline';
Expand All @@ -46,10 +46,10 @@
},
];
const FEDERATED_SIGNON_JWK_CERTS_AXIOS_RESPONSE = {
headers: {
headers: new Headers({
'cache-control':
'cache-control: public, max-age=24000, must-revalidate, no-transform',
},
}),
data: {keys: FEDERATED_SIGNON_JWK_CERTS},
};

Expand Down
11 changes: 5 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^6.1.1",
"gcp-metadata": "^6.1.0",
"gtoken": "^7.0.0",
"gaxios": "^7.0.0-rc.4",
"gcp-metadata": "^7.0.0-rc.1",
"gtoken": "^8.0.0-rc.1",
"jws": "^4.0.0"
},
"devDependencies": {
Expand Down Expand Up @@ -54,7 +54,7 @@
"mocha": "^9.2.2",
"mv": "^2.1.1",
"ncp": "^2.0.0",
"nock": "^13.0.0",
"nock": "^14.0.1",
"null-loader": "^4.0.0",
"puppeteer": "^24.0.0",
"sinon": "^18.0.0",
Expand Down Expand Up @@ -84,8 +84,7 @@
"browser-test": "karma start",
"docs-test": "linkinator docs",
"predocs-test": "npm run docs",
"prelint": "cd samples; npm link ../; npm install",
"precompile": "gts clean"
"prelint": "cd samples; npm link ../; npm install"
},
"license": "Apache-2.0"
}
61 changes: 43 additions & 18 deletions src/auth/authclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export interface CredentialsClient {
* { Authorization: 'Bearer <access_token_value>' }
* @param url The URI being authorized.
*/
getRequestHeaders(url?: string): Promise<Headers>;
getRequestHeaders(url?: string | URL): Promise<Headers>;

/**
* Provides an alternative Gaxios request implementation with auth credentials
Expand Down Expand Up @@ -251,10 +251,13 @@ export abstract class AuthClient
* resolves with authorization header fields.
*
* The result has the form:
* { Authorization: 'Bearer <access_token_value>' }
* ```ts
* new Headers({'Authorization': 'Bearer <access_token_value>'});
* ```
*
* @param url The URI being authorized.
*/
abstract getRequestHeaders(url?: string): Promise<Headers>;
abstract getRequestHeaders(url?: string | URL): Promise<Headers>;

/**
* @return A promise that resolves with the current GCP access token
Expand Down Expand Up @@ -285,35 +288,58 @@ export abstract class AuthClient
// the x-goog-user-project header, to indicate an alternate account for
// billing and quota:
if (
!headers['x-goog-user-project'] && // don't override a value the user sets.
!headers.has('x-goog-user-project') && // don't override a value the user sets.
this.quotaProjectId
) {
headers['x-goog-user-project'] = this.quotaProjectId;
headers.set('x-goog-user-project', this.quotaProjectId);
}
return headers;
}

/**
* Adds the `x-goog-user-project` and `Authorization` headers to the target Headers
* object, if they exist on the source.
*
* @param target the headers to target
* @param source the headers to source from
* @returns the target headers
*/
protected addUserProjectAndAuthHeaders<T extends Headers>(
target: T,
source: Headers
): T {
const xGoogUserProject = source.get('x-goog-user-project');
const authorizationHeader = source.get('Authorization');

if (xGoogUserProject) {
target.set('x-goog-user-project', xGoogUserProject);
}

if (authorizationHeader) {
target.set('Authorization', authorizationHeader);
}

return target;
}

static readonly DEFAULT_REQUEST_INTERCEPTOR: Parameters<
Gaxios['interceptors']['request']['add']
>[0] = {
resolved: async config => {
const headers = config.headers || {};

// Set `x-goog-api-client`, if not already set
if (!headers['x-goog-api-client']) {
if (!config.headers.has('x-goog-api-client')) {
const nodeVersion = process.version.replace(/^v/, '');
headers['x-goog-api-client'] = `gl-node/${nodeVersion}`;
config.headers.set('x-goog-api-client', `gl-node/${nodeVersion}`);
}

// Set `User-Agent`
if (!headers['User-Agent']) {
headers['User-Agent'] = USER_AGENT;
} else if (!headers['User-Agent'].includes(`${PRODUCT_NAME}/`)) {
headers['User-Agent'] = `${headers['User-Agent']} ${USER_AGENT}`;
const userAgent = config.headers.get('User-Agent');
if (!userAgent) {
config.headers.set('User-Agent', USER_AGENT);
} else if (!userAgent.includes(`${PRODUCT_NAME}/`)) {
config.headers.set('User-Agent', `${userAgent} ${USER_AGENT}`);
}

config.headers = headers;

return config;
},
};
Expand All @@ -337,9 +363,8 @@ export abstract class AuthClient
}
}

export interface Headers {
[index: string]: string;
}
// TypeScript does not have `HeadersInit` in the standard types yet
export type HeadersInit = ConstructorParameters<typeof Headers>[0];

export interface GetAccessTokenResponse {
token?: string | null;
Expand Down
14 changes: 7 additions & 7 deletions src/auth/awsclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {

import {DefaultAwsSecurityCredentialsSupplier} from './defaultawssecuritycredentialssupplier';
import {originalOrCamelOptions, SnakeToCamelObject} from '../util';
import {Gaxios} from 'gaxios';

/**
* AWS credentials JSON interface. This is used for AWS workloads.
Expand Down Expand Up @@ -240,7 +241,7 @@ export class AwsClient extends BaseExternalAccountClient {
// headers: [{key: 'x-amz-date', value: '...'}, ...]
// }))
const reformattedHeader: {key: string; value: string}[] = [];
const extendedHeaders = Object.assign(
const extendedHeaders = Gaxios.mergeHeaders(
{
// The full, canonical resource name of the workload identity pool
// provider, with or without the HTTPS prefix.
Expand All @@ -250,13 +251,12 @@ export class AwsClient extends BaseExternalAccountClient {
},
options.headers
);

// Reformat header to GCP STS expected format.
for (const key in extendedHeaders) {
reformattedHeader.push({
key,
value: extendedHeaders[key],
});
}
extendedHeaders.forEach((value, key) =>
reformattedHeader.push({key, value})
);

// Serialize the reformatted signed request.
return encodeURIComponent(
JSON.stringify({
Expand Down
70 changes: 34 additions & 36 deletions src/auth/awsrequestsigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {GaxiosOptions} from 'gaxios';
import {Gaxios, GaxiosOptions} from 'gaxios';

import {Headers} from './authclient';
import {HeadersInit} from './authclient';
import {Crypto, createCrypto, fromArrayBufferToHex} from '../crypto/crypto';

type HttpMethod =
| 'GET'
| 'POST'
| 'PUT'
| 'PATCH'
| 'HEAD'
| 'DELETE'
| 'CONNECT'
| 'OPTIONS'
| 'TRACE';

/** Interface defining the AWS authorization header map for signed requests. */
interface AwsAuthHeaderMap {
amzDate?: string;
Expand Down Expand Up @@ -60,15 +49,15 @@ interface GenerateAuthHeaderMapOptions {
// The AWS service URL query string.
canonicalQuerystring: string;
// The HTTP method used to call this API.
method: HttpMethod;
method: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this type changing? seems more flexible? also not clear if this change has to do with the gaxios upgrade.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HttpMethod was a handwritten version for what the spec provides. The spec accepts any method as a string.

// The AWS region.
region: string;
// The AWS security credentials.
securityCredentials: AwsSecurityCredentials;
// The optional request payload if available.
requestPayload?: string;
// The optional additional headers needed for the requested AWS API.
additionalAmzHeaders?: Headers;
additionalAmzHeaders?: HeadersInit;
}

/** AWS Signature Version 4 signing algorithm identifier. */
Expand Down Expand Up @@ -113,7 +102,7 @@ export class AwsRequestSigner {
*/
async getRequestOptions(amzOptions: GaxiosOptions): Promise<GaxiosOptions> {
if (!amzOptions.url) {
throw new Error('"url" is required in "amzOptions"');
throw new RangeError('"url" is required in "amzOptions"');
}
// Stringify JSON requests. This will be set in the request body of the
// generated signed request.
Expand All @@ -127,19 +116,26 @@ export class AwsRequestSigner {
const additionalAmzHeaders = amzOptions.headers;
const awsSecurityCredentials = await this.getCredentials();
const uri = new URL(url);

if (typeof requestPayload !== 'string' && requestPayload !== undefined) {
throw new TypeError(
`'requestPayload' is expected to be a string if provided. Got: ${requestPayload}`
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we know what requestPayload type could be? This could be an ugly error Got [object Object]

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want it to be a string, however we marshal objects to strings a few lines above it. This is preserving the existing functionality and makes it clearer, we used to throw later in the call stack.

}

const headerMap = await generateAuthenticationHeaderMap({
crypto: this.crypto,
host: uri.host,
canonicalUri: uri.pathname,
canonicalQuerystring: uri.search.substr(1),
canonicalQuerystring: uri.search.slice(1),
method,
region: this.region,
securityCredentials: awsSecurityCredentials,
requestPayload,
additionalAmzHeaders,
});
// Append additional optional headers, eg. X-Amz-Target, Content-Type, etc.
const headers: {[key: string]: string} = Object.assign(
const headers = Gaxios.mergeHeaders(
// Add x-amz-date if available.
headerMap.amzDate ? {'x-amz-date': headerMap.amzDate} : {},
{
Expand All @@ -149,7 +145,7 @@ export class AwsRequestSigner {
additionalAmzHeaders || {}
);
if (awsSecurityCredentials.token) {
Object.assign(headers, {
Gaxios.mergeHeaders(headers, {
'x-amz-security-token': awsSecurityCredentials.token,
});
}
Expand All @@ -159,7 +155,7 @@ export class AwsRequestSigner {
headers,
};

if (typeof requestPayload !== 'undefined') {
if (requestPayload !== undefined) {
awsSignedReq.body = requestPayload;
}

Expand Down Expand Up @@ -223,7 +219,9 @@ async function getSigningKey(
async function generateAuthenticationHeaderMap(
options: GenerateAuthHeaderMapOptions
): Promise<AwsAuthHeaderMap> {
const additionalAmzHeaders = options.additionalAmzHeaders || {};
const additionalAmzHeaders = Gaxios.mergeHeaders(
options.additionalAmzHeaders
);
const requestPayload = options.requestPayload || '';
// iam.amazonaws.com host => iam service.
// sts.us-east-2.amazonaws.com => sts service.
Expand All @@ -237,38 +235,38 @@ async function generateAuthenticationHeaderMap(
// Format: '%Y%m%d'.
const dateStamp = now.toISOString().replace(/[-]/g, '').replace(/T.*/, '');

// Change all additional headers to be lower case.
const reformattedAdditionalAmzHeaders: Headers = {};
Object.keys(additionalAmzHeaders).forEach(key => {
reformattedAdditionalAmzHeaders[key.toLowerCase()] =
additionalAmzHeaders[key];
});
// Add AWS token if available.
if (options.securityCredentials.token) {
reformattedAdditionalAmzHeaders['x-amz-security-token'] =
options.securityCredentials.token;
additionalAmzHeaders.set(
'x-amz-security-token',
options.securityCredentials.token
);
}
// Header keys need to be sorted alphabetically.
const amzHeaders = Object.assign(
const amzHeaders = Gaxios.mergeHeaders(
{
host: options.host,
},
// Previously the date was not fixed with x-amz- and could be provided manually.
// https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.req
reformattedAdditionalAmzHeaders.date ? {} : {'x-amz-date': amzDate},
reformattedAdditionalAmzHeaders
additionalAmzHeaders.has('date') ? {} : {'x-amz-date': amzDate},
additionalAmzHeaders
);
let canonicalHeaders = '';
const signedHeadersList = Object.keys(amzHeaders).sort();

// TypeScript is missing `Headers#keys` at the time of writing
const signedHeadersList = [
...(amzHeaders as Headers & {keys: () => string[]}).keys(),
].sort();
signedHeadersList.forEach(key => {
canonicalHeaders += `${key}:${amzHeaders[key]}\n`;
canonicalHeaders += `${key}:${amzHeaders.get(key)}\n`;
});
const signedHeaders = signedHeadersList.join(';');

const payloadHash = await options.crypto.sha256DigestHex(requestPayload);
// https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
const canonicalRequest =
`${options.method}\n` +
`${options.method.toUpperCase()}\n` +
`${options.canonicalUri}\n` +
`${options.canonicalQuerystring}\n` +
`${canonicalHeaders}\n` +
Expand Down Expand Up @@ -298,7 +296,7 @@ async function generateAuthenticationHeaderMap(

return {
// Do not return x-amz-date if date is available.
amzDate: reformattedAdditionalAmzHeaders.date ? undefined : amzDate,
amzDate: additionalAmzHeaders.has('date') ? undefined : amzDate,
authorizationHeader,
canonicalQuerystring: options.canonicalQuerystring,
};
Expand Down
Loading
Loading