@aws/nx-plugin 1.0.0-rc.44 → 1.0.0-rc.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/generators.json +6 -0
- package/package.json +1 -1
- package/src/agentcore-gateway/files/cedar/policies/README.md +9 -0
- package/src/agentcore-gateway/files/cedar/policies/permit-all.cedar +15 -0
- package/src/agentcore-gateway/generator.js +12 -5
- package/src/agentcore-gateway/generator.js.map +1 -1
- package/src/agentcore-gateway/schema.d.js.map +1 -1
- package/src/agentcore-gateway/schema.d.ts +1 -1
- package/src/agentcore-gateway/schema.json +3 -2
- package/src/mcp-server/tools/general-guidance.js +1 -1
- package/src/mcp-server/tools/general-guidance.js.map +1 -1
- package/src/preset/__snapshots__/generator.spec.ts.snap +5 -2
- package/src/py/agent/gateway-connection/generator.js +1 -1
- package/src/py/agent/gateway-connection/generator.js.map +1 -1
- package/src/py/rdb/__snapshots__/generator.spec.ts.snap +16 -0
- package/src/py/rdb/files/Dockerfile.create-db-user.template +4 -0
- package/src/py/rdb/files/Dockerfile.migration.template +4 -0
- package/src/sdk/ts.d.ts +2 -0
- package/src/sdk/ts.js +2 -0
- package/src/sdk/ts.js.map +1 -1
- package/src/ts/agent/__snapshots__/generator.spec.ts.snap +5 -1
- package/src/ts/agent/files/deploy/Dockerfile.template +5 -1
- package/src/ts/agent/gateway-connection/generator.js +1 -1
- package/src/ts/agent/gateway-connection/generator.js.map +1 -1
- package/src/ts/agent/generator.js +6 -0
- package/src/ts/agent/generator.js.map +1 -1
- package/src/ts/dcr-proxy/__snapshots__/generator.spec.ts.snap +982 -0
- package/src/ts/dcr-proxy/generator.d.ts +10 -0
- package/src/ts/dcr-proxy/generator.js +98 -0
- package/src/ts/dcr-proxy/generator.js.map +1 -0
- package/src/ts/dcr-proxy/schema.d.js +6 -0
- package/src/ts/dcr-proxy/schema.d.js.map +1 -0
- package/src/ts/dcr-proxy/schema.d.ts +13 -0
- package/src/ts/dcr-proxy/schema.json +44 -0
- package/src/ts/mcp-server/files/Dockerfile.template +5 -1
- package/src/ts/mcp-server/generator.js +1 -0
- package/src/ts/mcp-server/generator.js.map +1 -1
- package/src/ts/nx-plugin/__snapshots__/generator.spec.ts.snap +2 -2
- package/src/ts/nx-plugin/files/mcp-server/resources/GENERAL_GUIDANCE.md.template +1 -1
- package/src/ts/nx-plugin/files/mcp-server/utils.ts.template +1 -1
- package/src/ts/rdb/__snapshots__/generator.spec.ts.snap +7 -0
- package/src/ts/rdb/files/Dockerfile.template +7 -0
- package/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap +4 -4
- package/src/utils/agent-core-constructs/agent-core-constructs.d.ts +1 -0
- package/src/utils/agent-core-constructs/agent-core-constructs.js +2 -0
- package/src/utils/agent-core-constructs/agent-core-constructs.js.map +1 -1
- package/src/utils/agent-core-constructs/files/cdk/app/agentcore-gateway/__nameKebabCase__/__nameKebabCase__.ts.template +38 -3
- package/src/utils/agent-core-constructs/files/cdk/core/agentcore-gateway/agentcore-gateway.ts.template +13 -3
- package/src/utils/agent-core-constructs/files/terraform/app/agentcore-gateway/__nameKebabCase__/__nameKebabCase__.tf.template +22 -1
- package/src/utils/commands.js +1 -1
- package/src/utils/commands.js.map +1 -1
- package/src/utils/dcr-proxy-constructs/dcr-proxy-constructs.d.ts +38 -0
- package/src/utils/dcr-proxy-constructs/dcr-proxy-constructs.js +93 -0
- package/src/utils/dcr-proxy-constructs/dcr-proxy-constructs.js.map +1 -0
- package/src/utils/dcr-proxy-constructs/files/cdk/app/dcr-proxies/__nameKebabCase__/__nameKebabCase__.ts.template +227 -0
- package/src/utils/dcr-proxy-constructs/files/project/src/handlers/authorization-server-metadata.ts.template +39 -0
- package/src/utils/dcr-proxy-constructs/files/project/src/handlers/authorize.ts.template +26 -0
- package/src/utils/dcr-proxy-constructs/files/project/src/handlers/mcp-proxy.ts.template +89 -0
- package/src/utils/dcr-proxy-constructs/files/project/src/handlers/protected-resource-metadata.ts.template +13 -0
- package/src/utils/dcr-proxy-constructs/files/project/src/handlers/register.ts.template +70 -0
- package/src/utils/dcr-proxy-constructs/files/project/src/handlers/token.ts.template +75 -0
- package/src/utils/dcr-proxy-constructs/files/terraform/app/dcr-proxies/__nameKebabCase__/__nameKebabCase__.tf.template +410 -0
- package/src/utils/docker.d.ts +4 -3
- package/src/utils/docker.js +11 -7
- package/src/utils/docker.js.map +1 -1
- package/src/utils/format.d.ts +1 -1
- package/src/utils/format.js +3 -2
- package/src/utils/format.js.map +1 -1
- package/src/utils/init.js +4 -0
- package/src/utils/init.js.map +1 -1
- package/src/utils/versions.d.ts +3 -1
- package/src/utils/versions.js +4 -0
- package/src/utils/versions.js.map +1 -1
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`ts#dcr-proxy generator > matches snapshot for generated construct and a handler > dcr-proxy-authorization-server-metadata-handler.ts 1`] = `
|
|
4
|
+
"import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
|
|
5
|
+
|
|
6
|
+
const COGNITO_ISSUER = process.env.COGNITO_ISSUER!;
|
|
7
|
+
|
|
8
|
+
let cached: Record<string, unknown> | undefined;
|
|
9
|
+
|
|
10
|
+
async function fetchUpstream(): Promise<Record<string, unknown>> {
|
|
11
|
+
if (cached) return cached;
|
|
12
|
+
const res = await fetch(\`\${COGNITO_ISSUER}/.well-known/openid-configuration\`);
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
\`Cognito discovery failed: \${res.status} \${res.statusText}\`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
cached = (await res.json()) as Record<string, unknown>;
|
|
19
|
+
return cached;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
|
|
23
|
+
const proxyUrl = \`https://\${event.requestContext.domainName}\`;
|
|
24
|
+
const upstream = await fetchUpstream();
|
|
25
|
+
|
|
26
|
+
// Override the proxy-handled endpoints.
|
|
27
|
+
// jwks_uri is kept as Cognito's so JWT verification works directly against
|
|
28
|
+
// Cognito. issuer is kept as Cognito's so JWT.iss validation matches.
|
|
29
|
+
// authorization_endpoint points to the proxy so we can strip the RFC 8707
|
|
30
|
+
// \`resource\` parameter that some MCP clients send (Cognito rejects token
|
|
31
|
+
// exchange for unregistered resource server URLs).
|
|
32
|
+
const metadata = {
|
|
33
|
+
...upstream,
|
|
34
|
+
authorization_endpoint: \`\${proxyUrl}/authorize\`,
|
|
35
|
+
token_endpoint: \`\${proxyUrl}/oauth/token\`,
|
|
36
|
+
registration_endpoint: \`\${proxyUrl}/register\`,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
statusCode: 200,
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
body: JSON.stringify(metadata),
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
"
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
exports[`ts#dcr-proxy generator > matches snapshot for generated construct and a handler > dcr-proxy-authorize-handler.ts 1`] = `
|
|
49
|
+
"import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
|
|
50
|
+
|
|
51
|
+
const COGNITO_HOSTED_UI_BASE = process.env.COGNITO_HOSTED_UI_BASE!;
|
|
52
|
+
|
|
53
|
+
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
|
|
54
|
+
const params = new URLSearchParams(event.rawQueryString ?? '');
|
|
55
|
+
|
|
56
|
+
console.log('[authorize]', {
|
|
57
|
+
scope: params.get('scope'),
|
|
58
|
+
redirect_uri: params.get('redirect_uri'),
|
|
59
|
+
has_resource: params.has('resource'),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// RFC 8707 \`resource\` parameter binds the access token's \`aud\` claim to
|
|
63
|
+
// the given URL. Cognito accepts it at /authorize but rejects the token
|
|
64
|
+
// exchange with \`invalid_grant\` when the URL is not registered as a
|
|
65
|
+
// resource server. MCP clients send this unconditionally, so strip it.
|
|
66
|
+
params.delete('resource');
|
|
67
|
+
|
|
68
|
+
const target = \`\${COGNITO_HOSTED_UI_BASE}/oauth2/authorize?\${params.toString()}\`;
|
|
69
|
+
return {
|
|
70
|
+
statusCode: 302,
|
|
71
|
+
headers: { location: target },
|
|
72
|
+
body: '',
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
"
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
exports[`ts#dcr-proxy generator > matches snapshot for generated construct and a handler > dcr-proxy-construct.ts 1`] = `
|
|
79
|
+
"import * as url from 'node:url';
|
|
80
|
+
import { Construct } from 'constructs';
|
|
81
|
+
import { Duration, Stack } from 'aws-cdk-lib';
|
|
82
|
+
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
|
|
83
|
+
import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
|
84
|
+
import * as lambda from 'aws-cdk-lib/aws-lambda';
|
|
85
|
+
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
|
86
|
+
import { HttpApi } from '../../../core/api/http-api.js';
|
|
87
|
+
import {
|
|
88
|
+
HttpApiIntegration,
|
|
89
|
+
OperationDetails,
|
|
90
|
+
} from '../../../core/api/utils.js';
|
|
91
|
+
|
|
92
|
+
export interface SnapshotProxyProps {
|
|
93
|
+
/**
|
|
94
|
+
* Cognito User Pool id (e.g. \`us-east-1_abc123\`).
|
|
95
|
+
*/
|
|
96
|
+
readonly userPoolId: string;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Cognito User Pool App Client id.
|
|
100
|
+
*/
|
|
101
|
+
readonly userPoolClientId: string;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* ARN of the Secrets Manager secret holding the Cognito App Client secret.
|
|
105
|
+
* The token handler reads this at runtime to complete the token exchange;
|
|
106
|
+
* the secret is never exposed to the client. Create the secret in your own
|
|
107
|
+
* stack (from your App Client's generated secret) and pass its ARN here.
|
|
108
|
+
*/
|
|
109
|
+
readonly cognitoClientSecretArn: string;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Base URL of the Cognito Hosted UI domain, without a trailing slash
|
|
113
|
+
* (e.g. \`https://my-domain.auth.us-east-1.amazoncognito.com\`). The proxy
|
|
114
|
+
* forwards \`/authorize\` and \`/oauth/token\` to this host.
|
|
115
|
+
*/
|
|
116
|
+
readonly cognitoHostedUiBase: string;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Full URL of the upstream MCP server (e.g. AgentCore Runtime/Gateway
|
|
120
|
+
* endpoint). The proxy forwards requests on its \`/mcp\` path to this URL and
|
|
121
|
+
* rewrites the upstream \`WWW-Authenticate\` header so MCP clients discover
|
|
122
|
+
* this proxy as the protected-resource metadata host.
|
|
123
|
+
*/
|
|
124
|
+
readonly upstreamUrl: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The proxy operations wired to the shared HttpApi construct. The
|
|
128
|
+
// openid-configuration route reuses the authorization-server-metadata handler.
|
|
129
|
+
type SnapshotProxyOperation =
|
|
130
|
+
| 'protectedResourceMetadata'
|
|
131
|
+
| 'authServerMetadata'
|
|
132
|
+
| 'openIdConfiguration'
|
|
133
|
+
| 'register'
|
|
134
|
+
| 'authorize'
|
|
135
|
+
| 'token'
|
|
136
|
+
| 'mcp';
|
|
137
|
+
|
|
138
|
+
const OPERATIONS: Record<SnapshotProxyOperation, OperationDetails> = {
|
|
139
|
+
protectedResourceMetadata: {
|
|
140
|
+
path: '/.well-known/oauth-protected-resource',
|
|
141
|
+
method: 'GET',
|
|
142
|
+
},
|
|
143
|
+
authServerMetadata: {
|
|
144
|
+
path: '/.well-known/oauth-authorization-server',
|
|
145
|
+
method: 'GET',
|
|
146
|
+
},
|
|
147
|
+
openIdConfiguration: {
|
|
148
|
+
path: '/.well-known/openid-configuration',
|
|
149
|
+
method: 'GET',
|
|
150
|
+
},
|
|
151
|
+
register: { path: '/register', method: 'POST' },
|
|
152
|
+
authorize: { path: '/authorize', method: 'GET' },
|
|
153
|
+
token: { path: '/oauth/token', method: 'POST' },
|
|
154
|
+
mcp: { path: '/mcp', method: 'ANY' },
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Resolve a handler's rolldown bundle output directory, relative to this
|
|
158
|
+
// compiled construct file, back to the workspace root.
|
|
159
|
+
const asset = (bundlePathFromRoot: string): lambda.Code =>
|
|
160
|
+
lambda.Code.fromAsset(
|
|
161
|
+
url.fileURLToPath(
|
|
162
|
+
new URL(\`../../../../../../../\${bundlePathFromRoot}\`, import.meta.url),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* OAuth Dynamic Client Registration (DCR) proxy for Amazon Cognito.
|
|
168
|
+
*
|
|
169
|
+
* Lets MCP clients (Claude Code, Kiro CLI, MCP Inspector, ...) authenticate
|
|
170
|
+
* against an Amazon Cognito User Pool while keeping the Hosted UI flow intact,
|
|
171
|
+
* supporting RFC 7591 Dynamic Client Registration, and never exposing the
|
|
172
|
+
* Cognito App Client secret to the client.
|
|
173
|
+
*/
|
|
174
|
+
export class SnapshotProxy extends Construct {
|
|
175
|
+
readonly httpApi: HttpApi<
|
|
176
|
+
SnapshotProxyOperation,
|
|
177
|
+
Record<SnapshotProxyOperation, HttpApiIntegration>
|
|
178
|
+
>;
|
|
179
|
+
readonly proxyUrl: string;
|
|
180
|
+
readonly mcpUrl: string;
|
|
181
|
+
readonly metadataUrl: string;
|
|
182
|
+
readonly authServerMetadataUrl: string;
|
|
183
|
+
readonly tokenEndpoint: string;
|
|
184
|
+
readonly registrationEndpoint: string;
|
|
185
|
+
|
|
186
|
+
constructor(scope: Construct, id: string, props: SnapshotProxyProps) {
|
|
187
|
+
super(scope, id);
|
|
188
|
+
|
|
189
|
+
const region = Stack.of(this).region;
|
|
190
|
+
|
|
191
|
+
const clientSecret = secretsmanager.Secret.fromSecretCompleteArn(
|
|
192
|
+
this,
|
|
193
|
+
'CognitoClientSecret',
|
|
194
|
+
props.cognitoClientSecretArn,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const cognitoIssuer = \`https://cognito-idp.\${region}.amazonaws.com/\${props.userPoolId}\`;
|
|
198
|
+
|
|
199
|
+
const baseEnv: Record<string, string> = {
|
|
200
|
+
COGNITO_CLIENT_ID: props.userPoolClientId,
|
|
201
|
+
COGNITO_CLIENT_SECRET_ARN: clientSecret.secretArn,
|
|
202
|
+
COGNITO_ISSUER: cognitoIssuer,
|
|
203
|
+
COGNITO_HOSTED_UI_BASE: props.cognitoHostedUiBase,
|
|
204
|
+
COGNITO_REGION: region,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Lambda functions use their auto-created log group; access logging for the
|
|
208
|
+
// API is handled by the shared HttpApi construct's KMS-encrypted log group.
|
|
209
|
+
const makeFn = (
|
|
210
|
+
id_: string,
|
|
211
|
+
bundlePathFromRoot: string,
|
|
212
|
+
overrides?: Partial<lambda.FunctionProps>,
|
|
213
|
+
): lambda.Function =>
|
|
214
|
+
new lambda.Function(this, id_, {
|
|
215
|
+
code: asset(bundlePathFromRoot),
|
|
216
|
+
handler: 'index.handler',
|
|
217
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
218
|
+
architecture: lambda.Architecture.ARM_64,
|
|
219
|
+
timeout: Duration.seconds(10),
|
|
220
|
+
memorySize: 256,
|
|
221
|
+
environment: baseEnv,
|
|
222
|
+
tracing: lambda.Tracing.ACTIVE,
|
|
223
|
+
...overrides,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const protectedResourceFn = makeFn(
|
|
227
|
+
'ProtectedResourceFn',
|
|
228
|
+
'dist/snapshot-proxy/bundle/protected-resource-metadata',
|
|
229
|
+
);
|
|
230
|
+
const authServerFn = makeFn(
|
|
231
|
+
'AuthServerFn',
|
|
232
|
+
'dist/snapshot-proxy/bundle/authorization-server-metadata',
|
|
233
|
+
);
|
|
234
|
+
const registerFn = makeFn(
|
|
235
|
+
'RegisterFn',
|
|
236
|
+
'dist/snapshot-proxy/bundle/register',
|
|
237
|
+
);
|
|
238
|
+
const authorizeFn = makeFn(
|
|
239
|
+
'AuthorizeFn',
|
|
240
|
+
'dist/snapshot-proxy/bundle/authorize',
|
|
241
|
+
);
|
|
242
|
+
const tokenFn = makeFn('TokenFn', 'dist/snapshot-proxy/bundle/token');
|
|
243
|
+
|
|
244
|
+
clientSecret.grantRead(tokenFn);
|
|
245
|
+
|
|
246
|
+
// The MCP proxy Lambda buffers upstream responses, so it is given more time
|
|
247
|
+
// (up to the API Gateway HTTP API integration limit) and memory.
|
|
248
|
+
const mcpProxyFn = makeFn(
|
|
249
|
+
'McpProxyFn',
|
|
250
|
+
'dist/snapshot-proxy/bundle/mcp-proxy',
|
|
251
|
+
{
|
|
252
|
+
timeout: Duration.seconds(29),
|
|
253
|
+
memorySize: 512,
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const int = (id_: string, fn: lambda.Function): HttpApiIntegration => ({
|
|
258
|
+
integration: new integrations.HttpLambdaIntegration(id_, fn),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
this.httpApi = new HttpApi(this, 'HttpApi', {
|
|
262
|
+
apiName: \`\${id}-mcp-auth-proxy\`,
|
|
263
|
+
description: 'MCP-compatible OAuth proxy for Amazon Cognito',
|
|
264
|
+
corsPreflight: {
|
|
265
|
+
allowOrigins: ['*'],
|
|
266
|
+
allowMethods: [
|
|
267
|
+
apigwv2.CorsHttpMethod.GET,
|
|
268
|
+
apigwv2.CorsHttpMethod.POST,
|
|
269
|
+
apigwv2.CorsHttpMethod.DELETE,
|
|
270
|
+
apigwv2.CorsHttpMethod.OPTIONS,
|
|
271
|
+
],
|
|
272
|
+
allowHeaders: ['authorization', 'content-type', 'mcp-protocol-version'],
|
|
273
|
+
maxAge: Duration.minutes(10),
|
|
274
|
+
},
|
|
275
|
+
operations: OPERATIONS,
|
|
276
|
+
integrations: {
|
|
277
|
+
protectedResourceMetadata: int(
|
|
278
|
+
'ProtectedResourceInt',
|
|
279
|
+
protectedResourceFn,
|
|
280
|
+
),
|
|
281
|
+
authServerMetadata: int('AuthServerInt', authServerFn),
|
|
282
|
+
openIdConfiguration: int('OpenIdConfigInt', authServerFn),
|
|
283
|
+
register: int('RegisterInt', registerFn),
|
|
284
|
+
authorize: int('AuthorizeInt', authorizeFn),
|
|
285
|
+
token: int('TokenInt', tokenFn),
|
|
286
|
+
mcp: int('McpProxyInt', mcpProxyFn),
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
this.proxyUrl = this.httpApi.api.apiEndpoint;
|
|
291
|
+
this.mcpUrl = \`\${this.proxyUrl}/mcp\`;
|
|
292
|
+
this.metadataUrl = \`\${this.proxyUrl}/.well-known/oauth-protected-resource\`;
|
|
293
|
+
this.authServerMetadataUrl = \`\${this.proxyUrl}/.well-known/oauth-authorization-server\`;
|
|
294
|
+
this.tokenEndpoint = \`\${this.proxyUrl}/oauth/token\`;
|
|
295
|
+
this.registrationEndpoint = \`\${this.proxyUrl}/register\`;
|
|
296
|
+
|
|
297
|
+
// Wire the MCP proxy handler's upstream + metadata URLs after the API is
|
|
298
|
+
// created so its endpoint is resolvable.
|
|
299
|
+
mcpProxyFn.addEnvironment('UPSTREAM_URL', props.upstreamUrl);
|
|
300
|
+
mcpProxyFn.addEnvironment('PROXY_METADATA_URL', this.metadataUrl);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
"
|
|
304
|
+
`;
|
|
305
|
+
|
|
306
|
+
exports[`ts#dcr-proxy generator > matches snapshot for generated construct and a handler > dcr-proxy-mcp-proxy-handler.ts 1`] = `
|
|
307
|
+
"import type {
|
|
308
|
+
APIGatewayProxyHandlerV2,
|
|
309
|
+
APIGatewayProxyStructuredResultV2,
|
|
310
|
+
} from 'aws-lambda';
|
|
311
|
+
|
|
312
|
+
const UPSTREAM_URL = process.env.UPSTREAM_URL!;
|
|
313
|
+
const PROXY_METADATA_URL = process.env.PROXY_METADATA_URL!;
|
|
314
|
+
|
|
315
|
+
// Headers stripped when relaying requests/responses through the proxy:
|
|
316
|
+
// RFC 7230 hop-by-hop headers (scoped to a single connection, not the next
|
|
317
|
+
// hop), plus entity headers that fetch() re-derives from the actual body -
|
|
318
|
+
// forwarding stale values would corrupt the response.
|
|
319
|
+
const HOP_BY_HOP = new Set([
|
|
320
|
+
'connection',
|
|
321
|
+
'keep-alive',
|
|
322
|
+
'proxy-authenticate',
|
|
323
|
+
'proxy-authorization',
|
|
324
|
+
'te',
|
|
325
|
+
'trailers',
|
|
326
|
+
'transfer-encoding',
|
|
327
|
+
'upgrade',
|
|
328
|
+
'content-length',
|
|
329
|
+
'content-encoding',
|
|
330
|
+
'host',
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
export const handler: APIGatewayProxyHandlerV2 = async (
|
|
334
|
+
event,
|
|
335
|
+
): Promise<APIGatewayProxyStructuredResultV2> => {
|
|
336
|
+
const method = event.requestContext.http.method;
|
|
337
|
+
const qs = event.rawQueryString ? \`?\${event.rawQueryString}\` : '';
|
|
338
|
+
const url = \`\${UPSTREAM_URL}\${qs}\`;
|
|
339
|
+
|
|
340
|
+
const fwdHeaders = new Headers();
|
|
341
|
+
for (const [k, v] of Object.entries(event.headers ?? {})) {
|
|
342
|
+
if (!v) continue;
|
|
343
|
+
if (HOP_BY_HOP.has(k.toLowerCase())) continue;
|
|
344
|
+
fwdHeaders.set(k, v);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const reqBody = event.body
|
|
348
|
+
? event.isBase64Encoded
|
|
349
|
+
? Buffer.from(event.body, 'base64')
|
|
350
|
+
: event.body
|
|
351
|
+
: undefined;
|
|
352
|
+
|
|
353
|
+
const upstream = await fetch(url, {
|
|
354
|
+
method,
|
|
355
|
+
headers: fwdHeaders,
|
|
356
|
+
body: reqBody as unknown as ArrayBuffer | string | undefined,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const respHeaders: Record<string, string> = {};
|
|
360
|
+
upstream.headers.forEach((v, k) => {
|
|
361
|
+
if (HOP_BY_HOP.has(k.toLowerCase())) return;
|
|
362
|
+
respHeaders[k] = v;
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Rewrite WWW-Authenticate so MCP clients discover this proxy
|
|
366
|
+
// as the protected-resource metadata host instead of the upstream.
|
|
367
|
+
const authKey = Object.keys(respHeaders).find(
|
|
368
|
+
(k) => k.toLowerCase() === 'www-authenticate',
|
|
369
|
+
);
|
|
370
|
+
if (authKey) {
|
|
371
|
+
respHeaders[authKey] = respHeaders[authKey].replace(
|
|
372
|
+
/resource_metadata="[^"]*"/i,
|
|
373
|
+
\`resource_metadata="\${PROXY_METADATA_URL}"\`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
378
|
+
const contentType = (
|
|
379
|
+
respHeaders['content-type'] ??
|
|
380
|
+
respHeaders['Content-Type'] ??
|
|
381
|
+
''
|
|
382
|
+
).toLowerCase();
|
|
383
|
+
const isText =
|
|
384
|
+
contentType.includes('json') ||
|
|
385
|
+
contentType.includes('text') ||
|
|
386
|
+
contentType.includes('xml') ||
|
|
387
|
+
contentType.includes('event-stream');
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
statusCode: upstream.status,
|
|
391
|
+
headers: respHeaders,
|
|
392
|
+
body: isText ? buf.toString('utf-8') : buf.toString('base64'),
|
|
393
|
+
isBase64Encoded: !isText,
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
"
|
|
397
|
+
`;
|
|
398
|
+
|
|
399
|
+
exports[`ts#dcr-proxy generator > matches snapshot for generated construct and a handler > dcr-proxy-protected-resource-metadata-handler.ts 1`] = `
|
|
400
|
+
"import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
|
|
401
|
+
|
|
402
|
+
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
|
|
403
|
+
const proxyUrl = \`https://\${event.requestContext.domainName}\`;
|
|
404
|
+
return {
|
|
405
|
+
statusCode: 200,
|
|
406
|
+
headers: { 'content-type': 'application/json' },
|
|
407
|
+
body: JSON.stringify({
|
|
408
|
+
resource: \`\${proxyUrl}/mcp\`,
|
|
409
|
+
authorization_servers: [proxyUrl],
|
|
410
|
+
}),
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
"
|
|
414
|
+
`;
|
|
415
|
+
|
|
416
|
+
exports[`ts#dcr-proxy generator > matches snapshot for generated construct and a handler > dcr-proxy-register-handler.ts 1`] = `
|
|
417
|
+
"import type {
|
|
418
|
+
APIGatewayProxyHandlerV2,
|
|
419
|
+
APIGatewayProxyStructuredResultV2,
|
|
420
|
+
} from 'aws-lambda';
|
|
421
|
+
|
|
422
|
+
const COGNITO_CLIENT_ID = process.env.COGNITO_CLIENT_ID!;
|
|
423
|
+
|
|
424
|
+
interface RegisterRequest {
|
|
425
|
+
redirect_uris?: unknown;
|
|
426
|
+
[key: string]: unknown;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function jsonResponse(
|
|
430
|
+
statusCode: number,
|
|
431
|
+
body: unknown,
|
|
432
|
+
): APIGatewayProxyStructuredResultV2 {
|
|
433
|
+
return {
|
|
434
|
+
statusCode,
|
|
435
|
+
headers: { 'content-type': 'application/json' },
|
|
436
|
+
body: JSON.stringify(body),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
|
|
441
|
+
if (!event.body) {
|
|
442
|
+
return jsonResponse(400, {
|
|
443
|
+
error: 'invalid_client_metadata',
|
|
444
|
+
error_description: 'request body is required',
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let req: RegisterRequest;
|
|
449
|
+
try {
|
|
450
|
+
req = JSON.parse(event.body) as RegisterRequest;
|
|
451
|
+
} catch {
|
|
452
|
+
return jsonResponse(400, {
|
|
453
|
+
error: 'invalid_client_metadata',
|
|
454
|
+
error_description: 'request body must be valid JSON',
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const redirectUris = req.redirect_uris;
|
|
459
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
|
|
460
|
+
return jsonResponse(400, {
|
|
461
|
+
error: 'invalid_redirect_uri',
|
|
462
|
+
error_description:
|
|
463
|
+
'redirect_uris is required and must be a non-empty array',
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// The proxy fronts a single Cognito App Client, so Cognito performs the
|
|
468
|
+
// authoritative check: any redirect_uri forwarded to the Hosted UI must be
|
|
469
|
+
// registered as a callback URL on that App Client. The proxy only validates
|
|
470
|
+
// the request shape here and passes the URIs through unchanged.
|
|
471
|
+
for (const uri of redirectUris) {
|
|
472
|
+
if (typeof uri !== 'string') {
|
|
473
|
+
return jsonResponse(400, {
|
|
474
|
+
error: 'invalid_redirect_uri',
|
|
475
|
+
error_description: 'each redirect_uri must be a string',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return jsonResponse(201, {
|
|
481
|
+
client_id: COGNITO_CLIENT_ID,
|
|
482
|
+
redirect_uris: redirectUris,
|
|
483
|
+
token_endpoint_auth_method: 'none',
|
|
484
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
485
|
+
response_types: ['code'],
|
|
486
|
+
});
|
|
487
|
+
};
|
|
488
|
+
"
|
|
489
|
+
`;
|
|
490
|
+
|
|
491
|
+
exports[`ts#dcr-proxy generator > matches snapshot for generated construct and a handler > dcr-proxy-token-handler.ts 1`] = `
|
|
492
|
+
"import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
|
|
493
|
+
import {
|
|
494
|
+
SecretsManagerClient,
|
|
495
|
+
GetSecretValueCommand,
|
|
496
|
+
} from '@aws-sdk/client-secrets-manager';
|
|
497
|
+
|
|
498
|
+
const COGNITO_HOSTED_UI_BASE = process.env.COGNITO_HOSTED_UI_BASE!;
|
|
499
|
+
const COGNITO_CLIENT_ID = process.env.COGNITO_CLIENT_ID!;
|
|
500
|
+
const COGNITO_CLIENT_SECRET_ARN = process.env.COGNITO_CLIENT_SECRET_ARN!;
|
|
501
|
+
const COGNITO_REGION = process.env.COGNITO_REGION!;
|
|
502
|
+
|
|
503
|
+
const sm = new SecretsManagerClient({ region: COGNITO_REGION });
|
|
504
|
+
let cachedSecret: string | undefined;
|
|
505
|
+
|
|
506
|
+
async function getClientSecret(): Promise<string> {
|
|
507
|
+
if (cachedSecret) return cachedSecret;
|
|
508
|
+
const res = await sm.send(
|
|
509
|
+
new GetSecretValueCommand({ SecretId: COGNITO_CLIENT_SECRET_ARN }),
|
|
510
|
+
);
|
|
511
|
+
if (!res.SecretString) {
|
|
512
|
+
throw new Error('client_secret missing in Secrets Manager');
|
|
513
|
+
}
|
|
514
|
+
cachedSecret = res.SecretString;
|
|
515
|
+
return cachedSecret;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
|
|
519
|
+
if (!event.body) {
|
|
520
|
+
return {
|
|
521
|
+
statusCode: 400,
|
|
522
|
+
headers: { 'content-type': 'application/json' },
|
|
523
|
+
body: JSON.stringify({
|
|
524
|
+
error: 'invalid_request',
|
|
525
|
+
error_description: 'request body is required',
|
|
526
|
+
}),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const rawBody = event.isBase64Encoded
|
|
531
|
+
? Buffer.from(event.body, 'base64').toString('utf-8')
|
|
532
|
+
: event.body;
|
|
533
|
+
|
|
534
|
+
const params = new URLSearchParams(rawBody);
|
|
535
|
+
|
|
536
|
+
// Always force the real Cognito client_id and inject the server-side secret.
|
|
537
|
+
// DCR returned the same client_id to every caller, but we re-assert here
|
|
538
|
+
// to keep client_id/secret consistent regardless of what the caller sent.
|
|
539
|
+
params.set('client_id', COGNITO_CLIENT_ID);
|
|
540
|
+
params.set('client_secret', await getClientSecret());
|
|
541
|
+
|
|
542
|
+
// Cognito does not understand RFC 8707 \`resource\`; some MCP clients send it
|
|
543
|
+
// and Cognito has been observed to reject the exchange when present.
|
|
544
|
+
params.delete('resource');
|
|
545
|
+
|
|
546
|
+
const res = await fetch(\`\${COGNITO_HOSTED_UI_BASE}/oauth2/token\`, {
|
|
547
|
+
method: 'POST',
|
|
548
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
549
|
+
body: params.toString(),
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const responseBody = await res.text();
|
|
553
|
+
console.log('[token]', {
|
|
554
|
+
grant_type: params.get('grant_type'),
|
|
555
|
+
status: res.status,
|
|
556
|
+
...(res.ok ? {} : { body: responseBody }),
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
statusCode: res.status,
|
|
561
|
+
headers: {
|
|
562
|
+
'content-type': res.headers.get('content-type') ?? 'application/json',
|
|
563
|
+
},
|
|
564
|
+
body: responseBody,
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
"
|
|
568
|
+
`;
|
|
569
|
+
|
|
570
|
+
exports[`ts#dcr-proxy generator > terraform > matches snapshot for the generated terraform module > dcr-proxy-module.tf 1`] = `
|
|
571
|
+
"# OAuth Dynamic Client Registration (DCR) proxy for Amazon Cognito.
|
|
572
|
+
#
|
|
573
|
+
# Lets MCP clients (Claude Code, Kiro CLI, MCP Inspector, ...) authenticate
|
|
574
|
+
# against an Amazon Cognito User Pool while keeping the Hosted UI flow intact,
|
|
575
|
+
# supporting RFC 7591 Dynamic Client Registration, and never exposing the
|
|
576
|
+
# Cognito App Client secret to the client.
|
|
577
|
+
|
|
578
|
+
terraform {
|
|
579
|
+
required_providers {
|
|
580
|
+
aws = {
|
|
581
|
+
source = "hashicorp/aws"
|
|
582
|
+
version = "6.55.0"
|
|
583
|
+
}
|
|
584
|
+
archive = {
|
|
585
|
+
source = "hashicorp/archive"
|
|
586
|
+
version = "2.8.0"
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
# Cognito configuration (injected by the caller)
|
|
592
|
+
variable "user_pool_id" {
|
|
593
|
+
description = "Cognito User Pool id (e.g. us-east-1_abc123)"
|
|
594
|
+
type = string
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
variable "user_pool_client_id" {
|
|
598
|
+
description = "Cognito User Pool App Client id"
|
|
599
|
+
type = string
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
variable "cognito_client_secret_arn" {
|
|
603
|
+
description = "ARN of the Secrets Manager secret holding the Cognito App Client secret. The token handler reads this at runtime; the secret is never exposed to the client."
|
|
604
|
+
type = string
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
variable "cognito_hosted_ui_base" {
|
|
608
|
+
description = "Base URL of the Cognito Hosted UI domain, without a trailing slash (e.g. https://my-domain.auth.us-east-1.amazoncognito.com)"
|
|
609
|
+
type = string
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
variable "upstream_url" {
|
|
613
|
+
description = "Full URL of the upstream MCP server. The proxy forwards /mcp requests to this URL and rewrites the upstream WWW-Authenticate header."
|
|
614
|
+
type = string
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
variable "env" {
|
|
618
|
+
description = "Additional environment variables applied to all proxy Lambda functions"
|
|
619
|
+
type = map(string)
|
|
620
|
+
default = {}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
# CORS Configuration (passed to the core http-api module)
|
|
624
|
+
variable "cors_allow_headers" {
|
|
625
|
+
description = "List of allowed headers for CORS"
|
|
626
|
+
type = list(string)
|
|
627
|
+
default = ["authorization", "content-type", "mcp-protocol-version"]
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
variable "cors_allow_methods" {
|
|
631
|
+
description = "List of allowed HTTP methods for CORS"
|
|
632
|
+
type = list(string)
|
|
633
|
+
default = ["GET", "POST", "DELETE", "OPTIONS"]
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
variable "cors_allow_origins" {
|
|
637
|
+
description = "List of allowed origins for CORS"
|
|
638
|
+
type = list(string)
|
|
639
|
+
default = ["*"]
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
variable "asset_bucket_name" {
|
|
643
|
+
description = "Name of the shared asset S3 bucket used to stage the Lambda deployment zips. Instantiate the \`core/asset-bucket\` module once per deployment and pass its \`bucket_name\` output here."
|
|
644
|
+
type = string
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
variable "mcp_proxy_timeout" {
|
|
648
|
+
description = "Timeout (seconds) applied to the MCP proxy Lambda. Defaults to 29 (the API Gateway HTTP API integration limit)."
|
|
649
|
+
type = number
|
|
650
|
+
default = 29
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
variable "mcp_proxy_memory_size" {
|
|
654
|
+
description = "Memory size (MB) for the MCP proxy Lambda"
|
|
655
|
+
type = number
|
|
656
|
+
default = 512
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
variable "tags" {
|
|
660
|
+
description = "Tags to apply to all resources"
|
|
661
|
+
type = map(string)
|
|
662
|
+
default = {}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
data "aws_region" "current" {}
|
|
666
|
+
data "aws_caller_identity" "current" {}
|
|
667
|
+
|
|
668
|
+
# The Cognito App Client secret, referenced by ARN so the token handler's role
|
|
669
|
+
# can be scoped to read exactly this secret.
|
|
670
|
+
data "aws_secretsmanager_secret" "cognito_client_secret" {
|
|
671
|
+
arn = var.cognito_client_secret_arn
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
resource "random_string" "suffix" {
|
|
675
|
+
length = 8
|
|
676
|
+
special = false
|
|
677
|
+
upper = false
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
locals {
|
|
681
|
+
aws_account_id = data.aws_caller_identity.current.account_id
|
|
682
|
+
aws_region = data.aws_region.current.region
|
|
683
|
+
|
|
684
|
+
base_env = merge({
|
|
685
|
+
COGNITO_CLIENT_ID = var.user_pool_client_id
|
|
686
|
+
COGNITO_CLIENT_SECRET_ARN = var.cognito_client_secret_arn
|
|
687
|
+
COGNITO_ISSUER = "https://cognito-idp.\${local.aws_region}.amazonaws.com/\${var.user_pool_id}"
|
|
688
|
+
COGNITO_HOSTED_UI_BASE = var.cognito_hosted_ui_base
|
|
689
|
+
COGNITO_REGION = local.aws_region
|
|
690
|
+
}, var.env)
|
|
691
|
+
|
|
692
|
+
# Workspace-root-relative bundle output directory per handler
|
|
693
|
+
handler_bundle_dirs = {
|
|
694
|
+
"protected-resource-metadata" = "dist/snapshot-proxy/bundle/protected-resource-metadata"
|
|
695
|
+
"authorization-server-metadata" = "dist/snapshot-proxy/bundle/authorization-server-metadata"
|
|
696
|
+
"register" = "dist/snapshot-proxy/bundle/register"
|
|
697
|
+
"authorize" = "dist/snapshot-proxy/bundle/authorize"
|
|
698
|
+
"token" = "dist/snapshot-proxy/bundle/token" #checkov:skip=CKV_SECRET_6:Value is a Lambda bundle output path, not a secret
|
|
699
|
+
"mcp-proxy" = "dist/snapshot-proxy/bundle/mcp-proxy"
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
# Handlers deployed with the standard configuration (timeout 10s, mem 256).
|
|
703
|
+
# mcp-proxy is deployed separately since its env/timeout/memory differ.
|
|
704
|
+
standard_handlers = {
|
|
705
|
+
for name, dir in local.handler_bundle_dirs : name => dir if name != "mcp-proxy"
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
# Package each handler's rolldown bundle into a deployment zip
|
|
710
|
+
data "archive_file" "handler_zip" {
|
|
711
|
+
for_each = local.handler_bundle_dirs
|
|
712
|
+
|
|
713
|
+
type = "zip"
|
|
714
|
+
source_dir = "\${path.module}/../../../../../../../\${each.value}"
|
|
715
|
+
output_path = "\${path.module}/../../../../../../../dist/packages/common/terraform/dcr-proxies/snapshot-proxy/\${each.key}/lambda.zip"
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
resource "aws_s3_object" "handler_zip" {
|
|
719
|
+
for_each = local.handler_bundle_dirs
|
|
720
|
+
|
|
721
|
+
bucket = var.asset_bucket_name
|
|
722
|
+
key = "dcr-proxies/snapshot-proxy/\${each.key}/\${data.archive_file.handler_zip[each.key].output_sha256}.zip"
|
|
723
|
+
source = data.archive_file.handler_zip[each.key].output_path
|
|
724
|
+
source_hash = data.archive_file.handler_zip[each.key].output_base64sha256
|
|
725
|
+
etag = data.archive_file.handler_zip[each.key].output_md5
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
# Shared execution role for handlers that do not read the client secret
|
|
729
|
+
resource "aws_iam_role" "proxy" {
|
|
730
|
+
name = "snapshot-proxy-proxy-\${random_string.suffix.result}"
|
|
731
|
+
|
|
732
|
+
assume_role_policy = jsonencode({
|
|
733
|
+
Version = "2012-10-17"
|
|
734
|
+
Statement = [{
|
|
735
|
+
Effect = "Allow"
|
|
736
|
+
Principal = { Service = "lambda.amazonaws.com" }
|
|
737
|
+
Action = "sts:AssumeRole"
|
|
738
|
+
}]
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
tags = var.tags
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
# Dedicated role for the token handler, additionally granted read access to the
|
|
745
|
+
# Cognito App Client secret only.
|
|
746
|
+
resource "aws_iam_role" "token" {
|
|
747
|
+
name = "snapshot-proxy-token-\${random_string.suffix.result}"
|
|
748
|
+
|
|
749
|
+
assume_role_policy = jsonencode({
|
|
750
|
+
Version = "2012-10-17"
|
|
751
|
+
Statement = [{
|
|
752
|
+
Effect = "Allow"
|
|
753
|
+
Principal = { Service = "lambda.amazonaws.com" }
|
|
754
|
+
Action = "sts:AssumeRole"
|
|
755
|
+
}]
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
tags = var.tags
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
resource "aws_iam_role_policy_attachment" "proxy_basic" {
|
|
762
|
+
role = aws_iam_role.proxy.name
|
|
763
|
+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
resource "aws_iam_role_policy_attachment" "proxy_xray" {
|
|
767
|
+
role = aws_iam_role.proxy.name
|
|
768
|
+
policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
resource "aws_iam_role_policy_attachment" "token_basic" {
|
|
772
|
+
role = aws_iam_role.token.name
|
|
773
|
+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
resource "aws_iam_role_policy_attachment" "token_xray" {
|
|
777
|
+
role = aws_iam_role.token.name
|
|
778
|
+
policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
resource "aws_iam_role_policy" "token_secret_read" {
|
|
782
|
+
name = "snapshot-proxy-token-secret-read-\${random_string.suffix.result}"
|
|
783
|
+
role = aws_iam_role.token.id
|
|
784
|
+
|
|
785
|
+
policy = jsonencode({
|
|
786
|
+
Version = "2012-10-17"
|
|
787
|
+
Statement = [{
|
|
788
|
+
Effect = "Allow"
|
|
789
|
+
Action = ["secretsmanager:GetSecretValue"]
|
|
790
|
+
Resource = [data.aws_secretsmanager_secret.cognito_client_secret.arn]
|
|
791
|
+
}]
|
|
792
|
+
})
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
# Standard handler Lambda functions
|
|
796
|
+
resource "aws_lambda_function" "standard" {
|
|
797
|
+
#checkov:skip=CKV_AWS_117:Lambda does not need to be in a VPC for this use case
|
|
798
|
+
#checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this use case
|
|
799
|
+
#checkov:skip=CKV_AWS_272:Code signing not required for this use case
|
|
800
|
+
#checkov:skip=CKV_AWS_115:Concurrent execution limit not required for this use case
|
|
801
|
+
#checkov:skip=CKV_AWS_173:Lambda environment variables encrypted by managed key
|
|
802
|
+
for_each = local.standard_handlers
|
|
803
|
+
|
|
804
|
+
s3_bucket = aws_s3_object.handler_zip[each.key].bucket
|
|
805
|
+
s3_key = aws_s3_object.handler_zip[each.key].key
|
|
806
|
+
function_name = "snapshot-proxy-\${each.key}-\${random_string.suffix.result}"
|
|
807
|
+
role = each.key == "token" ? aws_iam_role.token.arn : aws_iam_role.proxy.arn
|
|
808
|
+
handler = "index.handler"
|
|
809
|
+
runtime = "nodejs22.x"
|
|
810
|
+
architectures = ["arm64"]
|
|
811
|
+
source_code_hash = data.archive_file.handler_zip[each.key].output_base64sha256
|
|
812
|
+
timeout = 10
|
|
813
|
+
memory_size = 256
|
|
814
|
+
|
|
815
|
+
tracing_config {
|
|
816
|
+
mode = "Active"
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
environment {
|
|
820
|
+
variables = local.base_env
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
tags = var.tags
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
# MCP proxy Lambda function (extended env, timeout and memory)
|
|
827
|
+
resource "aws_lambda_function" "mcp_proxy" {
|
|
828
|
+
#checkov:skip=CKV_AWS_117:Lambda does not need to be in a VPC for this use case
|
|
829
|
+
#checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this use case
|
|
830
|
+
#checkov:skip=CKV_AWS_272:Code signing not required for this use case
|
|
831
|
+
#checkov:skip=CKV_AWS_115:Concurrent execution limit not required for this use case
|
|
832
|
+
#checkov:skip=CKV_AWS_173:Lambda environment variables encrypted by managed key
|
|
833
|
+
s3_bucket = aws_s3_object.handler_zip["mcp-proxy"].bucket
|
|
834
|
+
s3_key = aws_s3_object.handler_zip["mcp-proxy"].key
|
|
835
|
+
function_name = "snapshot-proxy-mcp-proxy-\${random_string.suffix.result}"
|
|
836
|
+
role = aws_iam_role.proxy.arn
|
|
837
|
+
handler = "index.handler"
|
|
838
|
+
runtime = "nodejs22.x"
|
|
839
|
+
architectures = ["arm64"]
|
|
840
|
+
source_code_hash = data.archive_file.handler_zip["mcp-proxy"].output_base64sha256
|
|
841
|
+
timeout = var.mcp_proxy_timeout
|
|
842
|
+
memory_size = var.mcp_proxy_memory_size
|
|
843
|
+
|
|
844
|
+
tracing_config {
|
|
845
|
+
mode = "Active"
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
environment {
|
|
849
|
+
variables = merge(local.base_env, {
|
|
850
|
+
UPSTREAM_URL = var.upstream_url
|
|
851
|
+
PROXY_METADATA_URL = "\${module.http_api.api_endpoint}/.well-known/oauth-protected-resource"
|
|
852
|
+
})
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
tags = var.tags
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
# HTTP API Gateway (shared core module)
|
|
859
|
+
module "http_api" {
|
|
860
|
+
source = "../../../core/api/http-api"
|
|
861
|
+
|
|
862
|
+
api_name = "snapshot-proxy-mcp-auth-proxy-\${random_string.suffix.result}"
|
|
863
|
+
api_description = "MCP-compatible OAuth proxy for Amazon Cognito"
|
|
864
|
+
stage_name = "$default"
|
|
865
|
+
stage_auto_deploy = true
|
|
866
|
+
cors_allow_headers = var.cors_allow_headers
|
|
867
|
+
cors_allow_methods = var.cors_allow_methods
|
|
868
|
+
cors_allow_origins = var.cors_allow_origins
|
|
869
|
+
|
|
870
|
+
tags = var.tags
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
# Integrations
|
|
874
|
+
resource "aws_apigatewayv2_integration" "standard" {
|
|
875
|
+
for_each = local.standard_handlers
|
|
876
|
+
|
|
877
|
+
api_id = module.http_api.api_id
|
|
878
|
+
integration_type = "AWS_PROXY"
|
|
879
|
+
integration_uri = aws_lambda_function.standard[each.key].invoke_arn
|
|
880
|
+
payload_format_version = "2.0"
|
|
881
|
+
timeout_milliseconds = 30000
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
resource "aws_apigatewayv2_integration" "mcp_proxy" {
|
|
885
|
+
api_id = module.http_api.api_id
|
|
886
|
+
integration_type = "AWS_PROXY"
|
|
887
|
+
integration_uri = aws_lambda_function.mcp_proxy.invoke_arn
|
|
888
|
+
payload_format_version = "2.0"
|
|
889
|
+
timeout_milliseconds = 29000
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
# Routes. jwks_uri/issuer are handled by Cognito; the proxy hosts the OAuth
|
|
893
|
+
# metadata, authorize, token and registration endpoints plus the /mcp proxy.
|
|
894
|
+
locals {
|
|
895
|
+
routes = {
|
|
896
|
+
"GET /.well-known/oauth-protected-resource" = "protected-resource-metadata"
|
|
897
|
+
"GET /.well-known/oauth-authorization-server" = "authorization-server-metadata"
|
|
898
|
+
"GET /.well-known/openid-configuration" = "authorization-server-metadata"
|
|
899
|
+
"POST /register" = "register"
|
|
900
|
+
"GET /authorize" = "authorize"
|
|
901
|
+
"POST /oauth/token" = "token"
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
# OAuth discovery, registration and authorization endpoints are part of the
|
|
906
|
+
# public DCR/OAuth flow and must be reachable without prior authorization.
|
|
907
|
+
resource "aws_apigatewayv2_route" "standard" {
|
|
908
|
+
#checkov:skip=CKV_AWS_309:Public OAuth/DCR endpoints must be accessible without authorization
|
|
909
|
+
for_each = local.routes
|
|
910
|
+
|
|
911
|
+
api_id = module.http_api.api_id
|
|
912
|
+
route_key = each.key
|
|
913
|
+
target = "integrations/\${aws_apigatewayv2_integration.standard[each.value].id}"
|
|
914
|
+
authorization_type = "NONE"
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
# The /mcp route enforces authorization at the upstream MCP server, so the
|
|
918
|
+
# proxy route itself is intentionally left open.
|
|
919
|
+
resource "aws_apigatewayv2_route" "mcp_proxy" {
|
|
920
|
+
#checkov:skip=CKV_AWS_309:Authorization is enforced by the upstream MCP server, not this proxy route
|
|
921
|
+
api_id = module.http_api.api_id
|
|
922
|
+
route_key = "ANY /mcp"
|
|
923
|
+
target = "integrations/\${aws_apigatewayv2_integration.mcp_proxy.id}"
|
|
924
|
+
authorization_type = "NONE"
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
# Permissions for API Gateway to invoke each Lambda
|
|
928
|
+
resource "aws_lambda_permission" "standard" {
|
|
929
|
+
for_each = local.standard_handlers
|
|
930
|
+
|
|
931
|
+
statement_id = "AllowAPIGatewayInvoke"
|
|
932
|
+
action = "lambda:InvokeFunction"
|
|
933
|
+
function_name = aws_lambda_function.standard[each.key].function_name
|
|
934
|
+
principal = "apigateway.amazonaws.com"
|
|
935
|
+
source_arn = "\${module.http_api.api_execution_arn}/*/*"
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
resource "aws_lambda_permission" "mcp_proxy" {
|
|
939
|
+
statement_id = "AllowAPIGatewayInvoke"
|
|
940
|
+
action = "lambda:InvokeFunction"
|
|
941
|
+
function_name = aws_lambda_function.mcp_proxy.function_name
|
|
942
|
+
principal = "apigateway.amazonaws.com"
|
|
943
|
+
source_arn = "\${module.http_api.api_execution_arn}/*/*"
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
# Outputs
|
|
947
|
+
output "proxy_url" {
|
|
948
|
+
description = "Base URL of the DCR proxy"
|
|
949
|
+
value = module.http_api.api_endpoint
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
output "mcp_url" {
|
|
953
|
+
description = "MCP endpoint proxied to the upstream server"
|
|
954
|
+
value = "\${module.http_api.api_endpoint}/mcp"
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
output "metadata_url" {
|
|
958
|
+
description = "OAuth protected-resource metadata URL"
|
|
959
|
+
value = "\${module.http_api.api_endpoint}/.well-known/oauth-protected-resource"
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
output "auth_server_metadata_url" {
|
|
963
|
+
description = "OAuth authorization-server metadata URL"
|
|
964
|
+
value = "\${module.http_api.api_endpoint}/.well-known/oauth-authorization-server"
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
output "token_endpoint" {
|
|
968
|
+
description = "OAuth token endpoint"
|
|
969
|
+
value = "\${module.http_api.api_endpoint}/oauth/token"
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
output "registration_endpoint" {
|
|
973
|
+
description = "Dynamic Client Registration endpoint"
|
|
974
|
+
value = "\${module.http_api.api_endpoint}/register"
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
output "api_id" {
|
|
978
|
+
description = "ID of the HTTP API Gateway"
|
|
979
|
+
value = module.http_api.api_id
|
|
980
|
+
}
|
|
981
|
+
"
|
|
982
|
+
`;
|