diff --git a/apigw-APIKey-tenantid-cdk/README.md b/apigw-APIKey-tenantid-cdk/README.md new file mode 100644 index 000000000..119d82bc7 --- /dev/null +++ b/apigw-APIKey-tenantid-cdk/README.md @@ -0,0 +1,124 @@ +# Amazon API Gateway with Cognito, Lambda Authorizer, and DynamoDB for Tenant API Key Authentication + +API Gateway's usage plans and API keys are fundamentally disconnected from authorization tokens. +Usage plans enforce rate limits via API keys, but auth tokens (JWTs from Cognito, Auth0, etc.) carry identity and permissions — these are two separate systems with no native link. This means customers cannot simply issue an auth token that inherently comes with rate-limiting attached. At scale (millions of auth tokens across thousands of tenants), managing this disconnect manually becomes untenable. + +This pattern demonstrates how to implement a secure tenant-based API key authorization system using Amazon Cognito, Amazon API Gateway, AWS Lambda Authorizer, and Amazon DynamoDB. Cognito authenticates users and issues JWTs containing a custom `tenantId` claim. The Lambda authorizer extracts the tenant ID from the JWT, looks up the corresponding API key in DynamoDB, and returns a policy document enabling API Gateway access. + +What this pattern solves: + - Bridges the auth–throttling gap — The Lambda authorizer acts as the glue between identity (JWT tenantId) and rate-limiting (API Gateway API key). By looking up the tenant's API key in DynamoDB and returning it via [usageIdentifierKey](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-lambda-authorizer-output.html), a single auth token automatically activates the correct usage plan. Auth and throttling become one unified flow rather than two disconnected systems. + - Scales to millions of tokens per tenant — Any number of JWTs can map to the same tenant's API key. You don't need a 1:1 relationship between auth tokens and API keys. A tenant can have millions of active tokens, but they all resolve to one API key and one rate-limit policy — making management tractable at scale. + - Eliminates per-application auth logic — Backend services no longer independently validate tenants or enforce limits. The gateway handles both centrally, preventing inconsistency and reducing overhead. + - Prevents noisy neighbors transparently — Tenants only interact with their auth credentials. The API key mapping and usage plan enforcement happen internally, so rate-limiting is invisible to consumers but enforced consistently. + - Makes auth and usage a single operational concern — Onboarding a new tenant means: create identity (Cognito/Auth0), create API key with a usage plan, store the mapping in DynamoDB. One workflow governs both auth and throttling, rather than managing them as separate systems that drift apart over time. + + +Important: 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. + +## 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](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed +* [Node.js and npm](https://nodejs.org/) installed +* [AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html) installed + +## 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 + ``` +1. Change directory to the pattern directory: + ``` + cd apigw-APIKey-tenantid-cdk + ``` +1. Install dependencies: + ``` + npm install + ``` +1. Deploy the stack: + ``` + cdk deploy + ``` + +Note the outputs from the CDK deployment process. The output will include the API Gateway URL, DynamoDB table name, Cognito User Pool ID, and User Pool Client ID. + +## How it works + +![Architecture Diagram](./apigw-dynamodb-apikey-cdk.jpg) + +1. Client authenticates with Amazon Cognito and receives a JWT (ID token) containing the custom `tenantId` claim +2. Client makes a request to the API with the JWT in the `Authorization` header +3. API Gateway forwards the token to the Lambda Authorizer +4. The Lambda Authorizer decodes the JWT, extracts the `custom:tenantId` claim, and looks up the tenant in the DynamoDB table + - If the tenant exists, the associated API key is retrieved and returned in the authorization context via `usageIdentifierKey` + - If the tenant does not exist or the token is invalid, the request is denied +5. The API Gateway allows or denies access to the protected endpoint based on the policy returned by the authorizer + +The DynamoDB table uses `tenantId` as the partition key and stores the corresponding `apiKey` for each tenant. + +## Testing + +1. Get the outputs from the deployment: + ```bash + # The outputs will be similar to + ApigwDynamodbApikeyCdkStack.ApiUrl = https://abc123def.execute-api.us-east-1.amazonaws.com/prod/ + ApigwDynamodbApikeyCdkStack.TableName = ApigwDynamodbApikeyCdkStack-TenantApiKeyTableXXXXXX-YYYYYY + ApigwDynamodbApikeyCdkStack.UserPoolId = us-east-1_XXXXXXXXX + ApigwDynamodbApikeyCdkStack.UserPoolClientId = XXXXXXXXXXXXXXXXXXXXXXXXXX + ``` + +1. Create a Cognito user with a tenantId: + ```bash + aws cognito-idp admin-create-user \ + --user-pool-id USER_POOL_ID \ + --username user@example.com \ + --user-attributes Name=email,Value=user@example.com Name=custom:tenantId,Value=sample-tenant \ + --temporary-password "TempPass1!" + ``` + +1. Set a permanent password for the user: + ```bash + aws cognito-idp admin-set-user-password \ + --user-pool-id USER_POOL_ID \ + --username user@example.com \ + --password "MySecurePass1!" \ + --permanent + ``` + +1. Insert a tenant mapping into the DynamoDB table: + ```bash + aws dynamodb put-item \ + --table-name TABLE_NAME \ + --item '{"tenantId": {"S": "sample-tenant"}, "apiKey": {"S": "my-api-key-123"}}' + ``` + +1. Get a token and call the API using the helper script: + ```bash + node get-token.js --user-pool-id USER_POOL_ID --client-id CLIENT_ID \ + --username user@example.com --password "MySecurePass1!" \ + --api-url https://REPLACE_WITH_API_URL/protected + ``` + If successful, you should receive a response like: + ```json + { "message": "Access granted" } + ``` + +1. Try with an invalid or missing token: + ```bash + curl https://REPLACE_WITH_API_URL/protected + ``` + You should receive an unauthorized error. + +## Cleanup + +1. Delete the stack: + ```bash + cdk destroy + ``` + +---- +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/apigw-APIKey-tenantid-cdk/apigw-dynamodb-apikey-cdk.jpg b/apigw-APIKey-tenantid-cdk/apigw-dynamodb-apikey-cdk.jpg new file mode 100644 index 000000000..e8db079e1 Binary files /dev/null and b/apigw-APIKey-tenantid-cdk/apigw-dynamodb-apikey-cdk.jpg differ diff --git a/apigw-APIKey-tenantid-cdk/cdk.json b/apigw-APIKey-tenantid-cdk/cdk.json new file mode 100644 index 000000000..36520c8e3 --- /dev/null +++ b/apigw-APIKey-tenantid-cdk/cdk.json @@ -0,0 +1,88 @@ +{ + "app": "npx ts-node --prefer-ts-exts src/bin/apigw-dynamodb-apikey-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/apigw-APIKey-tenantid-cdk/deploy_dynamodb.sh b/apigw-APIKey-tenantid-cdk/deploy_dynamodb.sh new file mode 100644 index 000000000..83554368c --- /dev/null +++ b/apigw-APIKey-tenantid-cdk/deploy_dynamodb.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail + +APP="npx ts-node --prefer-ts-exts src/bin/apigw-dynamodb-apikey-cdk.ts" +STACK_NAME="ApigwDynamodbApikeyCdkStack" + +npm install +cdk deploy "$STACK_NAME" --app "$APP" "$@" diff --git a/apigw-APIKey-tenantid-cdk/example-pattern-tenantbasedAPIkeyauthorization.json b/apigw-APIKey-tenantid-cdk/example-pattern-tenantbasedAPIkeyauthorization.json new file mode 100644 index 000000000..80c3dcf96 --- /dev/null +++ b/apigw-APIKey-tenantid-cdk/example-pattern-tenantbasedAPIkeyauthorization.json @@ -0,0 +1,74 @@ +{ + "title": "Amazon API Gateway with Cognito, Lambda Authorizer & DynamoDB for Tenant API Key Authentication", + "description": "Implement a secure tenant-based API key authorization system using Amazon Cognito, Amazon API Gateway, AWS Lambda Authorizer, and Amazon DynamoDB. Cognito issues JWTs with a custom tenantId claim, and the Lambda authorizer maps tenants to API keys via DynamoDB.", + + "language": "TypeScript", + "level": "200", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern demonstrates how to implement a secure tenant-based API key authorization system using Amazon Cognito, Amazon API Gateway, Lambda Authorizer, and Amazon DynamoDB.", + "Amazon Cognito authenticates users and issues JWTs (ID tokens) containing a custom tenantId claim.", + "The client sends the JWT in the Authorization header. API Gateway forwards the token to the Lambda authorizer, which decodes the JWT, extracts the custom:tenantId claim, and queries DynamoDB to retrieve the corresponding API key.", + "The authorizer returns a policy document with the usageIdentifierKey set to the API key, enabling API Gateway usage plan integration.", + "The API Gateway then allows or denies access to the protected endpoint based on the policy returned by the authorizer." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-APIKey-tenantid-cdk", + "templateURL": "serverless-patterns/apigw-APIKey-tenantid-cdk", + "projectFolder": "apigw-APIKey-tenantid-cdk", + "templateFile": "src/lib/apigw-dynamodb-apikey-stack.ts" + } + }, + "resources": { + "bullets": [ + { + "text": "Amazon Cognito Developer Guide", + "link": "https://docs.aws.amazon.com/cognito/latest/developerguide/what-is-amazon-cognito.html" + }, + { + "text": "Lambda Authorizers for Amazon API Gateway", + "link": "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html" + }, + { + "text": "Amazon DynamoDB Developer Guide", + "link": "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Introduction.html" + }, + { + "text": "Amazon API Gateway - REST APIs", + "link": "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html" + }, + { + "text": "API Gateway Usage Plans", + "link": "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-usage-plans.html" + } + ] + }, + "deploy": { + "text": ["npm install", "cdk deploy"] + }, + "testing": { + "text": [ + "Create a Cognito user: aws cognito-idp admin-create-user --user-pool-id USER_POOL_ID --username user@example.com --user-attributes Name=email,Value=user@example.com Name=custom:tenantId,Value=sample-tenant --temporary-password \"TempPass1!\"", + "Set a permanent password: aws cognito-idp admin-set-user-password --user-pool-id USER_POOL_ID --username user@example.com --password \"MySecurePass1!\" --permanent", + "Insert a tenant mapping into the DynamoDB table: aws dynamodb put-item --table-name TABLE_NAME --item '{\"tenantId\": {\"S\": \"sample-tenant\"}, \"apiKey\": {\"S\": \"my-api-key-123\"}}'", + "Get a token and call the API: node get-token.js --user-pool-id USER_POOL_ID --client-id CLIENT_ID --username user@example.com --password \"MySecurePass1!\" --api-url https://REPLACE_WITH_API_URL/protected", + "If successful, you should receive a response: { \"message\": \"Access granted\" }" + ] + }, + "cleanup": { + "text": [ + "Delete the CDK stack: cdk destroy" + ] + }, + "authors": [ + { + "name": "Lavanya Tangutur", + "bio": "Lavanya Tangutur serves as a Senior Technical Account Manager at AWS ocused on helping customers build, deploy, and run secure, resilient, and cost-effective workloads on AWS.", + "linkedin": "www.linkedin.com/in/lavanyatangutur" + } + ] +} diff --git a/apigw-APIKey-tenantid-cdk/get-token.js b/apigw-APIKey-tenantid-cdk/get-token.js new file mode 100644 index 000000000..9a5f2b039 --- /dev/null +++ b/apigw-APIKey-tenantid-cdk/get-token.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +// Usage: +// node get-token.js --user-pool-id --client-id --username --password --api-url +// +// Authenticates with Cognito, retrieves an ID token, and calls the API Gateway endpoint. + +const { + CognitoIdentityProviderClient, + InitiateAuthCommand, +} = require("@aws-sdk/client-cognito-identity-provider"); +const https = require("https"); +const http = require("http"); + +function parseArgs() { + const args = process.argv.slice(2); + const parsed = {}; + for (let i = 0; i < args.length; i += 2) { + parsed[args[i].replace(/^--/, "")] = args[i + 1]; + } + const required = ["user-pool-id", "client-id", "username", "password", "api-url"]; + for (const key of required) { + if (!parsed[key]) { + console.error(`Missing required argument: --${key}`); + process.exit(1); + } + } + return parsed; +} + +async function getToken(clientId, username, password) { + const client = new CognitoIdentityProviderClient(); + const resp = await client.send( + new InitiateAuthCommand({ + AuthFlow: "USER_PASSWORD_AUTH", + ClientId: clientId, + AuthParameters: { USERNAME: username, PASSWORD: password }, + }) + ); + return resp.AuthenticationResult.IdToken; +} + +function callApi(url, token) { + return new Promise((resolve, reject) => { + const mod = url.startsWith("https") ? https : http; + const req = mod.get(url, { headers: { Authorization: `Bearer ${token}` } }, (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + console.log(`Status: ${res.statusCode}`); + try { console.log(JSON.stringify(JSON.parse(body), null, 2)); } + catch { console.log(body); } + resolve(); + }); + }); + req.on("error", reject); + }); +} + +(async () => { + const args = parseArgs(); + try { + console.log("Authenticating with Cognito..."); + const token = await getToken(args["client-id"], args.username, args.password); + console.log("Token obtained. Calling API...\n"); + await callApi(args["api-url"], token); + } catch (err) { + console.error("Error:", err.message); + process.exit(1); + } +})(); diff --git a/apigw-APIKey-tenantid-cdk/package.json b/apigw-APIKey-tenantid-cdk/package.json new file mode 100644 index 000000000..c74b3300e --- /dev/null +++ b/apigw-APIKey-tenantid-cdk/package.json @@ -0,0 +1,24 @@ +{ + "name": "apigw-dynamodb-apikey-cdk", + "version": "0.1.0", + "bin": { + "apigw-dynamodb-apikey-cdk": "bin/apigw-dynamodb-apikey-cdk.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk" + }, + "devDependencies": { + "@types/node": "22.7.9", + "aws-cdk": "2.1003.0", + "esbuild": "^0.25.1", + "ts-node": "^10.9.2", + "typescript": "~5.6.3" + }, + "dependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.1034.0", + "aws-cdk-lib": "2.189.1", + "constructs": "^10.0.0" + } +} diff --git a/apigw-APIKey-tenantid-cdk/src/bin/apigw-dynamodb-apikey-cdk.ts b/apigw-APIKey-tenantid-cdk/src/bin/apigw-dynamodb-apikey-cdk.ts new file mode 100644 index 000000000..09da105bc --- /dev/null +++ b/apigw-APIKey-tenantid-cdk/src/bin/apigw-dynamodb-apikey-cdk.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import * as cdk from "aws-cdk-lib"; +import { ApigwDynamodbApikeyStack } from "../lib/apigw-dynamodb-apikey-stack"; + +const app = new cdk.App(); +new ApigwDynamodbApikeyStack(app, "ApigwDynamodbApikeyCdkStack", { + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, +}); diff --git a/apigw-APIKey-tenantid-cdk/src/lib/apigw-dynamodb-apikey-stack.ts b/apigw-APIKey-tenantid-cdk/src/lib/apigw-dynamodb-apikey-stack.ts new file mode 100644 index 000000000..d3e494875 --- /dev/null +++ b/apigw-APIKey-tenantid-cdk/src/lib/apigw-dynamodb-apikey-stack.ts @@ -0,0 +1,114 @@ +import * as cdk from "aws-cdk-lib"; +import * as apigateway from "aws-cdk-lib/aws-apigateway"; +import * as lambda from "aws-cdk-lib/aws-lambda-nodejs"; +import { Runtime } from "aws-cdk-lib/aws-lambda"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import * as cognito from "aws-cdk-lib/aws-cognito"; +import * as path from "path"; +import { Construct } from "constructs"; + +export class ApigwDynamodbApikeyStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // DynamoDB table mapping tenantId -> apiKey + const table = new dynamodb.Table(this, "TenantApiKeyTable", { + partitionKey: { name: "tenantId", type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // Cognito User Pool with custom tenantId attribute + const userPool = new cognito.UserPool(this, "TenantUserPool", { + selfSignUpEnabled: false, + signInAliases: { email: true }, + customAttributes: { + tenantId: new cognito.StringAttribute({ mutable: false }), + }, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const userPoolClient = userPool.addClient("TenantUserPoolClient", { + authFlows: { userPassword: true }, + }); + + // Lambda authorizer log group + const authorizerLogGroup = new cdk.aws_logs.LogGroup(this, "AuthorizerLogGroup", { + retention: cdk.aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // Lambda authorizer + const authorizerFn = new lambda.NodejsFunction(this, "DynamoDbApiKeyAuthorizer", { + runtime: Runtime.NODEJS_22_X, + entry: path.join(__dirname, "lambda/dynamodb-authorizer.js"), + timeout: cdk.Duration.seconds(10), + environment: { + TABLE_NAME: table.tableName, + }, + bundling: { + externalModules: ["@aws-sdk/*"], + }, + logGroup: authorizerLogGroup, + }); + + table.grantReadData(authorizerFn); + + // API Gateway + const api = new apigateway.RestApi(this, "ApiGateway", { + restApiName: "DynamoDB API Key Protected Service", + description: "API protected with DynamoDB-based API key authorization", + }); + + // Token authorizer using Authorization header (Cognito JWT) + const lambdaAuthorizer = new apigateway.TokenAuthorizer(this, "TokenAuthorizer", { + handler: authorizerFn, + identitySource: "method.request.header.Authorization", + }); + + // Protected endpoint with mock integration + const protectedResource = api.root.addResource("protected"); + + protectedResource.addMethod( + "GET", + new apigateway.MockIntegration({ + integrationResponses: [ + { + statusCode: "200", + responseTemplates: { + "application/json": '{ "message": "Access granted" }', + }, + }, + ], + passthroughBehavior: apigateway.PassthroughBehavior.NEVER, + requestTemplates: { + "application/json": '{ "statusCode": 200 }', + }, + }), + { + authorizer: lambdaAuthorizer, + methodResponses: [{ statusCode: "200" }], + }, + ); + + new cdk.CfnOutput(this, "ApiUrl", { + value: api.url, + description: "URL of the API Gateway", + }); + + new cdk.CfnOutput(this, "TableName", { + value: table.tableName, + description: "DynamoDB table name for tenant-apikey mappings", + }); + + new cdk.CfnOutput(this, "UserPoolId", { + value: userPool.userPoolId, + description: "Cognito User Pool ID", + }); + + new cdk.CfnOutput(this, "UserPoolClientId", { + value: userPoolClient.userPoolClientId, + description: "Cognito User Pool Client ID", + }); + } +} diff --git a/apigw-APIKey-tenantid-cdk/src/lib/lambda/dynamodb-authorizer.js b/apigw-APIKey-tenantid-cdk/src/lib/lambda/dynamodb-authorizer.js new file mode 100644 index 000000000..995f93f9f --- /dev/null +++ b/apigw-APIKey-tenantid-cdk/src/lib/lambda/dynamodb-authorizer.js @@ -0,0 +1,56 @@ +import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb"; +const client = new DynamoDBClient(); + +const TABLE_NAME = process.env.TABLE_NAME; + +exports.handler = async (event) => { + const token = event.authorizationToken; + + if (!token) { + throw new Error("Unauthorized: No token provided"); + } + + try { + // Decode the JWT payload (Cognito token is already validated by API Gateway if needed) + const payload = JSON.parse( + Buffer.from(token.replace(/^Bearer\s+/i, "").split(".")[1], "base64url").toString(), + ); + const tenantId = payload["custom:tenantId"]; + + if (!tenantId) { + throw new Error("Unauthorized: No tenant ID in claims"); + } + + const result = await client.send( + new GetItemCommand({ + TableName: TABLE_NAME, + Key: { tenantId: { S: tenantId } }, + }), + ); + + if (!result.Item || !result.Item.apiKey) { + throw new Error("Unauthorized: Tenant not found"); + } + + const apiKey = result.Item.apiKey.S; + + return { + principalId: tenantId, + policyDocument: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Resource: event.methodArn, + Action: "execute-api:Invoke", + }, + ], + }, + context: { tenantId }, + usageIdentifierKey: apiKey, + }; + } catch (error) { + console.error("Authorization error:", error.message); + throw new Error("Unauthorized"); + } +}; diff --git a/apigw-APIKey-tenantid-cdk/tsconfig.json b/apigw-APIKey-tenantid-cdk/tsconfig.json new file mode 100644 index 000000000..aaa7dc510 --- /dev/null +++ b/apigw-APIKey-tenantid-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" + ] +}