diff --git a/apigw-websocket-api-lambda-authorizer/.gitignore b/apigw-websocket-api-lambda-authorizer/.gitignore new file mode 100644 index 000000000..a4609e758 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/.gitignore @@ -0,0 +1,342 @@ +# CDK asset staging directory +.cdk.staging +cdk.out + +# Created by https://www.gitignore.io/api/csharp + +### Csharp ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + + +# End of https://www.gitignore.io/api/csharp \ No newline at end of file diff --git a/apigw-websocket-api-lambda-authorizer/Readme.md b/apigw-websocket-api-lambda-authorizer/Readme.md new file mode 100644 index 000000000..71d28bb58 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/Readme.md @@ -0,0 +1,118 @@ +# WebSocket API Cognito Authentication using Lambda Authorizer + +This pattern demonstrates how to integrate Amazon Cognito authentication with Amazon API Gateway WebSocket API. + +It includes the Lambda implementations for Lambda Authorizer, Lambda functions for $connect, $disconnect and custom route, and AWS Serverless Application Model (SAM) code to deploy backend infrastructure. + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/apigw-websocket-api-lambda-authorizer + +## Why Lambda Authorizer, not Cognito Authorizer? +API Gateway Websocket API doesn't support [Cognito authorizer](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-enable-cognito-user-pool.html) as of now. To enable Cognito Authentication for API Gateway Websocket API you need to create a Lambda authorizer instead of using the built-in Cognito authorizer. + +## Requirements + +* [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) +* [AWS Serverless Application Model](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) (AWS SAM) installed +* [.NET 6.0](https://dotnet.microsoft.com/en-us/download/dotnet/6.0) + +## Architecture +![architecture](assets/img/architecture.png) + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + ``` + git clone https://github.com/aws-samples/serverless-patterns + ``` +2. Change directory to the pattern directory: + ``` + cd apigw-websocket-api-lambda-authorizer + ``` +3. From the command line, use AWS SAM to build and deploy the AWS resources for the pattern as specified in the template.yaml file: + ``` + sam build + sam deploy --guided + ``` +4. During the prompts: + * Enter a stack name + * Enter the desired AWS Region + * Allow SAM CLI to create IAM roles with the required permissions. + + Once you have run `sam deploy -guided` mode and saved arguments to a configuration file (samconfig.toml), you can use `sam deploy` in future to use these defaults. + +5. Note the outputs from the SAM deployment process. These contain the WebSocketURI, UserpoolId, and ClientId which are used for testing. + +## Testing +In order to test, follow the steps given below: + +### Step 1. Create a user in Cognito user pool +> Note: For production workloads, you should use a strong password; the password given below is simply for demonstration purposes. + +```bash +# perform sign-up +aws cognito-idp sign-up \ + --region YOUR_COGNITO_REGION \ + --client-id YOUR_COGNITO_APP_CLIENT_ID \ + --username admin@example.com \ + --password Passw0rd! + +# confirm sign-up +aws cognito-idp admin-confirm-sign-up \ + --region YOUR_COGNITO_REGION \ + --user-pool-id YOUR_COGNITO_USER_POOL_ID \ + --username admin@example.com + +``` +### Step 2. Obtain ID Token for the user +Run the following command to get the Cognito tokens for the user. + +```bash +# run the initiate-auth command to get cognito tokens +aws cognito-idp initiate-auth \ + --auth-flow USER_PASSWORD_AUTH \ + --auth-parameters USERNAME=YOUR_USERNAME,PASSWORD=YOUR_PASSWORD \ + --client-id your_app_client_id \ + --region YOUR_COGNITO_REGION +``` +Copy the ID Token for use in the following step. + +### Step 3. Invoke WebSocket API with ID Token +An easy way to test the WebSocket after deploying is to use the tool [wscat](https://github.com/websockets/wscat) from NPM. To install the tool from NPM +use the following command: +``` +npm install -g wscat +``` +> Note: It is important to understand that only the initial web-socket connect request (handshake) that establishes a WebSocket connection between client and server requires authentication. Once the channel has been established, subsequent requests work just fine. + +To continue testing, follow the steps below: +1. Run the below command (in more than one window) to establish websocket connections with the API Gateway. + ``` + wscat -c wss://{YOUR-API-ID}.execute-api.{YOUR-REGION}.amazonaws.com/{STAGE}?ID_Token={ID_TOKEN} + ``` + The url to connect to can also be found as the output parameter of the CloudFormation stack. + 2. Just for your information, query parameter `ID_Token` is case sensitive. If you want to change it, then update it in `template.yaml` file at `route.request.querystring.ID_Token`. +2. If the token is valid, the response is `Connected`; otherwise, the response is 401 unauthorized or 403 forbidden. +3. Optionally, to perform a negative test, send an invalid value for `ID_Token` and verify you get 401 or 403 in the response. +4. In one of the windows use the following command to send a message to the WebSocket which will broadcast the message to all other open console windows: + ``` + $ wscat -c wss://{YOUR-API-ID}.execute-api.{YOUR-REGION}.amazonaws.com/prod + connected (press CTRL+C to quit) + > {"message":"sendmessage", "data":"hello world"} + < hello world + ``` + +## Cleanup +1. Delete the stack + ```bash + sam delete + ``` +2. Confirm the stack has been deleted + ```bash + aws cloudformation list-stacks --query "StackSummaries[?contains(StackName,'STACK_NAME')].StackStatus" + ``` +---- +Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 \ No newline at end of file diff --git a/apigw-websocket-api-lambda-authorizer/apigw-websocket-api-lambda-authorizer.json b/apigw-websocket-api-lambda-authorizer/apigw-websocket-api-lambda-authorizer.json new file mode 100644 index 000000000..720b30af3 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/apigw-websocket-api-lambda-authorizer.json @@ -0,0 +1,100 @@ +{ + "title": "WebSocket API Cognito Authentication using Lambda Authorizer", + "description": "This pattern demonstrates how to integrate Cognito authentication with Amazon API Gateway WebSocket API.", + "language": ".NET", + "level": "200", + "framework": "SAM", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern demonstrates how to integrate Cognito authentication with Amazon API Gateway WebSocket API.", + "It includes the Lambda implementations for Lambda Authorizer, Lambda functions for $connect, $disconnect and custom route, and AWS Serverless Application Model (SAM) code to deploy backend infrastructure." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-websocket-api-lambda-authorizer", + "projectFolder": "apigw-websocket-api-lambda-authorize", + "readmeURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-websocket-api-lambda-authorizer/README.md", + "templateURL": "apigw-websocket-api-lambda-authorizer", + "templateFile": "template.yaml" + } + }, + "deploy": { + "text": [ + "sam build", + "sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the Github repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: sam delete" + ] + }, + "authors": [ + { + "name": "Ankush Jain", + "image": "https://avatars.githubusercontent.com/u/13661966", + "bio": "Application Development Consultant at AWS Professional Services.", + "linkedin": "ankush-jain-developer", + "twitter": "ankushjain358" + } + ], + "patternArch": { + "icon1": { + "x": 20, + "y": 20, + "service": "apigw", + "label": "API Gateway Websocket API" + }, + "icon2": { + "x": 44, + "y": 70, + "service": "lambda", + "label": "AWS Lambda" + }, + "icon3": { + "x": 50, + "y": 20, + "service": "lambda", + "label": "AWS Lambda" + }, + "icon4": { + "x": 80, + "y": 20, + "service": "dynamodb", + "label": "DynamoDB" + }, + "icon5": { + "x": 70, + "y": 70, + "service": "cognito", + "label": "Cognito" + }, + "line1": { + "from": "icon2", + "to": "icon5", + "label": "" + }, + "line2": { + "from": "icon1", + "to": "icon3", + "label": "" + }, + "line3": { + "from": "icon3", + "to": "icon4", + "label": "" + }, + "line4": { + "from": "icon1", + "to": "icon2", + "label": "" + } + } +} diff --git a/apigw-websocket-api-lambda-authorizer/assets/img/architecture.png b/apigw-websocket-api-lambda-authorizer/assets/img/architecture.png new file mode 100644 index 000000000..90371a305 Binary files /dev/null and b/apigw-websocket-api-lambda-authorizer/assets/img/architecture.png differ diff --git a/apigw-websocket-api-lambda-authorizer/example-pattern.json b/apigw-websocket-api-lambda-authorizer/example-pattern.json new file mode 100644 index 000000000..53be3e751 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/example-pattern.json @@ -0,0 +1,48 @@ +{ + "title": "WebSocket API Cognito Authentication using Lambda Authorizer", + "description": "This pattern demonstrates how to integrate Cognito authentication with Amazon API Gateway WebSocket API.", + "language": ".NET", + "level": "200", + "framework": "SAM", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern demonstrates how to integrate Cognito authentication with Amazon API Gateway WebSocket API.", + "It includes the Lambda implementations for Lambda Authorizer, Lambda functions for $connect, $disconnect and custom route, and AWS Serverless Application Model (SAM) code to deploy backend infrastructure." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-websocket-api-lambda-authorizer", + "projectFolder": "apigw-websocket-api-lambda-authorize", + "readmeURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-websocket-api-lambda-authorizer/README.md", + "templateURL": "apigw-websocket-api-lambda-authorizer", + "templateFile": "template.yaml" + } + }, + "deploy": { + "text": [ + "sam build", + "sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the Github repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: sam delete" + ] + }, + "authors": [ + { + "name": "Ankush Jain", + "image": "https://avatars.githubusercontent.com/u/13661966", + "bio": "Application Development Consultant at AWS Professional Services.", + "linkedin": "ankush-jain-developer", + "twitter": "ankushjain358" + } + ] + } \ No newline at end of file diff --git a/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/CognitoJwtVerifier.cs b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/CognitoJwtVerifier.cs new file mode 100644 index 000000000..ecaabc362 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/CognitoJwtVerifier.cs @@ -0,0 +1,66 @@ +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace LambdaAuthorizer +{ + public class CognitoJwtVerifier + { + private readonly string _userPoolId; + private readonly string _clientId; + private readonly string _region; + + public CognitoJwtVerifier(string userPoolId, string clientId, string region) + { + _userPoolId = userPoolId; + _clientId = clientId; + _region = region; + } + + public async Task ValidateTokenAsync(string jwtToken) + { + try + { + if (string.IsNullOrWhiteSpace(jwtToken)) + { + throw new Exception("Missing identity bearer token"); + } + + jwtToken = jwtToken.Replace("Bearer", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(); + + var issuer = $"https://cognito-idp.{_region}.amazonaws.com/{_userPoolId}"; + var metadataEndpoint = $"{issuer}/.well-known/openid-configuration"; + + var configurationManager = new ConfigurationManager(metadataEndpoint, new OpenIdConnectConfigurationRetriever(), new HttpDocumentRetriever()); + var discoveryDocument = await configurationManager.GetConfigurationAsync(); + var signingKeys = discoveryDocument.SigningKeys; + + var tokenValidationParameters = new TokenValidationParameters() + { + ValidateAudience = true, + ValidAudiences = new string[] { _clientId }, // list all audiences + ValidateIssuer = true, + ValidIssuer = issuer, + RequireSignedTokens = true, + ValidateIssuerSigningKey = true, + IssuerSigningKeys = signingKeys, + RoleClaimType = "cognito:groups" + }; + + SecurityToken validatedToken = new JwtSecurityToken(); + + var tokenHandler = new JwtSecurityTokenHandler(); + + return tokenHandler.ValidateToken(jwtToken, tokenValidationParameters, out validatedToken); + } + catch + { + throw; + } + } + } + + +} diff --git a/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/Function.cs b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/Function.cs new file mode 100644 index 000000000..a705862af --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/Function.cs @@ -0,0 +1,108 @@ +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Core; +using AWS.Lambda.Powertools.Logging; + +// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class. +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] + +namespace LambdaAuthorizer; + +public class Function +{ + public const string AWS_REGION = "AWS_REGION"; + public const string COGNITO_USER_POOL_ID = "COGNITO_USER_POOL_ID"; + public const string COGNITO_USER_POOL_CLIENT_ID = "COGNITO_USER_POOL_CLIENT_ID"; + + [Logging(LogEvent = true)] + public async Task LambdaAuthorizerHandler(APIGatewayCustomAuthorizerRequest request, ILambdaContext context) + { + try + { + string? userPoolId = Environment.GetEnvironmentVariable(COGNITO_USER_POOL_ID); + string? clientId = Environment.GetEnvironmentVariable(COGNITO_USER_POOL_CLIENT_ID); + string? region = Environment.GetEnvironmentVariable(AWS_REGION); + + if (string.IsNullOrEmpty(userPoolId)) + { + throw new ArgumentException($"Missing ENV variable: {COGNITO_USER_POOL_ID}"); + } + + if (string.IsNullOrEmpty(clientId)) + { + throw new ArgumentException($"Missing ENV variable: {COGNITO_USER_POOL_CLIENT_ID}"); + } + + if (string.IsNullOrEmpty(region)) + { + throw new ArgumentException($"Missing ENV variable: {AWS_REGION}"); + } + + // 1. retrieve the id_token from the query string + var id_token = request.QueryStringParameters.FirstOrDefault(item => item.Key.Equals("id_token", StringComparison.OrdinalIgnoreCase)); + if (id_token.Value == null) + { + throw new ArgumentException($"Missing id_token querystring parameter"); + } + + // 2. validate the incoming token against cognito userpool and clientId + var claimPrincipal = await new CognitoJwtVerifier(userPoolId, clientId, region).ValidateTokenAsync(id_token.Value); + + + // 3. either claimPrincipal is recieved (not null) or an exception is thrown in case of invalid token + if (claimPrincipal != null) + { + string cogntioUserId = claimPrincipal.Claims.First(t => t.Type == "cognito:username").Value; + return GenerateAllow(cogntioUserId, request.MethodArn); + } + + // 4. most likely, won't be executed (but keep it on the safer side) + return GenerateDeny("default", request.MethodArn); + } + catch (Exception ex) + { + Logger.LogError(ex); + + return GenerateDeny("default", request.MethodArn); + } + } + + private APIGatewayCustomAuthorizerResponse GenerateAllow(string principalId, string resource) + { + return GeneratePolicy(principalId, "Allow", resource); + } + + private APIGatewayCustomAuthorizerResponse GenerateDeny(string principalId, string resource) + { + return GeneratePolicy(principalId, "Deny", resource); + } + + private APIGatewayCustomAuthorizerResponse GeneratePolicy(string principalId, string effect, string resource) + { + var policyDocument = new APIGatewayCustomAuthorizerPolicy() + { + Version = "2012-10-17", + Statement = new List() + { + new APIGatewayCustomAuthorizerPolicy.IAMPolicyStatement() + { + Action = new HashSet(){"execute-api:Invoke"}, + Effect = effect, + Resource = new HashSet(){resource} + } + } + }; + + var response = new APIGatewayCustomAuthorizerResponse() + { + PrincipalID = principalId, + PolicyDocument = policyDocument, + Context = new APIGatewayCustomAuthorizerContextOutput() + { + {"cognitoUserId", principalId} + } + }; + + return response; + } + +} diff --git a/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/LambdaAuthorizer.csproj b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/LambdaAuthorizer.csproj new file mode 100644 index 000000000..76dc6a579 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/LambdaAuthorizer.csproj @@ -0,0 +1,22 @@ + + + net6.0 + enable + enable + true + Lambda + + true + + true + + + + + + + + + + + \ No newline at end of file diff --git a/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/Properties/launchSettings.json b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/Properties/launchSettings.json new file mode 100644 index 000000000..a82d4042b --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "Mock Lambda Test Tool": { + "commandName": "Executable", + "commandLineArgs": "--port 5050", + "workingDirectory": ".\\bin\\$(Configuration)\\net6.0", + "executablePath": "%USERPROFILE%\\.dotnet\\tools\\dotnet-lambda-test-tool-6.0.exe" + } + } +} \ No newline at end of file diff --git a/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/Readme.md b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/Readme.md new file mode 100644 index 000000000..fa79d3d14 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/Readme.md @@ -0,0 +1,50 @@ +# AWS Lambda Empty Function Project + +This starter project consists of: +* Function.cs - class file containing a class with a single function handler method +* aws-lambda-tools-defaults.json - default argument settings for use with Visual Studio and command line deployment tools for AWS +* CognitoJwtVerifier - class file to verify ID and access JWT tokens obtained from Amazon Cognito + +You may also have a test project depending on the options selected. + +The generated function handler is a simple method accepting a string argument that returns the uppercase equivalent of the input string. Replace the body of this method, and parameters, to suit your needs. + +## Here are some steps to follow from Visual Studio: + +To deploy your function to AWS Lambda, right click the project in Solution Explorer and select *Publish to AWS Lambda*. + +To view your deployed function open its Function View window by double-clicking the function name shown beneath the AWS Lambda node in the AWS Explorer tree. + +To perform testing against your deployed function use the Test Invoke tab in the opened Function View window. + +To configure event sources for your deployed function, for example to have your function invoked when an object is created in an Amazon S3 bucket, use the Event Sources tab in the opened Function View window. + +To update the runtime configuration of your deployed function use the Configuration tab in the opened Function View window. + +To view execution logs of invocations of your function use the Logs tab in the opened Function View window. + +## Here are some steps to follow to get started from the command line: + +Once you have edited your template and code you can deploy your application using the [Amazon.Lambda.Tools Global Tool](https://github.com/aws/aws-extensions-for-dotnet-cli#aws-lambda-amazonlambdatools) from the command line. + +Install Amazon.Lambda.Tools Global Tools if not already installed. +``` + dotnet tool install -g Amazon.Lambda.Tools +``` + +If already installed check if new version is available. +``` + dotnet tool update -g Amazon.Lambda.Tools +``` + +Execute unit tests +``` + cd "LambdaAuthorizer/test/LambdaAuthorizer.Tests" + dotnet test +``` + +Deploy function to AWS Lambda +``` + cd "LambdaAuthorizer/src/LambdaAuthorizer" + dotnet lambda deploy-function +``` diff --git a/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/aws-lambda-tools-defaults.json b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/aws-lambda-tools-defaults.json new file mode 100644 index 000000000..2e1890229 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/LambdaAuthorizer/aws-lambda-tools-defaults.json @@ -0,0 +1,16 @@ +{ + "Information": [ + "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.", + "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.", + "dotnet lambda help", + "All the command line options for the Lambda command can be specified in this file." + ], + "profile": "default", + "region": "ap-south-1", + "configuration": "Release", + "function-architecture": "x86_64", + "function-runtime": "dotnet6", + "function-memory-size": 256, + "function-timeout": 30, + "function-handler": "LambdaAuthorizer::LambdaAuthorizer.Function::LambdaAuthorizerHandler" +} \ No newline at end of file diff --git a/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI.sln b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI.sln new file mode 100644 index 000000000..b29d3b849 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34309.116 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebSocketAPI", "WebSocketAPI\WebSocketAPI.csproj", "{5235BDEC-9491-42F3-AC78-482154E68C62}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LambdaAuthorizer", "LambdaAuthorizer\LambdaAuthorizer.csproj", "{A21F9634-1289-4434-9A23-33E78E18F46B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5235BDEC-9491-42F3-AC78-482154E68C62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5235BDEC-9491-42F3-AC78-482154E68C62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5235BDEC-9491-42F3-AC78-482154E68C62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5235BDEC-9491-42F3-AC78-482154E68C62}.Release|Any CPU.Build.0 = Release|Any CPU + {A21F9634-1289-4434-9A23-33E78E18F46B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A21F9634-1289-4434-9A23-33E78E18F46B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A21F9634-1289-4434-9A23-33E78E18F46B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A21F9634-1289-4434-9A23-33E78E18F46B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E37CC01B-7B98-4717-B9DE-72509482ED3A} + EndGlobalSection +EndGlobal diff --git a/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/Functions.cs b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/Functions.cs new file mode 100644 index 000000000..628c90836 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/Functions.cs @@ -0,0 +1,251 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Amazon.Lambda.Core; +using Amazon.Lambda.APIGatewayEvents; + +using Amazon.Runtime; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using Amazon.ApiGatewayManagementApi; +using Amazon.ApiGatewayManagementApi.Model; + + +// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class. +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] + +namespace WebSocketAPI; + +public class Functions +{ + public const string ConnectionIdField = "connectionId"; + private const string TABLE_NAME_ENV = "TABLE_NAME"; + + /// + /// DynamoDB table used to store the open connection ids. More advanced use cases could store logged on user map to their connection id to implement direct message chatting. + /// + string ConnectionMappingTable { get; } + + /// + /// DynamoDB service client used to store and retieve connection information from the ConnectionMappingTable + /// + IAmazonDynamoDB DDBClient { get; } + + /// + /// Factory func to create the AmazonApiGatewayManagementApiClient. This is needed to created per endpoint of the a connection. It is a factory to make it easy for tests + /// to moq the creation. + /// + Func ApiGatewayManagementApiClientFactory { get; } + + + /// + /// Default constructor that Lambda will invoke. + /// + public Functions() + { + DDBClient = new AmazonDynamoDBClient(); + + // Grab the name of the DynamoDB from the environment variable setup in the CloudFormation template serverless.template + if(Environment.GetEnvironmentVariable(TABLE_NAME_ENV) == null) + { + throw new ArgumentException($"Missing required environment variable {TABLE_NAME_ENV}"); + } + + ConnectionMappingTable = Environment.GetEnvironmentVariable(TABLE_NAME_ENV) ?? ""; + + this.ApiGatewayManagementApiClientFactory = (Func)((endpoint) => + { + return new AmazonApiGatewayManagementApiClient(new AmazonApiGatewayManagementApiConfig + { + ServiceURL = endpoint + }); + }); + } + + /// + /// Constructor used for testing allow tests to pass in moq versions of the service clients. + /// + /// + /// + /// + public Functions(IAmazonDynamoDB ddbClient, Func apiGatewayManagementApiClientFactory, string connectionMappingTable) + { + this.DDBClient = ddbClient; + this.ApiGatewayManagementApiClientFactory = apiGatewayManagementApiClientFactory; + this.ConnectionMappingTable = connectionMappingTable; + } + + public async Task OnConnectHandler(APIGatewayProxyRequest request, ILambdaContext context) + { + try + { + var connectionId = request.RequestContext.ConnectionId; + context.Logger.LogInformation($"ConnectionId: {connectionId}"); + + var ddbRequest = new PutItemRequest + { + TableName = ConnectionMappingTable, + Item = new Dictionary + { + {ConnectionIdField, new AttributeValue{ S = connectionId}} + } + }; + + await DDBClient.PutItemAsync(ddbRequest); + + return new APIGatewayProxyResponse + { + StatusCode = 200, + Body = "Connected." + }; + } + catch (Exception e) + { + context.Logger.LogInformation("Error connecting: " + e.Message); + context.Logger.LogInformation(e.StackTrace); + return new APIGatewayProxyResponse + { + StatusCode = 500, + Body = $"Failed to connect: {e.Message}" + }; + } + } + + + public async Task SendMessageHandler(APIGatewayProxyRequest request, ILambdaContext context) + { + try + { + // Construct the API Gateway endpoint that incoming message will be broadcasted to. + var domainName = request.RequestContext.DomainName; + var stage = request.RequestContext.Stage; + var endpoint = $"https://{domainName}/{stage}"; + context.Logger.LogInformation($"API Gateway management endpoint: {endpoint}"); + + // The body will look something like this: {"message":"sendmessage", "data":"What are you doing?"} + JsonDocument message = JsonDocument.Parse(request.Body); + + // Grab the data from the JSON body which is the message to broadcasted. + JsonElement dataProperty; + if (!message.RootElement.TryGetProperty("data", out dataProperty) || dataProperty.GetString() == null) + { + context.Logger.LogInformation("Failed to find data element in JSON document"); + return new APIGatewayProxyResponse + { + StatusCode = (int)HttpStatusCode.BadRequest + }; + } + + var data = dataProperty.GetString() ?? ""; + var stream = new MemoryStream(UTF8Encoding.UTF8.GetBytes(data)); + + // List all of the current connections. In a more advanced use case the table could be used to grab a group of connection ids for a chat group. + var scanRequest = new ScanRequest + { + TableName = ConnectionMappingTable, + ProjectionExpression = ConnectionIdField + }; + + var scanResponse = await DDBClient.ScanAsync(scanRequest); + + // Construct the IAmazonApiGatewayManagementApi which will be used to send the message to. + var apiClient = ApiGatewayManagementApiClientFactory(endpoint); + + // Loop through all of the connections and broadcast the message out to the connections. + var count = 0; + foreach (var item in scanResponse.Items) + { + var postConnectionRequest = new PostToConnectionRequest + { + ConnectionId = item[ConnectionIdField].S, + Data = stream + }; + + try + { + context.Logger.LogInformation($"Post to connection {count}: {postConnectionRequest.ConnectionId}"); + stream.Position = 0; + await apiClient.PostToConnectionAsync(postConnectionRequest); + count++; + } + catch (AmazonServiceException e) + { + // API Gateway returns a status of 410 GONE then the connection is no + // longer available. If this happens, delete the identifier + // from our DynamoDB table. + if (e.StatusCode == HttpStatusCode.Gone) + { + var ddbDeleteRequest = new DeleteItemRequest + { + TableName = ConnectionMappingTable, + Key = new Dictionary + { + {ConnectionIdField, new AttributeValue {S = postConnectionRequest.ConnectionId}} + } + }; + + context.Logger.LogInformation($"Deleting gone connection: {postConnectionRequest.ConnectionId}"); + await DDBClient.DeleteItemAsync(ddbDeleteRequest); + } + else + { + context.Logger.LogInformation($"Error posting message to {postConnectionRequest.ConnectionId}: {e.Message}"); + context.Logger.LogInformation(e.StackTrace); + } + } + } + + return new APIGatewayProxyResponse + { + StatusCode = (int)HttpStatusCode.OK, + Body = "Data sent to " + count + " connection" + (count == 1 ? "" : "s") + }; + } + catch (Exception e) + { + context.Logger.LogInformation("Error disconnecting: " + e.Message); + context.Logger.LogInformation(e.StackTrace); + return new APIGatewayProxyResponse + { + StatusCode = (int)HttpStatusCode.InternalServerError, + Body = $"Failed to send message: {e.Message}" + }; + } + } + + public async Task OnDisconnectHandler(APIGatewayProxyRequest request, ILambdaContext context) + { + try + { + var connectionId = request.RequestContext.ConnectionId; + context.Logger.LogInformation($"ConnectionId: {connectionId}"); + + var ddbRequest = new DeleteItemRequest + { + TableName = ConnectionMappingTable, + Key = new Dictionary + { + {ConnectionIdField, new AttributeValue {S = connectionId}} + } + }; + + await DDBClient.DeleteItemAsync(ddbRequest); + + return new APIGatewayProxyResponse + { + StatusCode = 200, + Body = "Disconnected." + }; + } + catch (Exception e) + { + context.Logger.LogInformation("Error disconnecting: " + e.Message); + context.Logger.LogInformation(e.StackTrace); + return new APIGatewayProxyResponse + { + StatusCode = 500, + Body = $"Failed to disconnect: {e.Message}" + }; + } + } +} \ No newline at end of file diff --git a/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/Properties/launchSettings.json b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/Properties/launchSettings.json new file mode 100644 index 000000000..a82d4042b --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "Mock Lambda Test Tool": { + "commandName": "Executable", + "commandLineArgs": "--port 5050", + "workingDirectory": ".\\bin\\$(Configuration)\\net6.0", + "executablePath": "%USERPROFILE%\\.dotnet\\tools\\dotnet-lambda-test-tool-6.0.exe" + } + } +} \ No newline at end of file diff --git a/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/Readme.md b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/Readme.md new file mode 100644 index 000000000..eb365b21d --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/Readme.md @@ -0,0 +1,82 @@ +# WebSocket API Project + +This starter project consists of: +* serverless.template - an AWS CloudFormation Serverless Application Model template file for declaring your Serverless functions and other AWS resources +* Functions.cs - class file containing the 3 separate Lambda function handlers for connecting, disconnecting and sending messages. +* aws-lambda-tools-defaults.json - default argument settings for use with Visual Studio and command line deployment tools for AWS + +You may also have a test project depending on the options selected. + +The generated project contains a Serverless template which declares 3 Lambda functions to connect, disconnect and send messages for the WebSocket API. +The template then declares the WebSocket API with API Gateway and a DynamoDB table to manage connection ids for the open WebSocket connections. + +An easy way to test the WebSocket after deploying is to use the tool [wscat](https://github.com/websockets/wscat) from NPM. To install the tool from NPM +use the following command: +``` +npm install -g wscat +``` + +Then to test then open multiple console windows and each console run the following command to connect. +``` +wscat -c wss://{YOUR-API-ID}.execute-api.{YOUR-REGION}.amazonaws.com/{STAGE} +``` +Note: The url to connect to can also be found as the output parameter of the CloudFormation stack. + +In one of the windows use the following command to send a message to the WebSocket which will broadcast the message to all other open console windows: +``` +$ wscat -c wss://{YOUR-API-ID}.execute-api.{YOUR-REGION}.amazonaws.com/prod +connected (press CTRL+C to quit) +> {"message":"sendmessage", "data":"hello world"} +< hello world +``` + +In .NET you can access the WebSocket API using the `System.Net.WebSockets.ClientWebSocket` class. Here is a snippet showing how to send a message to the WebSocketAPI. +```csharp +static async Task Main(string[] args) +{ + var cws = new ClientWebSocket(); + + var cancelSource = new CancellationTokenSource(); + var connectionUri = new Uri("wss://{YOUR-API-ID}.execute-api.{YOUR-REGION}.amazonaws.com/prod"); + await cws.ConnectAsync(connectionUri, cancelSource.Token); + + ArraySegment message = new ArraySegment(UTF8Encoding.UTF8.GetBytes("{\"message\":\"sendmessage\", \"data\":\"Hello from .NET ClientWebSocket\"}")); + await cws.SendAsync(message, WebSocketMessageType.Text, true, cancelSource.Token); +} +``` + +For more information about this demo and and API Gateway WebSocket check out the following blog post which had the original version of +this demo written in Node.js: https://aws.amazon.com/blogs/compute/announcing-websocket-apis-in-amazon-api-gateway/ + + +## Here are some steps to follow from Visual Studio: + +To deploy your Serverless application, right click the project in Solution Explorer and select *Publish to AWS Lambda*. + +To view your deployed application open the Stack View window by double-clicking the stack name shown beneath the AWS CloudFormation node in the AWS Explorer tree. The Stack View also displays the root URL to your published application. + +## Here are some steps to follow to get started from the command line: + +Once you have edited your template and code you can deploy your application using the [Amazon.Lambda.Tools Global Tool](https://github.com/aws/aws-extensions-for-dotnet-cli#aws-lambda-amazonlambdatools) from the command line. + +Install Amazon.Lambda.Tools Global Tools if not already installed. +``` + dotnet tool install -g Amazon.Lambda.Tools +``` + +If already installed check if new version is available. +``` + dotnet tool update -g Amazon.Lambda.Tools +``` + +Execute unit tests +``` + cd "WebSocketAPI/test/WebSocketAPI.Tests" + dotnet test +``` + +Deploy application +``` + cd "WebSocketAPI/src/WebSocketAPI" + dotnet lambda deploy-serverless +``` diff --git a/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/WebSocketAPI.csproj b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/WebSocketAPI.csproj new file mode 100644 index 000000000..349114648 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/WebSocketAPI.csproj @@ -0,0 +1,20 @@ + + + net6.0 + enable + enable + true + Lambda + + true + + true + + + + + + + + + \ No newline at end of file diff --git a/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/aws-lambda-tools-defaults.json b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/aws-lambda-tools-defaults.json new file mode 100644 index 000000000..e5f8ff70e --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/src/WebSocketAPI/aws-lambda-tools-defaults.json @@ -0,0 +1,14 @@ +{ + "Information": [ + "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.", + "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.", + "dotnet lambda help", + "All the command line options for the Lambda command can be specified in this file." + ], + "profile": "default", + "region": "ap-south-1", + "configuration": "Release", + "s3-prefix": "WebSocketAPI/", + "template": "serverless.template", + "template-parameters": "" +} \ No newline at end of file diff --git a/apigw-websocket-api-lambda-authorizer/template.yaml b/apigw-websocket-api-lambda-authorizer/template.yaml new file mode 100644 index 000000000..aa4f22673 --- /dev/null +++ b/apigw-websocket-api-lambda-authorizer/template.yaml @@ -0,0 +1,275 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: An AWS Serverless Application to demonstrates Cognito authentication with Amazon API Gateway WebSocket API. +Parameters: + ConnectionMappingTableName: + Type: String + Default: SimpleChatConnections + Description: The name of the new DynamoDB to store connection identifiers for + each connected clients. Minimum 3 characters. + MinLength: '3' + MaxLength: '50' + AllowedPattern: ^[A-Za-z_]+$ +Resources: + + # API Gateway Websocket API, Deployment & Stage + SimpleChatWebSocketApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: SimpleChatWebSocket + ProtocolType: WEBSOCKET + RouteSelectionExpression: $request.body.message + Deployment: + Type: AWS::ApiGatewayV2::Deployment + DependsOn: + - ConnectRoute + - SendMessageRoute + - DisconnectRoute + Properties: + ApiId: !Ref 'SimpleChatWebSocketApi' + Stage: + Type: AWS::ApiGatewayV2::Stage + Properties: + ApiId: !Ref 'SimpleChatWebSocketApi' + DeploymentId: !Ref 'Deployment' + StageName: Prod + + # Lambda functions for OnConnect, OnDisconnect, SendMessage + OnConnectFunction: + Type: AWS::Serverless::Function + Properties: + Handler: WebSocketAPI::WebSocketAPI.Functions::OnConnectHandler + Runtime: dotnet6 + CodeUri: './src/WebSocketAPI' + MemorySize: 256 + Timeout: 30 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref 'ConnectionMappingTableName' + Environment: + Variables: + TABLE_NAME: !Ref 'ConnectionMappingTableName' + OnDisconnectFunction: + Type: AWS::Serverless::Function + Properties: + Handler: WebSocketAPI::WebSocketAPI.Functions::OnDisconnectHandler + Runtime: dotnet6 + CodeUri: './src/WebSocketAPI' + MemorySize: 256 + Timeout: 30 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref 'ConnectionMappingTableName' + Environment: + Variables: + TABLE_NAME: !Ref 'ConnectionMappingTableName' + SendMessageFunction: + Type: AWS::Serverless::Function + Properties: + Handler: WebSocketAPI::WebSocketAPI.Functions::SendMessageHandler + Runtime: dotnet6 + CodeUri: './src/WebSocketAPI' + MemorySize: 256 + Timeout: 30 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref 'ConnectionMappingTableName' + - Statement: + Effect: Allow + Action: execute-api:ManageConnections + Resource: !Sub + - arn:aws:execute-api:${region}:${accountId}:*/@connections/* + - region: !Ref 'AWS::Region' + accountId: !Ref 'AWS::AccountId' + Environment: + Variables: + TABLE_NAME: !Ref 'ConnectionMappingTableName' + +# Websocket API routes and integrations + ConnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref 'SimpleChatWebSocketApi' + RouteKey: $connect + AuthorizationType: CUSTOM + AuthorizerId: !Ref 'LambdaAuthorizer' + OperationName: ConnectRoute + Target: !Join + - / + - - integrations + - !Ref 'ConnectInteg' + ConnectInteg: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref 'SimpleChatWebSocketApi' + IntegrationType: AWS_PROXY + IntegrationUri: !Sub + - arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${function}/invocations + - region: !Ref 'AWS::Region' + function: !GetAtt 'OnConnectFunction.Arn' + DisconnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref 'SimpleChatWebSocketApi' + RouteKey: $disconnect + AuthorizationType: NONE + OperationName: ConnectRoute + Target: !Join + - / + - - integrations + - !Ref 'DisconnectInteg' + DisconnectInteg: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref 'SimpleChatWebSocketApi' + IntegrationType: AWS_PROXY + IntegrationUri: !Sub + - arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${function}/invocations + - region: !Ref 'AWS::Region' + function: !GetAtt 'OnDisconnectFunction.Arn' + SendMessageRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref 'SimpleChatWebSocketApi' + RouteKey: sendmessage + AuthorizationType: NONE + OperationName: ConnectRoute + Target: !Join + - / + - - integrations + - !Ref 'SendMessageInteg' + SendMessageInteg: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref 'SimpleChatWebSocketApi' + IntegrationType: AWS_PROXY + IntegrationUri: !Sub + - arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${function}/invocations + - region: !Ref 'AWS::Region' + function: !GetAtt 'SendMessageFunction.Arn' + +# Grant API Gateway permissions to invoke these functions + OnConnectPermission: + Type: AWS::Lambda::Permission + DependsOn: + - OnConnectFunction + - SimpleChatWebSocketApi + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref 'OnConnectFunction' + Principal: apigateway.amazonaws.com + OnDisconnectPermission: + Type: AWS::Lambda::Permission + DependsOn: + - OnDisconnectFunction + - SimpleChatWebSocketApi + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref 'OnDisconnectFunction' + Principal: apigateway.amazonaws.com + SendMessagePermission: + Type: AWS::Lambda::Permission + DependsOn: + - SendMessageFunction + - SimpleChatWebSocketApi + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref 'SendMessageFunction' + Principal: apigateway.amazonaws.com + + # DynamoDB table for storing connection identifiers + ConnectionMappingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Ref 'ConnectionMappingTableName' + AttributeDefinitions: + - AttributeName: connectionId + AttributeType: S + KeySchema: + - AttributeName: connectionId + KeyType: HASH + BillingMode: PAY_PER_REQUEST + +# Cognito User Pool for authentication + CognitoUserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: demo-userpool + UsernameAttributes: + - email + AutoVerifiedAttributes: + - email + +# Cognito User Pool Client + CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + SupportedIdentityProviders: + - COGNITO + ClientName: demo-userpool-client + UserPoolId: !Ref 'CognitoUserPool' + ExplicitAuthFlows: + - ALLOW_USER_SRP_AUTH + - ALLOW_USER_PASSWORD_AUTH + - ALLOW_ADMIN_USER_PASSWORD_AUTH + - ALLOW_REFRESH_TOKEN_AUTH + + # Lambda function working as authorizer + LambdaAuthorizerHandler: + Type: AWS::Serverless::Function + Metadata: + Tool: Amazon.Lambda.Annotations + Properties: + Runtime: dotnet6 + CodeUri: ./src/LambdaAuthorizer + MemorySize: 1024 + Timeout: 30 + Policies: + - AWSLambdaBasicExecutionRole + PackageType: Zip + Handler: LambdaAuthorizer::LambdaAuthorizer.Function::LambdaAuthorizerHandler + Environment: + Variables: + COGNITO_USER_POOL_ID: !Ref 'CognitoUserPool' + COGNITO_USER_POOL_CLIENT_ID: !Ref 'CognitoUserPoolClient' + + # Grant API Gateway permissions to invoke this function + LambdaAuthorizerPermission: + Type: AWS::Lambda::Permission + DependsOn: + - LambdaAuthorizerHandler + - SimpleChatWebSocketApi + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref 'LambdaAuthorizerHandler' + Principal: apigateway.amazonaws.com + + # Authorizer for API Gateway (WebSocket API) + LambdaAuthorizer: + Type: AWS::ApiGatewayV2::Authorizer + Properties: + Name: LambdaAuthorizer + ApiId: !Ref 'SimpleChatWebSocketApi' + AuthorizerType: REQUEST + AuthorizerUri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaAuthorizerHandler.Arn}/invocations' + IdentitySource: + - route.request.querystring.ID_Token + +Outputs: + WebSocketURI: + Value: !Join + - '' + - - wss:// + - !Ref 'SimpleChatWebSocketApi' + - .execute-api. + - !Ref 'AWS::Region' + - .amazonaws.com/ + - !Ref 'Stage' + Description: The WSS Protocol URI to connect to + UserpoolId: + Value: !Ref CognitoUserPool + Description: Cognito UserPool Id + ClientId: + Value: !Ref CognitoUserPoolClient + Description: Cognito UserPool ClientId +