Skip to content

Commit

Permalink
feat(core): add experimentalIdentityAndAuth AWS SDK SigV4 support (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
Steven Yuan committed Dec 14, 2023
1 parent 3814163 commit 9a97df5
Show file tree
Hide file tree
Showing 15 changed files with 486 additions and 1 deletion.
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";
216 changes: 216 additions & 0 deletions packages/core/src/httpAuthSchemes/aws-sdk/resolveAWSSDKSigV4Config.ts
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

0 comments on commit 9a97df5

Please sign in to comment.