Skip to content

feat(core): add AWS SDK SigV4 support #5586

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

Merged
merged 1 commit into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"lint": "node ./scripts/lint.js",
"clean": "rimraf ./dist-* && rimraf *.tsbuildinfo",
"extract:docs": "api-extractor run --local",
"test": "jest --passWithNoTests"
"test": "jest"
},
"main": "./dist-cjs/index.js",
"module": "./dist-es/index.js",
Expand All @@ -24,7 +24,11 @@
},
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^1.1.0",
"@smithy/protocol-http": "^3.0.11",
"@smithy/smithy-client": "^2.1.18",
"@smithy/signature-v4": "^2.0.0",
"@smithy/types": "^2.7.0",
"tslib": "^2.5.0"
},
"devDependencies": {
Expand Down
118 changes: 118 additions & 0 deletions packages/core/src/httpAuthSchemes/aws-sdk/AWSSDKSigV4Signer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { HttpRequest } from "@smithy/protocol-http";
import { ServiceException } from "@smithy/smithy-client";
import {
AuthScheme,
AwsCredentialIdentity,
HandlerExecutionContext,
HttpRequest as IHttpRequest,
HttpResponse,
HttpSigner,
RequestSigner,
} from "@smithy/types";

import { getDateHeader, getSkewCorrectedDate, getUpdatedSystemClockOffset } from "../utils";
import { throwAWSSDKSigningPropertyError } from "./throwAWSSDKSigningPropertyError";

/**
* @internal
*/
interface AWSSDKSigV4Config {
systemClockOffset: number;
signer: (authScheme?: AuthScheme) => Promise<RequestSigner>;
}

/**
* @internal
*/
interface AWSSDKSigV4AuthSigningProperties {
config: AWSSDKSigV4Config;
signer: RequestSigner;
signingRegion?: string;
signingName?: string;
}

/**
* @internal
*/
interface AWSSDKSigV4Exception extends ServiceException {
ServerTime?: string;
}

/**
* @internal
*/
const validateSigningProperties = async (
signingProperties: Record<string, unknown>
): Promise<AWSSDKSigV4AuthSigningProperties> => {
const context = throwAWSSDKSigningPropertyError(
"context",
signingProperties.context as HandlerExecutionContext | undefined
);
const config = throwAWSSDKSigningPropertyError("config", signingProperties.config as AWSSDKSigV4Config | undefined);
const authScheme = context.endpointV2?.properties?.authSchemes?.[0];
const signerFunction = throwAWSSDKSigningPropertyError(
"signer",
config.signer as ((authScheme?: AuthScheme) => Promise<RequestSigner>) | undefined
);
const signer = await signerFunction(authScheme);
const signingRegion: string | undefined = signingProperties?.signingRegion as string | undefined;
const signingName = signingProperties?.signingName as string | undefined;
return {
config,
signer,
signingRegion,
signingName,
};
};

/**
* @internal
*/
export class AWSSDKSigV4Signer implements HttpSigner {
async sign(
httpRequest: IHttpRequest,
/**
* `identity` is bound in {@link resolveAWSSDKSigV4Config}
*/
identity: AwsCredentialIdentity,
signingProperties: Record<string, unknown>
): Promise<IHttpRequest> {
if (!HttpRequest.isInstance(httpRequest)) {
throw new Error("The request is not an instance of `HttpRequest` and cannot be signed");
}
const { config, signer, signingRegion, signingName } = await validateSigningProperties(signingProperties);

const signedRequest = await signer.sign(httpRequest, {
signingDate: getSkewCorrectedDate(config.systemClockOffset),
signingRegion: signingRegion,
signingService: signingName,
});
return signedRequest;
}

errorHandler(signingProperties: Record<string, unknown>): (error: Error) => never {
return (error: Error) => {
const serverTime: string | undefined =
(error as AWSSDKSigV4Exception).ServerTime ?? getDateHeader((error as AWSSDKSigV4Exception).$response);
if (serverTime) {
const config = throwAWSSDKSigningPropertyError(
"config",
signingProperties.config as AWSSDKSigV4Config | undefined
);
config.systemClockOffset = getUpdatedSystemClockOffset(serverTime, config.systemClockOffset);
}
throw error;
};
}

successHandler(httpResponse: HttpResponse | unknown, signingProperties: Record<string, unknown>): void {
const dateHeader = getDateHeader(httpResponse);
if (dateHeader) {
const config = throwAWSSDKSigningPropertyError(
"config",
signingProperties.config as AWSSDKSigV4Config | undefined
);
config.systemClockOffset = getUpdatedSystemClockOffset(dateHeader, config.systemClockOffset);
}
}
}
2 changes: 2 additions & 0 deletions packages/core/src/httpAuthSchemes/aws-sdk/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./AWSSDKSigV4Signer";
export * from "./resolveAWSSDKSigV4Config";
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import {
doesIdentityRequireRefresh,
isIdentityExpired,
memoizeIdentityProvider,
normalizeProvider,
} from "@smithy/core";
import { SignatureV4, SignatureV4CryptoInit, SignatureV4Init } from "@smithy/signature-v4";
import {
AuthScheme,
AwsCredentialIdentity,
AwsCredentialIdentityProvider,
ChecksumConstructor,
HashConstructor,
MemoizedProvider,
Provider,
RegionInfo,
RegionInfoProvider,
RequestSigner,
} from "@smithy/types";

/**
* @internal
*/
export interface AWSSDKSigV4AuthInputConfig {
/**
* The credentials used to sign requests.
*/
credentials?: AwsCredentialIdentity | AwsCredentialIdentityProvider;

/**
* The signer to use when signing requests.
*/
signer?: RequestSigner | ((authScheme?: AuthScheme) => Promise<RequestSigner>);

/**
* Whether to escape request path when signing the request.
*/
signingEscapePath?: boolean;

/**
* An offset value in milliseconds to apply to all signing times.
*/
systemClockOffset?: number;

/**
* The region where you want to sign your request against. This
* can be different to the region in the endpoint.
*/
signingRegion?: string;

/**
* The injectable SigV4-compatible signer class constructor. If not supplied,
* regular SignatureV4 constructor will be used.
*
* @internal
*/
signerConstructor?: new (options: SignatureV4Init & SignatureV4CryptoInit) => RequestSigner;
}

/**
* @internal
*/
export interface AWSSDKSigV4PreviouslyResolved {
credentialDefaultProvider?: (input: any) => MemoizedProvider<AwsCredentialIdentity>;
region: string | Provider<string>;
sha256: ChecksumConstructor | HashConstructor;
signingName?: string;
regionInfoProvider?: RegionInfoProvider;
defaultSigningName?: string;
serviceId: string;
useFipsEndpoint: Provider<boolean>;
useDualstackEndpoint: Provider<boolean>;
}

/**
* @internal
*/
export interface AWSSDKSigV4AuthResolvedConfig {
/**
* Resolved value for input config {@link AWSSDKSigV4AuthInputConfig.credentials}
* This provider MAY memoize the loaded credentials for certain period.
* See {@link MemoizedProvider} for more information.
*/
credentials: AwsCredentialIdentityProvider;
/**
* Resolved value for input config {@link AWSSDKSigV4AuthInputConfig.signer}
*/
signer: (authScheme?: AuthScheme) => Promise<RequestSigner>;
/**
* Resolved value for input config {@link AWSSDKSigV4AuthInputConfig.signingEscapePath}
*/
signingEscapePath: boolean;
/**
* Resolved value for input config {@link AWSSDKSigV4AuthInputConfig.systemClockOffset}
*/
systemClockOffset: number;
}

/**
* @internal
*/
export const resolveAWSSDKSigV4Config = <T>(
config: T & AWSSDKSigV4AuthInputConfig & AWSSDKSigV4PreviouslyResolved
): T & AWSSDKSigV4AuthResolvedConfig => {
// Normalize credentials
let normalizedCreds: AwsCredentialIdentityProvider | undefined;
if (config.credentials) {
normalizedCreds = memoizeIdentityProvider(config.credentials, isIdentityExpired, doesIdentityRequireRefresh);
}
if (!normalizedCreds) {
// credentialDefaultProvider should always be populated, but in case
// it isn't, set a default identity provider that throws an error
if (config.credentialDefaultProvider) {
normalizedCreds = config.credentialDefaultProvider(config as any);
} else {
normalizedCreds = async () => { throw new Error("`credentials` is missing") };
}
}

// Populate sigv4 arguments
const {
// Default for signingEscapePath
signingEscapePath = true,
// Default for systemClockOffset
systemClockOffset = config.systemClockOffset || 0,
// No default for sha256 since it is platform dependent
sha256,
} = config;

// Resolve signer
let signer: (authScheme?: AuthScheme) => Promise<RequestSigner>;
if (config.signer) {
// if signer is supplied by user, normalize it to a function returning a promise for signer.
signer = normalizeProvider(config.signer);
} else if (config.regionInfoProvider) {
// This branch is for endpoints V1.
// construct a provider inferring signing from region.
signer = () =>
normalizeProvider(config.region)()
.then(
async (region) =>
[
(await config.regionInfoProvider!(region, {
useFipsEndpoint: await config.useFipsEndpoint(),
useDualstackEndpoint: await config.useDualstackEndpoint(),
})) || {},
region,
] as [RegionInfo, string]
)
.then(([regionInfo, region]) => {
const { signingRegion, signingService } = regionInfo;
// update client's singing region and signing service config if they are resolved.
// signing region resolving order: user supplied signingRegion -> endpoints.json inferred region -> client region
config.signingRegion = config.signingRegion || signingRegion || region;
// signing name resolving order:
// user supplied signingName -> endpoints.json inferred (credential scope -> model arnNamespace) -> model service id
config.signingName = config.signingName || signingService || config.serviceId;

const params: SignatureV4Init & SignatureV4CryptoInit = {
...config,
credentials: normalizedCreds!,
region: config.signingRegion,
service: config.signingName,
sha256,
uriEscapePath: signingEscapePath,
};
const SignerCtor = config.signerConstructor || SignatureV4;
return new SignerCtor(params);
});
} else {
// This branch is for endpoints V2.
// Handle endpoints v2 that resolved per-command
// TODO: need total refactor for reference auth architecture.
signer = async (authScheme?: AuthScheme) => {
authScheme = Object.assign(
{},
{
name: "sigv4",
signingName: config.signingName || config.defaultSigningName!,
signingRegion: await normalizeProvider(config.region)(),
properties: {},
},
authScheme
);

const signingRegion = authScheme.signingRegion;
const signingService = authScheme.signingName;
// update client's singing region and signing service config if they are resolved.
// signing region resolving order: user supplied signingRegion -> endpoints.json inferred region -> client region
config.signingRegion = config.signingRegion || signingRegion;
// signing name resolving order:
// user supplied signingName -> endpoints.json inferred (credential scope -> model arnNamespace) -> model service id
config.signingName = config.signingName || signingService || config.serviceId;

const params: SignatureV4Init & SignatureV4CryptoInit = {
...config,
credentials: normalizedCreds!,
region: config.signingRegion,
service: config.signingName,
sha256,
uriEscapePath: signingEscapePath,
};

const SignerCtor = config.signerConstructor || SignatureV4;
return new SignerCtor(params);
};
}

return {
...config,
systemClockOffset,
signingEscapePath,
credentials: normalizedCreds!,
signer,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @internal
*/
export const throwAWSSDKSigningPropertyError = <T>(name: string, property: T | undefined): T | never => {
if (!property) {
throw new Error(`Property \`${name}\` is not resolved for AWS SDK SigV4Auth`);
}
return property;
};
1 change: 1 addition & 0 deletions packages/core/src/httpAuthSchemes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./aws-sdk";
7 changes: 7 additions & 0 deletions packages/core/src/httpAuthSchemes/utils/getDateHeader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpResponse } from "@smithy/protocol-http";

/**
* @internal
*/
export const getDateHeader = (response: unknown): string | undefined =>
HttpResponse.isInstance(response) ? response.headers?.date ?? response.headers?.Date : undefined;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getSkewCorrectedDate } from "./getSkewCorrectedDate";

describe(getSkewCorrectedDate.name, () => {
const mockDateNow = Date.now();

beforeEach(() => {
jest.spyOn(Date, "now").mockReturnValue(mockDateNow);
});

afterEach(() => {
jest.clearAllMocks();
});

it.each([-100000, -100, 0, 100, 100000])("systemClockOffset: %d", (systemClockOffset) => {
expect(getSkewCorrectedDate(systemClockOffset)).toStrictEqual(new Date(mockDateNow + systemClockOffset));
});
});
Loading