diff --git a/cognito-appsync-bedrock/README.md b/cognito-appsync-bedrock/README.md new file mode 100644 index 000000000..ab590e98e --- /dev/null +++ b/cognito-appsync-bedrock/README.md @@ -0,0 +1,97 @@ +# AWS AppSync and Amazon Cognito to Amazon Bedrock via Lambda Resolver + +This pattern demonstrates how to invoke Amazon Bedrock models from AWS AppSync using a Lambda resolver, with user authentication handled by Amazon Cognito. + +> **Note**: This application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Prerequisites + +- [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +- [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- [Node and NPM](https://nodejs.org/en/download/) installed (Node.js 20.x recommended as used by the Lambda function) +- [AWS Cloud Development Kit (AWS CDK)](https://docs.aws.amazon.com/cdk/v2/guide/cli.html) installed +- Make sure to enable the **Anthropic Claude 3 Sonnet** model (e.g., `anthropic.claude-3-sonnet-20240229-v1:0`) in the [Amazon Bedrock console](https://console.aws.amazon.com/bedrock/home#/modelaccess) for the AWS region you intend to deploy this stack + +## Architecture + +This pattern sets up an AWS AppSync GraphQL API configured with Amazon Cognito User Pools for authentication. Authenticated users can send a prompt through a GraphQL mutation (`invoke`). + +### Flow + +1. **Authentication**: Users are authenticated against an Amazon Cognito User Pool +2. **AppSync Mutation**: The client sends a GraphQL mutation including the prompt and a valid Cognito ID token +3. **Lambda Resolver**: AppSync uses a Lambda resolver to process the `invoke` mutation +4. **Bedrock Invocation**: The AWS Lambda function (`src/lambda/invokeBedrock/index.ts`) receives the prompt from AppSync. It then constructs a request and invokes the specified Amazon Bedrock model (defaulting to Anthropic Claude 3 Sonnet). The Lambda function has the necessary IAM permissions to call the Bedrock `InvokeModel` API +5. **Response**: The Bedrock model processes the prompt and returns a response. The Lambda function forwards this response back to AppSync, which then relays it to the client + +### Resources + +The AWS CDK script (`lib/cdk-stack.ts`) provisions the following resources: + +- An Amazon Cognito User Pool and User Pool Client +- An AWS AppSync GraphQL API (`schema.gql`) with Cognito User Pool as the default authorization mode +- An AWS Lambda function with permissions to invoke the specified Bedrock model +- An AppSync Lambda Data Source and a Resolver connecting the `invoke` mutation to the Lambda function +- CloudFormation outputs for easy access to API endpoints and Cognito identifiers + +The Bedrock model ID, Anthropic API version, and other inference parameters (like `max_tokens`, `temperature`) can be configured via environment variables in the Lambda function, as defined in `lib/cdk-stack.ts` and used in `src/lambda/invokeBedrock/index.ts`. + +## Deployment + +1. Clone the repository: + + ```bash + git clone https://github.com/aws-samples/serverless-patterns + ``` + +2. Navigate to the project directory: + + ```bash + cd cognito-appsync-bedrock + ``` + +3. Install dependencies: + + ```bash + npm install + ``` + +4. Deploy the stack: + ```bash + npm run deploy + ``` + This will generate a `cdk-outputs.json` file containing the stack outputs. + +## Testing + +The project includes integration tests in `test/cdk.test.ts`. These tests will: + +1. Read deployed stack outputs from `cdk-outputs.json` +2. Programmatically sign up a new user in the Cognito User Pool +3. Admin-confirm the new user +4. Log in with the new user to obtain an ID token +5. Use the ID token to make an authenticated `invoke` mutation to the AppSync API with a sample prompt +6. Verify that the response from Bedrock (via AppSync) is received and contains expected content + +To run the tests: + +```bash +npm run test +``` + +> **Note**: The tests require the CDK stack to be deployed first, as they rely on the cdk-outputs.json file. Ensure the AWS region and credentials configured for your AWS CLI (and thus for the tests) match where the stack was deployed. + +## Cleanup + +To delete the stack and all associated resources: + +```bash +cdk destroy --all +``` + +--- + +Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/cognito-appsync-bedrock/cdk/.gitignore b/cognito-appsync-bedrock/cdk/.gitignore new file mode 100644 index 000000000..169de64a8 --- /dev/null +++ b/cognito-appsync-bedrock/cdk/.gitignore @@ -0,0 +1,9 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out +cdk-outputs.json \ No newline at end of file diff --git a/cognito-appsync-bedrock/cdk/.npmignore b/cognito-appsync-bedrock/cdk/.npmignore new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/cognito-appsync-bedrock/cdk/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/cognito-appsync-bedrock/cdk/README.md b/cognito-appsync-bedrock/cdk/README.md new file mode 100644 index 000000000..ab590e98e --- /dev/null +++ b/cognito-appsync-bedrock/cdk/README.md @@ -0,0 +1,97 @@ +# AWS AppSync and Amazon Cognito to Amazon Bedrock via Lambda Resolver + +This pattern demonstrates how to invoke Amazon Bedrock models from AWS AppSync using a Lambda resolver, with user authentication handled by Amazon Cognito. + +> **Note**: This application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Prerequisites + +- [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +- [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- [Node and NPM](https://nodejs.org/en/download/) installed (Node.js 20.x recommended as used by the Lambda function) +- [AWS Cloud Development Kit (AWS CDK)](https://docs.aws.amazon.com/cdk/v2/guide/cli.html) installed +- Make sure to enable the **Anthropic Claude 3 Sonnet** model (e.g., `anthropic.claude-3-sonnet-20240229-v1:0`) in the [Amazon Bedrock console](https://console.aws.amazon.com/bedrock/home#/modelaccess) for the AWS region you intend to deploy this stack + +## Architecture + +This pattern sets up an AWS AppSync GraphQL API configured with Amazon Cognito User Pools for authentication. Authenticated users can send a prompt through a GraphQL mutation (`invoke`). + +### Flow + +1. **Authentication**: Users are authenticated against an Amazon Cognito User Pool +2. **AppSync Mutation**: The client sends a GraphQL mutation including the prompt and a valid Cognito ID token +3. **Lambda Resolver**: AppSync uses a Lambda resolver to process the `invoke` mutation +4. **Bedrock Invocation**: The AWS Lambda function (`src/lambda/invokeBedrock/index.ts`) receives the prompt from AppSync. It then constructs a request and invokes the specified Amazon Bedrock model (defaulting to Anthropic Claude 3 Sonnet). The Lambda function has the necessary IAM permissions to call the Bedrock `InvokeModel` API +5. **Response**: The Bedrock model processes the prompt and returns a response. The Lambda function forwards this response back to AppSync, which then relays it to the client + +### Resources + +The AWS CDK script (`lib/cdk-stack.ts`) provisions the following resources: + +- An Amazon Cognito User Pool and User Pool Client +- An AWS AppSync GraphQL API (`schema.gql`) with Cognito User Pool as the default authorization mode +- An AWS Lambda function with permissions to invoke the specified Bedrock model +- An AppSync Lambda Data Source and a Resolver connecting the `invoke` mutation to the Lambda function +- CloudFormation outputs for easy access to API endpoints and Cognito identifiers + +The Bedrock model ID, Anthropic API version, and other inference parameters (like `max_tokens`, `temperature`) can be configured via environment variables in the Lambda function, as defined in `lib/cdk-stack.ts` and used in `src/lambda/invokeBedrock/index.ts`. + +## Deployment + +1. Clone the repository: + + ```bash + git clone https://github.com/aws-samples/serverless-patterns + ``` + +2. Navigate to the project directory: + + ```bash + cd cognito-appsync-bedrock + ``` + +3. Install dependencies: + + ```bash + npm install + ``` + +4. Deploy the stack: + ```bash + npm run deploy + ``` + This will generate a `cdk-outputs.json` file containing the stack outputs. + +## Testing + +The project includes integration tests in `test/cdk.test.ts`. These tests will: + +1. Read deployed stack outputs from `cdk-outputs.json` +2. Programmatically sign up a new user in the Cognito User Pool +3. Admin-confirm the new user +4. Log in with the new user to obtain an ID token +5. Use the ID token to make an authenticated `invoke` mutation to the AppSync API with a sample prompt +6. Verify that the response from Bedrock (via AppSync) is received and contains expected content + +To run the tests: + +```bash +npm run test +``` + +> **Note**: The tests require the CDK stack to be deployed first, as they rely on the cdk-outputs.json file. Ensure the AWS region and credentials configured for your AWS CLI (and thus for the tests) match where the stack was deployed. + +## Cleanup + +To delete the stack and all associated resources: + +```bash +cdk destroy --all +``` + +--- + +Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/cognito-appsync-bedrock/cdk/bin/cdk.ts b/cognito-appsync-bedrock/cdk/bin/cdk.ts new file mode 100644 index 000000000..04e36d211 --- /dev/null +++ b/cognito-appsync-bedrock/cdk/bin/cdk.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import * as cdk from "aws-cdk-lib"; +import { CdkStack } from "../lib/cdk-stack"; + +const app = new cdk.App(); +new CdkStack(app, "AppsyncBedrockCognitoStack", { + description: + "AWS CDK Stack for AppSync with Bedrock integration using Cognito User Pool authenticator.", +}); diff --git a/cognito-appsync-bedrock/cdk/cdk.json b/cognito-appsync-bedrock/cdk/cdk.json new file mode 100644 index 000000000..48b0cb77a --- /dev/null +++ b/cognito-appsync-bedrock/cdk/cdk.json @@ -0,0 +1,88 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/cdk.ts", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false, + "@aws-cdk/aws-ecs:disableEcsImdsBlocking": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, + "@aws-cdk/core:enableAdditionalMetadataCollection": true, + "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": true + } +} diff --git a/cognito-appsync-bedrock/cdk/jest.config.js b/cognito-appsync-bedrock/cdk/jest.config.js new file mode 100644 index 000000000..08263b895 --- /dev/null +++ b/cognito-appsync-bedrock/cdk/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; diff --git a/cognito-appsync-bedrock/cdk/lib/cdk-stack.ts b/cognito-appsync-bedrock/cdk/lib/cdk-stack.ts new file mode 100644 index 000000000..f09ed8ab6 --- /dev/null +++ b/cognito-appsync-bedrock/cdk/lib/cdk-stack.ts @@ -0,0 +1,153 @@ +import * as cdk from "aws-cdk-lib"; +import { + GraphqlApi, + SchemaFile, + AuthorizationType, + FieldLogLevel, + UserPoolDefaultAction, +} from "aws-cdk-lib/aws-appsync"; +import * as cognito from "aws-cdk-lib/aws-cognito"; +import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam"; // Removed Role, ServicePrincipal if not used elsewhere +import { Construct } from "constructs"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; +import * as path from "path"; +// import { MappingTemplate } from "aws-cdk-lib/aws-appsync"; // Not strictly needed for default Lambda resolver + +export class CdkStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // --- Cognito User Pool and Client --- + // (This section remains the same as your previous version) + const userPool = new cognito.UserPool(this, "AppsyncBedrockUserPool", { + userPoolName: "AppsyncBedrockUserPool", + selfSignUpEnabled: true, + signInAliases: { email: true, username: false }, + autoVerify: { email: true }, + passwordPolicy: { + minLength: 8, + requireLowercase: true, + requireUppercase: true, + requireDigits: true, + requireSymbols: false, + }, + standardAttributes: { + email: { required: true, mutable: true }, + }, + deletionProtection: false, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const userPoolClient = new cognito.UserPoolClient( + this, + "AppsyncBedrockUserPoolClient", + { + userPool: userPool, + userPoolClientName: "AppsyncBedrockClient", + authFlows: { + userSrp: true, + userPassword: true, + adminUserPassword: true, + }, + } + ); + + // --- AppSync GraphQL API --- + // (This section remains the same) + const api = new GraphqlApi(this, "AppsyncBedrockApi", { + name: "AppsyncBedrockApi", + definition: { + schema: SchemaFile.fromAsset("src/schema.gql"), + }, + authorizationConfig: { + defaultAuthorization: { + authorizationType: AuthorizationType.USER_POOL, + userPoolConfig: { + userPool: userPool, + defaultAction: UserPoolDefaultAction.ALLOW, + }, + }, + }, + xrayEnabled: true, + logConfig: { + excludeVerboseContent: false, + fieldLogLevel: FieldLogLevel.ALL, + }, + }); + + // --- AWS Lambda Function for Bedrock Invocation --- + // IMPORTANT: Update MODEL_ID to your specific Claude 3 model if different. + const modelIdForLambda = "anthropic.claude-3-sonnet-20240229-v1:0"; // Or your specific ID: "anthropic.claude-3-7-sonnet-20250219-v1:0" + // if you've confirmed it's correct and accessible. + + const bedrockInvokeLambda = new NodejsFunction( + this, + "BedrockInvokeLambdaHandler", + { + runtime: lambda.Runtime.NODEJS_20_X, + handler: "handler", + entry: path.join(__dirname, "../src/lambda/invokeBedrock/index.ts"), + memorySize: 512, // Increased memory for potentially larger SDK and payloads + timeout: cdk.Duration.seconds(30), // Increased timeout + bundling: { + minify: false, + sourceMap: true, + }, + environment: { + MODEL_ID: modelIdForLambda, + ANTHROPIC_VERSION: "bedrock-2023-05-31", // Required for Claude 3 Messages API + // You can add more env vars here to configure temperature, max_tokens etc. + // MAX_TOKENS: "1024", + // TEMPERATURE: "0.7", + }, + } + ); + + // Grant Lambda permission to invoke the specific Bedrock model + bedrockInvokeLambda.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + resources: [ + // IMPORTANT: Ensure this ARN matches the modelIdForLambda + `arn:aws:bedrock:${this.region}::foundation-model/${modelIdForLambda}`, + ], + actions: ["bedrock:InvokeModel"], + }) + ); + + // --- AppSync Lambda Data Source --- + // (This section remains the same) + const lambdaDataSource = api.addLambdaDataSource( + "BedrockLambdaDSSource", + bedrockInvokeLambda, + { + name: "BedrockLambdaDataSource", + description: "Lambda function to invoke Bedrock models (Claude 3)", + } + ); + + // --- AppSync Resolver for Mutation.invoke using Lambda Data Source --- + // (This section remains the same as Lambda still returns a string) + lambdaDataSource.createResolver("InvokeMutationLambdaResolver", { + typeName: "Mutation", + fieldName: "invoke", + }); + + // --- CloudFormation Outputs --- + // (This section remains largely the same, added Lambda name) + new cdk.CfnOutput(this, "GraphQLApiUrl", { value: api.graphqlUrl }); + new cdk.CfnOutput(this, "GraphQLApiId", { value: api.apiId }); + new cdk.CfnOutput(this, "AWSRegion", { value: this.region }); + new cdk.CfnOutput(this, "CognitoUserPoolId", { + value: userPool.userPoolId, + }); + new cdk.CfnOutput(this, "CognitoUserPoolClientId", { + value: userPoolClient.userPoolClientId, + }); + new cdk.CfnOutput(this, "BedrockInvokeLambdaName", { + value: bedrockInvokeLambda.functionName, + }); + new cdk.CfnOutput(this, "BedrockModelIdUsed", { value: modelIdForLambda }); + } +} diff --git a/cognito-appsync-bedrock/cdk/package.json b/cognito-appsync-bedrock/cdk/package.json new file mode 100644 index 000000000..ab85c8241 --- /dev/null +++ b/cognito-appsync-bedrock/cdk/package.json @@ -0,0 +1,34 @@ +{ + "name": "cdk", + "version": "0.1.0", + "bin": { + "cdk": "bin/cdk.js" + }, + "scripts": { + "deploy": "cdk deploy --outputs-file ./cdk-outputs.json", + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "cdk" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "22.7.9", + "aws-cdk": "2.1005.0", + "esbuild": "^0.25.4", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "typescript": "~5.6.3" + }, + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/client-cognito-identity-provider": "^3.817.0", + "@aws-sdk/client-bedrock-runtime": "^3.817.0", + "@aws-sdk/credential-provider-node": "^3.817.0", + "@aws-sdk/protocol-http": "^3.374.0", + "@aws-sdk/signature-v4": "^3.374.0", + "aws-cdk-lib": "2.181.1", + "constructs": "^10.0.0" + } +} diff --git a/cognito-appsync-bedrock/cdk/src/appsyncRequest.ts b/cognito-appsync-bedrock/cdk/src/appsyncRequest.ts new file mode 100644 index 000000000..b7bf9642d --- /dev/null +++ b/cognito-appsync-bedrock/cdk/src/appsyncRequest.ts @@ -0,0 +1,130 @@ +import { SignatureV4 } from "@aws-sdk/signature-v4"; +import { Sha256 } from "@aws-crypto/sha256-js"; +import { defaultProvider } from "@aws-sdk/credential-provider-node"; +import { HttpRequest } from "@aws-sdk/protocol-http"; +import * as https from "https"; + +export interface Operation { + query: string; + operationName: string; + variables: object; +} + +export interface Config { + url: string; + key?: string; + region: string; +} + +export interface RequestParams { + config: Config; + operation: Operation; +} + +export interface GraphQLResult { + data?: T; + errors?: any[]; + extensions?: { [key: string]: any }; +} + +export const AppSyncRequestIAM = async (params: RequestParams) => { + const endpoint = new URL(params.config.url); + const signer = new SignatureV4({ + credentials: defaultProvider(), + region: params.config.region, + service: "appsync", + sha256: Sha256, + }); + + const requestToBeSigned = new HttpRequest({ + hostname: endpoint.host, + port: 443, + path: endpoint.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + host: endpoint.host, + }, + body: JSON.stringify(params.operation), + }); + + const signedRequest = await signer.sign(requestToBeSigned); + + return new Promise((resolve, reject) => { + const httpRequest = https.request( + { ...signedRequest, host: endpoint.hostname }, + (result) => { + result.on("data", (data) => { + resolve(JSON.parse(data.toString())); + }); + } + ); + httpRequest.write(signedRequest.body); + httpRequest.end(); + }); +}; + +export const AppSyncRequestApiKey = async ( + params: RequestParams +) => { + const endpoint = new URL(params.config.url); + + const request = new HttpRequest({ + hostname: endpoint.host, + port: 443, + path: endpoint.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": params.config.key || "", + host: endpoint.host, + }, + body: JSON.stringify(params.operation), + }); + + return new Promise>((resolve, reject) => { + const httpRequest = https.request( + { ...request, host: endpoint.hostname }, + (result) => { + result.on("data", (data) => { + resolve(JSON.parse(data.toString())); + }); + } + ); + httpRequest.write(request.body); + httpRequest.end(); + }); +}; + +export const appSyncRequestCognito = async ( + params: RequestParams, + idToken: string +) => { + const endpoint = new URL(params.config.url); + + const request = new HttpRequest({ + hostname: endpoint.host, + port: 443, + path: endpoint.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: idToken, + host: endpoint.host, + }, + body: JSON.stringify(params.operation), + }); + + return new Promise>((resolve, reject) => { + const httpRequest = https.request( + { ...request, host: endpoint.hostname }, + (result) => { + result.on("data", (data) => { + resolve(JSON.parse(data.toString())); + }); + } + ); + httpRequest.write(request.body); + httpRequest.end(); + }); +}; diff --git a/cognito-appsync-bedrock/cdk/src/lambda/invokeBedrock/index.ts b/cognito-appsync-bedrock/cdk/src/lambda/invokeBedrock/index.ts new file mode 100644 index 000000000..f54d3c766 --- /dev/null +++ b/cognito-appsync-bedrock/cdk/src/lambda/invokeBedrock/index.ts @@ -0,0 +1,135 @@ +import { + BedrockRuntimeClient, + InvokeModelCommand, + InvokeModelCommandInput, +} from "@aws-sdk/client-bedrock-runtime"; + +const region = process.env.AWS_REGION || "us-east-1"; +const client = new BedrockRuntimeClient({ region }); + +interface AppSyncEvent { + arguments: { + prompt: string; + }; +} + +// Expected response structure for Claude 3 Messages API +interface Claude3MessagesResponseBody { + id: string; + type: string; + role: "assistant"; + content: Array<{ + type: "text"; + text: string; + }>; + model: string; + stop_reason: string; + // other fields might exist +} + +export const handler = async (event: AppSyncEvent): Promise => { + console.log("Received AppSync event:", JSON.stringify(event, null, 2)); + + const userPrompt = event.arguments.prompt; + + if ( + !userPrompt || + typeof userPrompt !== "string" || + userPrompt.trim() === "" + ) { + console.error( + "Validation Error: Prompt is required and must be a non-empty string." + ); + throw new Error("Prompt is required and must be a non-empty string."); + } + + // Model ID from environment variable, defaults to a common Claude 3 Sonnet ID. + // IMPORTANT: Replace with your specific model ID if different and verified. + const modelId = + process.env.MODEL_ID || "anthropic.claude-3-sonnet-20240229-v1:0"; + const anthropicVersion = + process.env.ANTHROPIC_VERSION || "bedrock-2023-05-31"; + + // Default parameters from your example, can be overridden by environment variables + const maxTokens = process.env.MAX_TOKENS + ? parseInt(process.env.MAX_TOKENS) + : 200; + const temperature = process.env.TEMPERATURE + ? parseFloat(process.env.TEMPERATURE) + : 1; + const topP = process.env.TOP_P ? parseFloat(process.env.TOP_P) : 0.999; + const topK = process.env.TOP_K ? parseInt(process.env.TOP_K) : 250; + // const stopSequences = process.env.STOP_SEQUENCES ? JSON.parse(process.env.STOP_SEQUENCES) : []; + + // Construct the payload for Claude 3 Messages API + const bedrockRequestBody = { + anthropic_version: anthropicVersion, + max_tokens: maxTokens, + temperature: temperature, + top_p: topP, + top_k: topK, + // stop_sequences: stopSequences, // Bedrock API expects an array of strings + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: userPrompt, + }, + ], + }, + ], + }; + + const invokeParams: InvokeModelCommandInput = { + modelId: modelId, + contentType: "application/json", + accept: "application/json", + body: JSON.stringify(bedrockRequestBody), + }; + + try { + console.log( + `Invoking Bedrock model '${modelId}' with payload: ${JSON.stringify( + bedrockRequestBody + ).substring(0, 500)}...` + ); + const command = new InvokeModelCommand(invokeParams); + const response = await client.send(command); + + const responseBodyString = new TextDecoder().decode(response.body); + const responseBody = JSON.parse( + responseBodyString + ) as Claude3MessagesResponseBody; + + console.log( + "Bedrock raw response body:", + JSON.stringify(responseBody, null, 2) + ); + + if ( + !responseBody.content || + !Array.isArray(responseBody.content) || + responseBody.content.length === 0 || + !responseBody.content[0].text + ) { + console.error( + "Bedrock response error: 'content[0].text' field is missing or invalid.", + responseBody + ); + throw new Error( + "Bedrock response error: 'content[0].text' field is missing or invalid in model output." + ); + } + + return responseBody.content[0].text.trim(); + } catch (error: any) { + console.error("Error invoking Bedrock model:", error); + throw new Error( + `Bedrock invocation failed for model ${modelId}: ${ + error.message || "Unknown error" + }` + ); + } +}; diff --git a/cognito-appsync-bedrock/cdk/src/schema.gql b/cognito-appsync-bedrock/cdk/src/schema.gql new file mode 100644 index 000000000..d6d6c7347 --- /dev/null +++ b/cognito-appsync-bedrock/cdk/src/schema.gql @@ -0,0 +1,12 @@ +schema { + query: Query + mutation: Mutation +} + +type Query { + ping: Boolean +} + +type Mutation { + invoke(prompt: String!): String! +} \ No newline at end of file diff --git a/cognito-appsync-bedrock/cdk/test/cdk.test.ts b/cognito-appsync-bedrock/cdk/test/cdk.test.ts new file mode 100644 index 000000000..86b17a0a3 --- /dev/null +++ b/cognito-appsync-bedrock/cdk/test/cdk.test.ts @@ -0,0 +1,195 @@ +import { + AdminConfirmSignUpCommand, + AdminDeleteUserCommand, + AuthFlowType, + CognitoIdentityProviderClient, + InitiateAuthCommand, + SignUpCommand, +} from "@aws-sdk/client-cognito-identity-provider"; +import { readFileSync } from "fs"; +import { appSyncRequestCognito } from "../src/appsyncRequest"; + +let APPSYNC_URL = ""; +let REGION = ""; +let USER_POOL_ID = ""; +let USER_POOL_CLIENT_ID = ""; +let cognitoClient: CognitoIdentityProviderClient; + +const testUserSuffix = Date.now(); +const TEST_USER_EMAIL = `testuser_${testUserSuffix}@example.com`; +const TEST_USER_PASSWORD = "SecurePassword123!"; + +let idToken: string | null = null; + +beforeAll(async () => { + let cdkOutputs; + try { + const cdkOutputsFile = readFileSync("cdk-outputs.json"); + cdkOutputs = JSON.parse(cdkOutputsFile.toString()); + } catch (error) { + console.error("Failed to read or parse cdk-outputs.json:", error); + throw new Error( + "cdk-outputs.json not found or is invalid. Deploy your CDK stack first." + ); + } + + const stackName = "AppsyncBedrockCognitoStack"; // Ensure this matches your deployed stack name + const stackOutputs = cdkOutputs[stackName]; + + if (!stackOutputs) { + throw new Error( + `Stack outputs for '${stackName}' not found in cdk-outputs.json. Check stack name and deployment.` + ); + } + + APPSYNC_URL = stackOutputs.GraphQLApiUrl; + REGION = stackOutputs.AWSRegion; + USER_POOL_ID = stackOutputs.CognitoUserPoolId; + USER_POOL_CLIENT_ID = stackOutputs.CognitoUserPoolClientId; + + if (!APPSYNC_URL || !REGION || !USER_POOL_ID || !USER_POOL_CLIENT_ID) { + console.error("Missing CDK outputs:", { + APPSYNC_URL, + REGION, + USER_POOL_ID, + USER_POOL_CLIENT_ID, + }); + throw new Error( + "One or more required CDK outputs are missing from cdk-outputs.json." + ); + } + + cognitoClient = new CognitoIdentityProviderClient({ region: REGION }); + + console.log(`Attempting to sign up test user: ${TEST_USER_EMAIL}`); + try { + await cognitoClient.send( + new SignUpCommand({ + ClientId: USER_POOL_CLIENT_ID, + Username: TEST_USER_EMAIL, + Password: TEST_USER_PASSWORD, + UserAttributes: [{ Name: "email", Value: TEST_USER_EMAIL }], + }) + ); + console.log(`Test user ${TEST_USER_EMAIL} sign-up initiated.`); + } catch (error: any) { + if (error.name === "UsernameExistsException") { + console.warn(`Test user ${TEST_USER_EMAIL} already exists.`); + } else { + console.error("Error during sign up:", error); + throw error; + } + } + + console.log(`Attempting to admin-confirm test user: ${TEST_USER_EMAIL}`); + try { + await cognitoClient.send( + new AdminConfirmSignUpCommand({ + UserPoolId: USER_POOL_ID, + Username: TEST_USER_EMAIL, + }) + ); + console.log(`Test user ${TEST_USER_EMAIL} confirmed by admin.`); + } catch (error) { + console.error("Error during admin confirm sign up:", error); + throw error; + } + + console.log(`Attempting to log in test user: ${TEST_USER_EMAIL}`); + try { + const authResponse = await cognitoClient.send( + new InitiateAuthCommand({ + AuthFlow: AuthFlowType.USER_PASSWORD_AUTH, + ClientId: USER_POOL_CLIENT_ID, + AuthParameters: { + USERNAME: TEST_USER_EMAIL, + PASSWORD: TEST_USER_PASSWORD, + }, + }) + ); + idToken = authResponse.AuthenticationResult?.IdToken ?? null; + if (!idToken) throw new Error("Failed to retrieve ID token."); + console.log(`Test user ${TEST_USER_EMAIL} logged in. ID token obtained.`); + } catch (error) { + console.error("Error during initiate auth (login):", error); + throw error; + } +}, 90000); + +afterAll(async () => { + if (TEST_USER_EMAIL && USER_POOL_ID && cognitoClient) { + console.log(`Attempting to delete test user: ${TEST_USER_EMAIL}`); + try { + await cognitoClient.send( + new AdminDeleteUserCommand({ + UserPoolId: USER_POOL_ID, + Username: TEST_USER_EMAIL, + }) + ); + console.log(`Test user ${TEST_USER_EMAIL} deleted successfully.`); + } catch (error: any) { + if (error.name === "UserNotFoundException") { + console.warn( + `Test user ${TEST_USER_EMAIL} was already deleted or not found.` + ); + } else { + console.error(`Failed to delete test user ${TEST_USER_EMAIL}:`, error); + } + } + } +}, 30000); + +describe("Bedrock Model invoked with Programmatic Cognito Auth (Lambda Resolver)", () => { + test("The user performs the invoke mutation using a programmatically obtained Cognito ID token", async () => { + if (!idToken) { + throw new Error( + "ID token was not obtained in beforeAll. Cannot run test." + ); + } + + const prompt = "What is the capital of India?"; + const operationName = "InvokeBedrockMutation"; // Aligned name + + console.log( + `Calling AppSync mutation (via Lambda resolver) with prompt: "${prompt}"` + ); + + const mutationPromise = await appSyncRequestCognito<{ invoke: string }>( + { + config: { url: APPSYNC_URL, region: REGION }, + operation: { + operationName: operationName, // Use aligned name + query: `mutation ${operationName}($prompt: String!) { + invoke(prompt: $prompt) + }`, + variables: { prompt: prompt }, + }, + }, + idToken + ); + + console.log( + "AppSync Mutation Response:", + JSON.stringify(mutationPromise, null, 2) + ); + + if (mutationPromise?.errors) { + console.error("GraphQL Errors:", mutationPromise.errors); + mutationPromise.errors.forEach((err) => + console.error(JSON.stringify(err, null, 2)) + ); + throw new Error( + `GraphQL request failed: ${ + mutationPromise.errors[0]?.message || "Unknown GraphQL error" + }` + ); + } + + expect(mutationPromise?.data).toBeDefined(); + expect(mutationPromise.data?.invoke).toBeTruthy(); + expect(typeof mutationPromise.data?.invoke).toBe("string"); + if (prompt === "What is the capital of India?") { + expect(mutationPromise.data?.invoke.toLowerCase()).toContain("delhi"); + } + }); +}); diff --git a/cognito-appsync-bedrock/cdk/tsconfig.json b/cognito-appsync-bedrock/cdk/tsconfig.json new file mode 100644 index 000000000..aaa7dc510 --- /dev/null +++ b/cognito-appsync-bedrock/cdk/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": [ + "es2020", + "dom" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +} diff --git a/cognito-appsync-bedrock/example-pattern.json b/cognito-appsync-bedrock/example-pattern.json new file mode 100644 index 000000000..343e914f1 --- /dev/null +++ b/cognito-appsync-bedrock/example-pattern.json @@ -0,0 +1,57 @@ +{ + "title": "Amazon Cognito to AWS Lambda to Amazon DynamoDB", + "description": "Create a user in Amazon Cognito, handle a Post Confirmation trigger with AWS Lambda, and store user details in Amazon DynamoDB.", + "language": "TypeScript", + "level": "200", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This sample project demonstrates how to create a user in an Amazon Cognito User Pool, then automatically insert that user's details into a DynamoDB table once the user confirms their email. The Post Confirmation Lambda trigger handles the event from Cognito and uses the AWS SDK for JavaScript (v3) to write user data to the DynamoDB table.", + "Key attributes such as the user's unique ID (sub), email, and optional custom attributes are passed to Lambda, which then processes and persists this data. The table is configured in on-demand capacity mode (Pay Per Request) for cost efficiency and minimal management overhead.", + "This pattern deploys a Cognito User Pool, a User Pool Client, a DynamoDB table, and a Node.js AWS Lambda function as the trigger." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns", + "templateURL": "serverless-patterns/cognito-appsync-bedrock", + "projectFolder": "cognito-appsync-bedrock/cdk", + "templateFile": "lib/cdk-stack.ts" + } + }, + "resources": { + "bullets": [ + { + "text": "Amazon Cognito - User sign-up/sign-in with triggers", + "link": "https://aws.amazon.com/cognito/" + }, + { + "text": "AWS Lambda - Serverless compute for triggered actions", + "link": "https://aws.amazon.com/lambda/" + }, + { + "text": "Amazon DynamoDB - Fast and flexible NoSQL database", + "link": "https://aws.amazon.com/dynamodb/" + } + ] + }, + "deploy": { + "text": ["cdk synth", "cdk deploy"] + }, + "testing": { + "text": ["See the GitHub repo for testing in test/cdk.test.ts."] + }, + "cleanup": { + "text": ["Delete the stack: cdk destroy."] + }, + "authors": [ + { + "name": "Vidit Shah", + "bio": "Software Engineer working @ServerlessCreed,making Serverless Courses and workshops", + "linkedin": "vidit-shah", + "twitter": "Vidit_210/", + "image": "https://media.licdn.com/dms/image/v2/D4D03AQHbL_7ZCYfUGQ/profile-displayphoto-shrink_200_200/B4DZUXcQlTGkAY-/0/1739855039564?e=2147483647&v=beta&t=MhOEFqsUDaKnLypK8eYYRqqD8Uq9xHUnijO5tN-fMpc" + } + ] +}