Skip to content

Commit 9a97df5

Browse files
author
Steven Yuan
authored
feat(core): add experimentalIdentityAndAuth AWS SDK SigV4 support (#5586)
1 parent 3814163 commit 9a97df5

15 files changed

+486
-1
lines changed

packages/core/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"lint": "node ./scripts/lint.js",
1313
"clean": "rimraf ./dist-* && rimraf *.tsbuildinfo",
1414
"extract:docs": "api-extractor run --local",
15-
"test": "jest --passWithNoTests"
15+
"test": "jest"
1616
},
1717
"main": "./dist-cjs/index.js",
1818
"module": "./dist-es/index.js",
@@ -24,7 +24,11 @@
2424
},
2525
"license": "Apache-2.0",
2626
"dependencies": {
27+
"@smithy/core": "^1.1.0",
28+
"@smithy/protocol-http": "^3.0.11",
2729
"@smithy/smithy-client": "^2.1.18",
30+
"@smithy/signature-v4": "^2.0.0",
31+
"@smithy/types": "^2.7.0",
2832
"tslib": "^2.5.0"
2933
},
3034
"devDependencies": {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { HttpRequest } from "@smithy/protocol-http";
2+
import { ServiceException } from "@smithy/smithy-client";
3+
import {
4+
AuthScheme,
5+
AwsCredentialIdentity,
6+
HandlerExecutionContext,
7+
HttpRequest as IHttpRequest,
8+
HttpResponse,
9+
HttpSigner,
10+
RequestSigner,
11+
} from "@smithy/types";
12+
13+
import { getDateHeader, getSkewCorrectedDate, getUpdatedSystemClockOffset } from "../utils";
14+
import { throwAWSSDKSigningPropertyError } from "./throwAWSSDKSigningPropertyError";
15+
16+
/**
17+
* @internal
18+
*/
19+
interface AWSSDKSigV4Config {
20+
systemClockOffset: number;
21+
signer: (authScheme?: AuthScheme) => Promise<RequestSigner>;
22+
}
23+
24+
/**
25+
* @internal
26+
*/
27+
interface AWSSDKSigV4AuthSigningProperties {
28+
config: AWSSDKSigV4Config;
29+
signer: RequestSigner;
30+
signingRegion?: string;
31+
signingName?: string;
32+
}
33+
34+
/**
35+
* @internal
36+
*/
37+
interface AWSSDKSigV4Exception extends ServiceException {
38+
ServerTime?: string;
39+
}
40+
41+
/**
42+
* @internal
43+
*/
44+
const validateSigningProperties = async (
45+
signingProperties: Record<string, unknown>
46+
): Promise<AWSSDKSigV4AuthSigningProperties> => {
47+
const context = throwAWSSDKSigningPropertyError(
48+
"context",
49+
signingProperties.context as HandlerExecutionContext | undefined
50+
);
51+
const config = throwAWSSDKSigningPropertyError("config", signingProperties.config as AWSSDKSigV4Config | undefined);
52+
const authScheme = context.endpointV2?.properties?.authSchemes?.[0];
53+
const signerFunction = throwAWSSDKSigningPropertyError(
54+
"signer",
55+
config.signer as ((authScheme?: AuthScheme) => Promise<RequestSigner>) | undefined
56+
);
57+
const signer = await signerFunction(authScheme);
58+
const signingRegion: string | undefined = signingProperties?.signingRegion as string | undefined;
59+
const signingName = signingProperties?.signingName as string | undefined;
60+
return {
61+
config,
62+
signer,
63+
signingRegion,
64+
signingName,
65+
};
66+
};
67+
68+
/**
69+
* @internal
70+
*/
71+
export class AWSSDKSigV4Signer implements HttpSigner {
72+
async sign(
73+
httpRequest: IHttpRequest,
74+
/**
75+
* `identity` is bound in {@link resolveAWSSDKSigV4Config}
76+
*/
77+
identity: AwsCredentialIdentity,
78+
signingProperties: Record<string, unknown>
79+
): Promise<IHttpRequest> {
80+
if (!HttpRequest.isInstance(httpRequest)) {
81+
throw new Error("The request is not an instance of `HttpRequest` and cannot be signed");
82+
}
83+
const { config, signer, signingRegion, signingName } = await validateSigningProperties(signingProperties);
84+
85+
const signedRequest = await signer.sign(httpRequest, {
86+
signingDate: getSkewCorrectedDate(config.systemClockOffset),
87+
signingRegion: signingRegion,
88+
signingService: signingName,
89+
});
90+
return signedRequest;
91+
}
92+
93+
errorHandler(signingProperties: Record<string, unknown>): (error: Error) => never {
94+
return (error: Error) => {
95+
const serverTime: string | undefined =
96+
(error as AWSSDKSigV4Exception).ServerTime ?? getDateHeader((error as AWSSDKSigV4Exception).$response);
97+
if (serverTime) {
98+
const config = throwAWSSDKSigningPropertyError(
99+
"config",
100+
signingProperties.config as AWSSDKSigV4Config | undefined
101+
);
102+
config.systemClockOffset = getUpdatedSystemClockOffset(serverTime, config.systemClockOffset);
103+
}
104+
throw error;
105+
};
106+
}
107+
108+
successHandler(httpResponse: HttpResponse | unknown, signingProperties: Record<string, unknown>): void {
109+
const dateHeader = getDateHeader(httpResponse);
110+
if (dateHeader) {
111+
const config = throwAWSSDKSigningPropertyError(
112+
"config",
113+
signingProperties.config as AWSSDKSigV4Config | undefined
114+
);
115+
config.systemClockOffset = getUpdatedSystemClockOffset(dateHeader, config.systemClockOffset);
116+
}
117+
}
118+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./AWSSDKSigV4Signer";
2+
export * from "./resolveAWSSDKSigV4Config";
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import {
2+
doesIdentityRequireRefresh,
3+
isIdentityExpired,
4+
memoizeIdentityProvider,
5+
normalizeProvider,
6+
} from "@smithy/core";
7+
import { SignatureV4, SignatureV4CryptoInit, SignatureV4Init } from "@smithy/signature-v4";
8+
import {
9+
AuthScheme,
10+
AwsCredentialIdentity,
11+
AwsCredentialIdentityProvider,
12+
ChecksumConstructor,
13+
HashConstructor,
14+
MemoizedProvider,
15+
Provider,
16+
RegionInfo,
17+
RegionInfoProvider,
18+
RequestSigner,
19+
} from "@smithy/types";
20+
21+
/**
22+
* @internal
23+
*/
24+
export interface AWSSDKSigV4AuthInputConfig {
25+
/**
26+
* The credentials used to sign requests.
27+
*/
28+
credentials?: AwsCredentialIdentity | AwsCredentialIdentityProvider;
29+
30+
/**
31+
* The signer to use when signing requests.
32+
*/
33+
signer?: RequestSigner | ((authScheme?: AuthScheme) => Promise<RequestSigner>);
34+
35+
/**
36+
* Whether to escape request path when signing the request.
37+
*/
38+
signingEscapePath?: boolean;
39+
40+
/**
41+
* An offset value in milliseconds to apply to all signing times.
42+
*/
43+
systemClockOffset?: number;
44+
45+
/**
46+
* The region where you want to sign your request against. This
47+
* can be different to the region in the endpoint.
48+
*/
49+
signingRegion?: string;
50+
51+
/**
52+
* The injectable SigV4-compatible signer class constructor. If not supplied,
53+
* regular SignatureV4 constructor will be used.
54+
*
55+
* @internal
56+
*/
57+
signerConstructor?: new (options: SignatureV4Init & SignatureV4CryptoInit) => RequestSigner;
58+
}
59+
60+
/**
61+
* @internal
62+
*/
63+
export interface AWSSDKSigV4PreviouslyResolved {
64+
credentialDefaultProvider?: (input: any) => MemoizedProvider<AwsCredentialIdentity>;
65+
region: string | Provider<string>;
66+
sha256: ChecksumConstructor | HashConstructor;
67+
signingName?: string;
68+
regionInfoProvider?: RegionInfoProvider;
69+
defaultSigningName?: string;
70+
serviceId: string;
71+
useFipsEndpoint: Provider<boolean>;
72+
useDualstackEndpoint: Provider<boolean>;
73+
}
74+
75+
/**
76+
* @internal
77+
*/
78+
export interface AWSSDKSigV4AuthResolvedConfig {
79+
/**
80+
* Resolved value for input config {@link AWSSDKSigV4AuthInputConfig.credentials}
81+
* This provider MAY memoize the loaded credentials for certain period.
82+
* See {@link MemoizedProvider} for more information.
83+
*/
84+
credentials: AwsCredentialIdentityProvider;
85+
/**
86+
* Resolved value for input config {@link AWSSDKSigV4AuthInputConfig.signer}
87+
*/
88+
signer: (authScheme?: AuthScheme) => Promise<RequestSigner>;
89+
/**
90+
* Resolved value for input config {@link AWSSDKSigV4AuthInputConfig.signingEscapePath}
91+
*/
92+
signingEscapePath: boolean;
93+
/**
94+
* Resolved value for input config {@link AWSSDKSigV4AuthInputConfig.systemClockOffset}
95+
*/
96+
systemClockOffset: number;
97+
}
98+
99+
/**
100+
* @internal
101+
*/
102+
export const resolveAWSSDKSigV4Config = <T>(
103+
config: T & AWSSDKSigV4AuthInputConfig & AWSSDKSigV4PreviouslyResolved
104+
): T & AWSSDKSigV4AuthResolvedConfig => {
105+
// Normalize credentials
106+
let normalizedCreds: AwsCredentialIdentityProvider | undefined;
107+
if (config.credentials) {
108+
normalizedCreds = memoizeIdentityProvider(config.credentials, isIdentityExpired, doesIdentityRequireRefresh);
109+
}
110+
if (!normalizedCreds) {
111+
// credentialDefaultProvider should always be populated, but in case
112+
// it isn't, set a default identity provider that throws an error
113+
if (config.credentialDefaultProvider) {
114+
normalizedCreds = config.credentialDefaultProvider(config as any);
115+
} else {
116+
normalizedCreds = async () => { throw new Error("`credentials` is missing") };
117+
}
118+
}
119+
120+
// Populate sigv4 arguments
121+
const {
122+
// Default for signingEscapePath
123+
signingEscapePath = true,
124+
// Default for systemClockOffset
125+
systemClockOffset = config.systemClockOffset || 0,
126+
// No default for sha256 since it is platform dependent
127+
sha256,
128+
} = config;
129+
130+
// Resolve signer
131+
let signer: (authScheme?: AuthScheme) => Promise<RequestSigner>;
132+
if (config.signer) {
133+
// if signer is supplied by user, normalize it to a function returning a promise for signer.
134+
signer = normalizeProvider(config.signer);
135+
} else if (config.regionInfoProvider) {
136+
// This branch is for endpoints V1.
137+
// construct a provider inferring signing from region.
138+
signer = () =>
139+
normalizeProvider(config.region)()
140+
.then(
141+
async (region) =>
142+
[
143+
(await config.regionInfoProvider!(region, {
144+
useFipsEndpoint: await config.useFipsEndpoint(),
145+
useDualstackEndpoint: await config.useDualstackEndpoint(),
146+
})) || {},
147+
region,
148+
] as [RegionInfo, string]
149+
)
150+
.then(([regionInfo, region]) => {
151+
const { signingRegion, signingService } = regionInfo;
152+
// update client's singing region and signing service config if they are resolved.
153+
// signing region resolving order: user supplied signingRegion -> endpoints.json inferred region -> client region
154+
config.signingRegion = config.signingRegion || signingRegion || region;
155+
// signing name resolving order:
156+
// user supplied signingName -> endpoints.json inferred (credential scope -> model arnNamespace) -> model service id
157+
config.signingName = config.signingName || signingService || config.serviceId;
158+
159+
const params: SignatureV4Init & SignatureV4CryptoInit = {
160+
...config,
161+
credentials: normalizedCreds!,
162+
region: config.signingRegion,
163+
service: config.signingName,
164+
sha256,
165+
uriEscapePath: signingEscapePath,
166+
};
167+
const SignerCtor = config.signerConstructor || SignatureV4;
168+
return new SignerCtor(params);
169+
});
170+
} else {
171+
// This branch is for endpoints V2.
172+
// Handle endpoints v2 that resolved per-command
173+
// TODO: need total refactor for reference auth architecture.
174+
signer = async (authScheme?: AuthScheme) => {
175+
authScheme = Object.assign(
176+
{},
177+
{
178+
name: "sigv4",
179+
signingName: config.signingName || config.defaultSigningName!,
180+
signingRegion: await normalizeProvider(config.region)(),
181+
properties: {},
182+
},
183+
authScheme
184+
);
185+
186+
const signingRegion = authScheme.signingRegion;
187+
const signingService = authScheme.signingName;
188+
// update client's singing region and signing service config if they are resolved.
189+
// signing region resolving order: user supplied signingRegion -> endpoints.json inferred region -> client region
190+
config.signingRegion = config.signingRegion || signingRegion;
191+
// signing name resolving order:
192+
// user supplied signingName -> endpoints.json inferred (credential scope -> model arnNamespace) -> model service id
193+
config.signingName = config.signingName || signingService || config.serviceId;
194+
195+
const params: SignatureV4Init & SignatureV4CryptoInit = {
196+
...config,
197+
credentials: normalizedCreds!,
198+
region: config.signingRegion,
199+
service: config.signingName,
200+
sha256,
201+
uriEscapePath: signingEscapePath,
202+
};
203+
204+
const SignerCtor = config.signerConstructor || SignatureV4;
205+
return new SignerCtor(params);
206+
};
207+
}
208+
209+
return {
210+
...config,
211+
systemClockOffset,
212+
signingEscapePath,
213+
credentials: normalizedCreds!,
214+
signer,
215+
};
216+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @internal
3+
*/
4+
export const throwAWSSDKSigningPropertyError = <T>(name: string, property: T | undefined): T | never => {
5+
if (!property) {
6+
throw new Error(`Property \`${name}\` is not resolved for AWS SDK SigV4Auth`);
7+
}
8+
return property;
9+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./aws-sdk";
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { HttpResponse } from "@smithy/protocol-http";
2+
3+
/**
4+
* @internal
5+
*/
6+
export const getDateHeader = (response: unknown): string | undefined =>
7+
HttpResponse.isInstance(response) ? response.headers?.date ?? response.headers?.Date : undefined;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { getSkewCorrectedDate } from "./getSkewCorrectedDate";
2+
3+
describe(getSkewCorrectedDate.name, () => {
4+
const mockDateNow = Date.now();
5+
6+
beforeEach(() => {
7+
jest.spyOn(Date, "now").mockReturnValue(mockDateNow);
8+
});
9+
10+
afterEach(() => {
11+
jest.clearAllMocks();
12+
});
13+
14+
it.each([-100000, -100, 0, 100, 100000])("systemClockOffset: %d", (systemClockOffset) => {
15+
expect(getSkewCorrectedDate(systemClockOffset)).toStrictEqual(new Date(mockDateNow + systemClockOffset));
16+
});
17+
});

0 commit comments

Comments
 (0)