Skip to content

Commit a264b80

Browse files
Merge pull request #2782 from aws/api-key-passthrough
feat(gateway): allow API_KEY on passthrough targets
2 parents eb165b5 + bfac28c commit a264b80

2 files changed

Lines changed: 171 additions & 1 deletion

File tree

‎src/projectSchemas/gateway.test.ts‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ const lambdaCompute = {
1414
implementation: { language: "Python" as const, path: "tools", handler: "handler.main" },
1515
pythonVersion: "PYTHON_3_12" as const,
1616
};
17+
const githubPassthrough = {
18+
name: "github",
19+
targetType: "passthrough",
20+
passthrough: { endpoint: "https://api.github.com", protocolType: "CUSTOM" },
21+
};
22+
const apiKeyAuth = { type: "API_KEY", credentialName: "githubToken" };
23+
const targetIssues = (target: object) =>
24+
AgentCoreGatewayTargetSchema.safeParse(target).error?.issues.map(({ path, message }) => ({
25+
path,
26+
message,
27+
}));
1728
describe("gateway custom validation", () => {
1829
it("binds compute runtime versions to implementation languages", () => {
1930
expect(
@@ -236,6 +247,121 @@ describe("gateway custom validation", () => {
236247
}).success,
237248
).toBe(true);
238249
});
250+
it.each([
251+
["passthrough with no API key placement", githubPassthrough, {}],
252+
[
253+
"passthrough with an Authorization Bearer header",
254+
githubPassthrough,
255+
{ location: "HEADER", parameterName: "Authorization", prefix: "Bearer" },
256+
],
257+
[
258+
"openApiSchema with an API key query parameter",
259+
{
260+
name: "weather",
261+
targetType: "openApiSchema",
262+
schemaSource: { inline: { path: "openapi.json" } },
263+
},
264+
{ location: "QUERY_PARAMETER", parameterName: "api_key" },
265+
],
266+
[
267+
"apiGateway with a custom API key header",
268+
{
269+
name: "pets",
270+
targetType: "apiGateway",
271+
apiGateway: {
272+
restApiId: "api",
273+
stage: "prod",
274+
apiGatewayToolConfiguration: { toolFilters: [{ filterPath: "/*", methods: ["GET"] }] },
275+
},
276+
},
277+
{ parameterName: "X-Pets-Key" },
278+
],
279+
[
280+
"a query parameter name with a colon",
281+
githubPassthrough,
282+
{ location: "QUERY_PARAMETER", parameterName: "api:key" },
283+
],
284+
[
285+
"a query parameter name with brackets",
286+
githubPassthrough,
287+
{ location: "QUERY_PARAMETER", parameterName: "auth[token]" },
288+
],
289+
])("accepts %s", (_case, target, placement) => {
290+
expect(
291+
targetIssues({ ...target, outboundAuth: { ...apiKeyAuth, ...placement } }),
292+
).toBeUndefined();
293+
});
294+
it.each([
295+
[
296+
"an unknown API key location",
297+
{ location: "BODY" },
298+
["outboundAuth", "location"],
299+
'Invalid option: expected one of "HEADER"|"QUERY_PARAMETER"',
300+
],
301+
[
302+
"a parameterName over 64 characters",
303+
{ parameterName: "x".repeat(65) },
304+
["outboundAuth", "parameterName"],
305+
"Too big: expected string to have <=64 characters",
306+
],
307+
[
308+
"a query parameter name over 64 characters",
309+
{ location: "QUERY_PARAMETER", parameterName: "x".repeat(65) },
310+
["outboundAuth", "parameterName"],
311+
"Too big: expected string to have <=64 characters",
312+
],
313+
[
314+
"a HEADER parameterName with a colon",
315+
{ location: "HEADER", parameterName: "Authorization: " },
316+
["outboundAuth", "parameterName"],
317+
"A HEADER API key's parameterName must be an HTTP header name, with no spaces or colons.",
318+
],
319+
[
320+
"a parameterName with spaces when location defaults to HEADER",
321+
{ parameterName: "x api key" },
322+
["outboundAuth", "parameterName"],
323+
"A HEADER API key's parameterName must be an HTTP header name, with no spaces or colons.",
324+
],
325+
[
326+
"an empty API key prefix",
327+
{ prefix: "" },
328+
["outboundAuth", "prefix"],
329+
"Too small: expected string to have >=1 characters",
330+
],
331+
[
332+
"an API key prefix over 64 characters",
333+
{ prefix: "x".repeat(65) },
334+
["outboundAuth", "prefix"],
335+
"Too big: expected string to have <=64 characters",
336+
],
337+
[
338+
"QUERY_PARAMETER without a parameterName",
339+
{ location: "QUERY_PARAMETER" },
340+
["outboundAuth", "parameterName"],
341+
"An API key with location QUERY_PARAMETER requires a parameterName.",
342+
],
343+
])("rejects %s", (_case, placement, path, message) => {
344+
expect(
345+
targetIssues({ ...githubPassthrough, outboundAuth: { ...apiKeyAuth, ...placement } }),
346+
).toEqual([{ path, message }]);
347+
});
348+
it("rejects API key placement fields with non-API_KEY outbound auth", () => {
349+
expect(
350+
targetIssues({
351+
...githubPassthrough,
352+
outboundAuth: {
353+
type: "OAUTH",
354+
credentialName: "githubOauth",
355+
parameterName: "Authorization",
356+
},
357+
}),
358+
).toEqual([
359+
{
360+
path: ["outboundAuth"],
361+
message: "location, parameterName and prefix are only valid with API_KEY outbound auth.",
362+
},
363+
]);
364+
});
239365
it("requires JWT configuration when a gateway selects CUSTOM_JWT", () => {
240366
const gateway = { name: "gateway", targets: [] };
241367
expect(

‎src/projectSchemas/gateway.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,20 @@ export const OutboundAuthTypeSchema = z.enum([
4848
"JWT_PASSTHROUGH",
4949
]);
5050
export type OutboundAuthType = z.infer<typeof OutboundAuthTypeSchema>;
51+
// An HTTP header name (RFC 9110 token). Rejects spaces and colons, so "Authorization: " fails
52+
// validation instead of the CloudFormation deploy. Query parameter names have no such rule: any
53+
// character can be percent-encoded (RFC 3986), so they're only length-checked.
54+
const HTTP_HEADER_NAME_PATTERN = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
5155
export const OutboundAuthSchema = z
5256
.object({
5357
type: OutboundAuthTypeSchema.default("NONE"),
5458
credentialName: z.string().min(1).optional(),
5559
scopes: z.array(z.string()).optional(),
5660
service: z.string().min(1).max(64).optional(),
5761
region: z.string().min(1).max(32).optional(),
62+
location: z.enum(["HEADER", "QUERY_PARAMETER"]).optional(),
63+
parameterName: z.string().min(1).max(64).optional(),
64+
prefix: z.string().min(1).max(64).optional(),
5865
})
5966
.strict();
6067
export type OutboundAuth = z.infer<typeof OutboundAuthSchema>;
@@ -88,7 +95,7 @@ export const TARGET_TYPE_AUTH_CONFIG: Record<
8895
connector: { authRequired: false, validAuthTypes: [], iamRoleFallback: true },
8996
passthrough: {
9097
authRequired: true,
91-
validAuthTypes: ["GATEWAY_IAM_ROLE", "OAUTH", "JWT_PASSTHROUGH"],
98+
validAuthTypes: ["GATEWAY_IAM_ROLE", "OAUTH", "JWT_PASSTHROUGH", "API_KEY"],
9299
iamRoleFallback: false,
93100
},
94101
};
@@ -429,6 +436,43 @@ export const AgentCoreGatewayTargetSchema = z
429436
path: ["outboundAuth"],
430437
});
431438
}
439+
if (
440+
data.outboundAuth?.type !== "API_KEY" &&
441+
(data.outboundAuth?.location !== undefined ||
442+
data.outboundAuth?.parameterName !== undefined ||
443+
data.outboundAuth?.prefix !== undefined)
444+
) {
445+
ctx.addIssue({
446+
code: "custom",
447+
message: "location, parameterName and prefix are only valid with API_KEY outbound auth.",
448+
path: ["outboundAuth"],
449+
});
450+
}
451+
if (
452+
data.outboundAuth?.location === "QUERY_PARAMETER" &&
453+
data.outboundAuth.parameterName === undefined
454+
) {
455+
ctx.addIssue({
456+
code: "custom",
457+
message: "An API key with location QUERY_PARAMETER requires a parameterName.",
458+
path: ["outboundAuth", "parameterName"],
459+
});
460+
}
461+
// location defaults to HEADER, so an unset location is checked as a header name.
462+
const parameterName = data.outboundAuth?.parameterName;
463+
if (
464+
data.outboundAuth?.type === "API_KEY" &&
465+
data.outboundAuth.location !== "QUERY_PARAMETER" &&
466+
parameterName !== undefined &&
467+
!HTTP_HEADER_NAME_PATTERN.test(parameterName)
468+
) {
469+
ctx.addIssue({
470+
code: "custom",
471+
message:
472+
"A HEADER API key's parameterName must be an HTTP header name, with no spaces or colons.",
473+
path: ["outboundAuth", "parameterName"],
474+
});
475+
}
432476
}
433477
});
434478
export type AgentCoreGatewayTarget = z.infer<typeof AgentCoreGatewayTargetSchema>;

0 commit comments

Comments
 (0)