From 69534a56ab90011bd0f45c4cc852a64f065d035d Mon Sep 17 00:00:00 2001 From: mate329 Date: Fri, 16 Jan 2026 09:27:25 +0100 Subject: [PATCH 1/2] cloudfront-s3-signed-cookies-cognito pattern push --- .../README.md | 169 ++++++++ cloudfront-s3-signed-cookies-cognito/app.py | 366 ++++++++++++++++++ cloudfront-s3-signed-cookies-cognito/cdk.json | 29 ++ .../example-pattern.json | 68 ++++ .../lambda/Login/lambda_handler.py | 340 ++++++++++++++++ .../lambda/Register/lambda_handler.py | 150 +++++++ .../requirements.txt | 3 + 7 files changed, 1125 insertions(+) create mode 100644 cloudfront-s3-signed-cookies-cognito/README.md create mode 100644 cloudfront-s3-signed-cookies-cognito/app.py create mode 100644 cloudfront-s3-signed-cookies-cognito/cdk.json create mode 100644 cloudfront-s3-signed-cookies-cognito/example-pattern.json create mode 100644 cloudfront-s3-signed-cookies-cognito/lambda/Login/lambda_handler.py create mode 100644 cloudfront-s3-signed-cookies-cognito/lambda/Register/lambda_handler.py create mode 100644 cloudfront-s3-signed-cookies-cognito/requirements.txt diff --git a/cloudfront-s3-signed-cookies-cognito/README.md b/cloudfront-s3-signed-cookies-cognito/README.md new file mode 100644 index 000000000..cd080ca6a --- /dev/null +++ b/cloudfront-s3-signed-cookies-cognito/README.md @@ -0,0 +1,169 @@ +# Amazon CloudFront signed cookies with Amazon Cognito authentication using Python CDK + +This pattern demonstrates how to implement Amazon CloudFront signed cookies for private S3 content access with Amazon Cognito user authentication using AWS CDK with Python. + +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 +* [AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/cli.html) installed and configured +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [Python 3.9+](https://www.python.org/downloads/) 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 cloudfront-s3-signed-cookies-cognito + ``` +1. Create a virtual environment for Python: + ```bash + python3 -m venv .venv + ``` +1. Activate the virtual environment: + ```bash + source .venv/bin/activate + ``` + For Windows: + ```bash + .venv\Scripts\activate.bat + ``` +1. Install the required dependencies: + ```bash + pip install -r requirements.txt + ``` +1. Bootstrap your AWS account for CDK (if you haven't done so already): + ```bash + cdk bootstrap + ``` +1. Deploy the stack: + ```bash + cdk deploy + ``` +1. Note the outputs from the CDK deployment. These contain the API endpoints, CloudFront distribution URL, and other important resource information. + +## How it works + +This pattern creates a secure content delivery solution using CloudFront signed cookies with the following workflow: + +1. **User Registration**: Users register via the `/register` API endpoint, which creates a new user in the Amazon Cognito User Pool. + +2. **User Authentication**: Users authenticate via the `/login` API endpoint with their email and password. Upon successful authentication: + - Cognito returns JWT tokens (ID token, access token, refresh token) + - The Lambda function retrieves the CloudFront private key from AWS Secrets Manager + - CloudFront signed cookies are generated with a configurable TTL + - Both Cognito tokens and signed cookies are returned to the client + +3. **Content Access**: + - Public content under the default path is accessible without authentication + - Private content under the `/private/*` path requires valid CloudFront signed cookies + - The CloudFront distribution validates the signed cookies against the configured Key Group + +4. **Security**: + - S3 bucket is configured as private with no public access + - CloudFront uses Origin Access Control (OAC) to securely access S3 content + - RSA key pairs are used for signing, with the private key securely stored in AWS Secrets Manager + - The public key is configured in a CloudFront Key Group for cookie validation + +## Architecture Components + +- **Amazon Cognito User Pool**: Manages user registration and authentication +- **API Gateway**: REST API with `/register` and `/login` endpoints +- **AWS Lambda**: Two functions for user registration and login (with signed cookie generation) +- **AWS Secrets Manager**: Securely stores the CloudFront private key +- **Amazon S3**: Hosts private content accessible only via CloudFront +- **Amazon CloudFront**: + - Distribution with Origin Access Control (OAC) + - Public Key and Key Group for signed cookie validation + - Behavior rules for public vs private content +- **AWS Lambda Powertools**: For structured logging and observability + +## Testing + +### 1. Register a new user + +```bash +curl -X POST https://{API_ID}.execute-api.{REGION}.amazonaws.com/v1/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "TestPassword123!", + "name": "Test User" + }' +``` + +### 2. Login and receive signed cookies + +```bash +curl -X POST https://{API_ID}.execute-api.{REGION}.amazonaws.com/v1/login \ + -H "Content-Type: application/json" \ + -c cookies.txt \ + -d '{ + "email": "user@example.com", + "password": "TestPassword123!" + }' +``` + +The response will include Cognito tokens and set CloudFront signed cookies. + +### 3. Upload test content to S3 + +```bash +# Upload public content +aws s3 cp test-public.html s3://{BUCKET_NAME}/index.html + +# Upload private content +aws s3 cp test-private.html s3://{BUCKET_NAME}/private/secret.html +``` + +### 4. Access public content (no authentication required) + +```bash +curl https://{CLOUDFRONT_DOMAIN}/index.html +``` + +### 5. Access private content (requires signed cookies) + +```bash +# Without cookies (should fail) +curl https://{CLOUDFRONT_DOMAIN}/private/secret.html + +# With cookies from login (should succeed) +curl -b cookies.txt https://{CLOUDFRONT_DOMAIN}/private/secret.html +``` + +## Configuration + +The stack supports the following context variables in `cdk.json`: + +- `allowed_cors_origin`: CORS origin for API Gateway (default: "*") +- `cookie_domain`: Domain for CloudFront signed cookies (optional) +- `same_site`: SameSite attribute for cookies (default: "None") +- `cookie_ttl_seconds`: TTL for signed cookies in seconds (default: 600) + +Example: +```bash +cdk deploy -c allowed_cors_origin="https://example.com" -c cookie_ttl_seconds=3600 +``` + +## Cleanup + +1. Empty the S3 bucket (if you uploaded any content): + ```bash + aws s3 rm s3://{BUCKET_NAME} --recursive + ``` +2. Delete the stack: + ```bash + cdk destroy + ``` +3. Confirm the stack has been deleted: + ```bash + aws cloudformation list-stacks --query "StackSummaries[?contains(StackName,'CloudFrontSignedCookiesStack')].StackStatus" + ``` + diff --git a/cloudfront-s3-signed-cookies-cognito/app.py b/cloudfront-s3-signed-cookies-cognito/app.py new file mode 100644 index 000000000..0d5efa8fc --- /dev/null +++ b/cloudfront-s3-signed-cookies-cognito/app.py @@ -0,0 +1,366 @@ +from constructs import Construct +from aws_cdk import ( + App, Stack, + CfnOutput, + Duration, + RemovalPolicy, + SecretValue, + aws_s3 as s3, + aws_cloudfront as cloudfront, + aws_cloudfront_origins as origins, + aws_secretsmanager as secretsmanager, + aws_lambda as _lambda, + aws_apigateway as apigw, + aws_iam as iam, + aws_cognito as cognito, +) +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend + +class CloudFrontSignedCookiesStack(Stack): + """CDK Stack for CloudFront signed cookies pattern.""" + + def __init__(self, scope: Construct, construct_id: str, user_pool=None, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + # ============================================================ + # Context Variables / Configuration + # ============================================================ + allowed_cors_origin = self.node.try_get_context("allowed_cors_origin") or "*" + cookie_domain = self.node.try_get_context("cookie_domain") or "" + same_site = self.node.try_get_context("same_site") or "None" + cookie_ttl_seconds = int(self.node.try_get_context("cookie_ttl_seconds") or "600") + + # ============================================================ + # Cognito User Pool + # ============================================================ + user_pool = cognito.UserPool( + self, + "UserPool", + user_pool_name="UserPool", + auto_verify=cognito.AutoVerifiedAttrs(email=True), + sign_in_aliases=cognito.SignInAliases(email=True), + self_sign_up_enabled=True, + password_policy=cognito.PasswordPolicy( + min_length=8, + require_lowercase=True, + require_uppercase=True, + require_digits=True, + require_symbols=False, + ), + standard_attributes=cognito.StandardAttributes( + email=cognito.StandardAttribute(required=True, mutable=True), + ), + removal_policy=RemovalPolicy.DESTROY, + ) + + user_pool_client = cognito.UserPoolClient( + self, + "UserPoolClient", + user_pool=user_pool, + user_pool_client_name="WebApp", + generate_secret=False, + auth_flows=cognito.AuthFlow( + user_srp=True, + user_password=True, + custom=False, + admin_user_password=False, + ), + prevent_user_existence_errors=True, + ) + + # ============================================================ + # S3 Bucket for Private Assets + # ============================================================ + private_assets_bucket = s3.Bucket( + self, + "PrivateAssetsBucket", + block_public_access=s3.BlockPublicAccess.BLOCK_ALL, + encryption=s3.BucketEncryption.S3_MANAGED, + enforce_ssl=True, + removal_policy=RemovalPolicy.DESTROY, + auto_delete_objects=True, + versioned=False, + ) + + # ============================================================ + # CloudFront Origin Access Control (OAC) + # ============================================================ + oac = cloudfront.CfnOriginAccessControl( + self, + "S3OriginAccessControl", + origin_access_control_config=cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty( + name=f"{construct_id}-S3-OAC", + origin_access_control_origin_type="s3", + signing_behavior="always", + signing_protocol="sigv4", + description="OAC for private S3 bucket access via CloudFront", + ), + ) + + # ============================================================ + # CloudFront Public Key and Key Group + # ============================================================ + # Generate private key + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + + # Serialize private key to PEM format + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ).decode('utf-8') + + # Extract public key and serialize to PEM format + public_key = private_key.public_key() + public_key_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ).decode('utf-8') + + cf_public_key = cloudfront.PublicKey( + self, + "CloudFrontPublicKey", + encoded_key=public_key_pem, + comment="Public key for CloudFront signed cookies", + ) + + cf_key_group = cloudfront.KeyGroup( + self, + "CloudFrontKeyGroup", + items=[cf_public_key], + comment="Key group for signed cookie validation", + ) + + # ============================================================ + # Secrets Manager - Private Key Storage + # ============================================================ + private_key_secret = secretsmanager.Secret( + self, + "CloudFrontPrivateKeySecret", + description="CloudFront private key for signing cookies (PEM format)", + secret_string_value=SecretValue.unsafe_plain_text(private_key_pem), + removal_policy=RemovalPolicy.DESTROY, + ) + + # ============================================================ + # CloudFront Distribution + # ============================================================ + # Create the S3 origin without OAC first (we'll add OAC via escape hatch) + s3_origin = origins.S3BucketOrigin.with_origin_access_control( + private_assets_bucket + ) + + # Default behavior - public content (no signed cookies required) + default_behavior = cloudfront.BehaviorOptions( + origin=s3_origin, + viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + allowed_methods=cloudfront.AllowedMethods.ALLOW_GET_HEAD, + cached_methods=cloudfront.CachedMethods.CACHE_GET_HEAD, + cache_policy=cloudfront.CachePolicy.CACHING_OPTIMIZED, + compress=True, + ) + + # Private behavior - requires signed cookies + private_behavior = cloudfront.BehaviorOptions( + origin=s3_origin, + viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + allowed_methods=cloudfront.AllowedMethods.ALLOW_GET_HEAD, + cached_methods=cloudfront.CachedMethods.CACHE_GET_HEAD, + cache_policy=cloudfront.CachePolicy.CACHING_OPTIMIZED, + compress=True, + trusted_key_groups=[cf_key_group], + ) + + distribution = cloudfront.Distribution( + self, + "PrivateContentDistribution", + default_behavior=default_behavior, + additional_behaviors={ + "private/*": private_behavior, + }, + comment="Distribution for private S3 content with signed cookies", + price_class=cloudfront.PriceClass.PRICE_CLASS_100, + enabled=True, + ) + + # ============================================================ + # Apply OAC to CloudFront Distribution (L1 escape hatch) + # ============================================================ + cfn_distribution = distribution.node.default_child + + # Remove OAI configuration and add OAC + cfn_distribution.add_property_override( + "DistributionConfig.Origins.0.S3OriginConfig.OriginAccessIdentity", "" + ) + cfn_distribution.add_property_override( + "DistributionConfig.Origins.0.OriginAccessControlId", oac.attr_id + ) + + # ============================================================ + # S3 Bucket Policy for CloudFront OAC + # ============================================================ + private_assets_bucket.add_to_resource_policy( + iam.PolicyStatement( + sid="AllowCloudFrontServicePrincipalReadOnly", + effect=iam.Effect.ALLOW, + principals=[iam.ServicePrincipal("cloudfront.amazonaws.com")], + actions=["s3:GetObject"], + resources=[private_assets_bucket.arn_for_objects("*")], + conditions={ + "StringEquals": { + "AWS:SourceArn": f"arn:aws:cloudfront::{self.account}:distribution/{distribution.distribution_id}" + } + }, + ) + ) + + # ============================================================ + # Lambdas + # ============================================================ + # IAM Policy for Lambda functions to access Cognito + cognito_policy = iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "cognito-idp:SignUp", + "cognito-idp:AdminConfirmSignUp", + "cognito-idp:InitiateAuth", + ], + resources=[user_pool.user_pool_arn], + ) + + # Register User Lambda Function + register_lambda = _lambda.Function( + self, + "RegisterUserLambda", + runtime=_lambda.Runtime.PYTHON_3_12, + handler="lambda_handler.handler", + code=_lambda.Code.from_asset( + "./lambda/Register", + bundling={ + "image": _lambda.Runtime.PYTHON_3_12.bundling_image, + "command": ["bash", "-c", "pip install aws-lambda-powertools -t /asset-output && cp -r . /asset-output"], + }, + ), + environment={ + "POWERTOOLS_SERVICE_NAME": "authentication", + "USER_POOL_ID": user_pool.user_pool_id, + "USER_POOL_CLIENT_ID": user_pool_client.user_pool_client_id, + "ALLOWED_ORIGIN": allowed_cors_origin, + }, + timeout=Duration.seconds(30), + ) + register_lambda.add_to_role_policy(cognito_policy) + + # Login User Lambda Function + login_lambda = _lambda.Function( + self, + "LoginUserLambda", + runtime=_lambda.Runtime.PYTHON_3_12, + handler="lambda_handler.handler", + code=_lambda.Code.from_asset( + "./lambda/Login", + bundling={ + "image": _lambda.Runtime.PYTHON_3_12.bundling_image, + "command": [ + "bash", + "-lc", + "pip install --platform manylinux2014_x86_64 --only-binary=:all: --no-cache-dir --upgrade " + "-t /asset-output aws-lambda-powertools 'cryptography>=41' " + "&& cp -au . /asset-output" + ], + }, + + ), + environment={ + "POWERTOOLS_SERVICE_NAME": "authentication", + "USER_POOL_ID": user_pool.user_pool_id, + "USER_POOL_CLIENT_ID": user_pool_client.user_pool_client_id, + "ALLOWED_ORIGIN": allowed_cors_origin, + "PRIVATE_KEY_SECRET_ARN": private_key_secret.secret_arn, + "CLOUDFRONT_DOMAIN": distribution.distribution_domain_name, + "KEY_PAIR_ID": cf_public_key.public_key_id, + "COOKIE_TTL_SECONDS": str(cookie_ttl_seconds), + "COOKIE_DOMAIN": cookie_domain, + "COOKIE_SAME_SITE": same_site, + }, + timeout=Duration.seconds(30), + memory_size=256, + ) + login_lambda.add_to_role_policy(cognito_policy) + private_key_secret.grant_read(login_lambda) + + # ============================================================ + # API Gateway REST API + # ============================================================ + api = apigw.RestApi( + self, + "AuthApi", + rest_api_name="Auth and Cookie API", + description="API for authentication and CloudFront signed cookies", + deploy=True, + deploy_options=apigw.StageOptions(stage_name="v1"), + default_cors_preflight_options=apigw.CorsOptions( + allow_origins=[allowed_cors_origin] if allowed_cors_origin != "*" else apigw.Cors.ALL_ORIGINS, + allow_methods=["POST", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-Amz-Date", "X-Api-Key"], + allow_credentials=True, + ), + ) + + # API Gateway Lambda Integration + register_integration = apigw.LambdaIntegration(register_lambda) + login_integration = apigw.LambdaIntegration(login_lambda) + + # API Gateway Resources and Methods + api.root.add_resource("register").add_method("POST", register_integration) + api.root.add_resource("login").add_method("POST", login_integration) + + + # ============================================================ + # Outputs + # ============================================================ + CfnOutput( + self, + "CloudFrontDistributionId", + description="CloudFront Distribution ID", + value=distribution.distribution_id, + ) + + CfnOutput( + self, + "ApiEndpoint", + description="API Gateway Endpoint URL", + value=api.url, + export_name=f"{construct_id}-ApiEndpoint", + ) + + CfnOutput( + self, + "RegisterEndpoint", + description="Register Lambda API Endpoint", + value=f"{api.url}register", + ) + + CfnOutput( + self, + "LoginEndpoint", + description="Login Lambda API Endpoint", + value=f"{api.url}login", + ) + + CfnOutput( + self, + "DistributionUrl", + description="CloudFront Distribution URL", + value=f"https://{distribution.distribution_domain_name}", + ) + +app = App() +CloudFrontSignedCookiesStack(app, "CloudFrontSignedCookiesStack") +app.synth() diff --git a/cloudfront-s3-signed-cookies-cognito/cdk.json b/cloudfront-s3-signed-cookies-cognito/cdk.json new file mode 100644 index 000000000..6d3592131 --- /dev/null +++ b/cloudfront-s3-signed-cookies-cognito/cdk.json @@ -0,0 +1,29 @@ +{ + "app": "python3 app.py", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "source.bat", + "**/*.pyc", + "**/__pycache__", + ".git", + ".venv", + "*.pem" + ] + }, + "context": { + "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, + "@aws-cdk/core:stackRelativeExports": true, + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "allowed_cors_origin": "*", + "cookie_domain": "", + "same_site": "None", + "cookie_ttl_seconds": "600" + } +} \ No newline at end of file diff --git a/cloudfront-s3-signed-cookies-cognito/example-pattern.json b/cloudfront-s3-signed-cookies-cognito/example-pattern.json new file mode 100644 index 000000000..195c7e138 --- /dev/null +++ b/cloudfront-s3-signed-cookies-cognito/example-pattern.json @@ -0,0 +1,68 @@ +{ + "title": "Amazon CloudFront signed cookies with Amazon Cognito authentication using Python CDK", + "description": "This pattern demonstrates how to implement Amazon CloudFront signed cookies for private S3 content access with Amazon Cognito user authentication using AWS CDK with Python.", + "language": "Python", + "level": "300", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern creates a secure content delivery solution using CloudFront signed cookies. Users authenticate through Amazon Cognito via API Gateway Lambda functions.", + "Upon successful login, the Lambda function generates CloudFront signed cookies that grant time-limited access to private S3 content behind the CloudFront distribution.", + "The CloudFront distribution uses Origin Access Control (OAC) to securely access private S3 content. Public content is accessible without authentication, while private content requires valid signed cookies.", + "The signed cookies use RSA key pairs, with the private key stored securely in AWS Secrets Manager and the public key configured in a CloudFront Key Group." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/cloudfront-s3-signed-cookies-cognito", + "templateURL": "serverless-patterns/cloudfront-s3-signed-cookies-cognito", + "projectFolder": "cloudfront-s3-signed-cookies-cognito", + "templateFile": "cloudfront-s3-signed-cookies-cognito/app.py" + } + }, + "resources": { + "bullets": [ + { + "text": "Serving private content with signed URLs and signed cookies", + "link": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PrivateContent.html" + }, + { + "text": "Using CloudFront signed cookies", + "link": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-cookies.html" + }, + { + "text": "Amazon Cognito User Pools", + "link": "https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html" + }, + { + "text": "Restricting access to Amazon S3 content by using an origin access control", + "link": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html" + } + ] + }, + "deploy": { + "text": [ + "cdk deploy" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: cdk destroy." + ] + }, + "authors": [ + { + "name": "Matia Rasetina", + "image": "", + "bio": "Senior Software Engineer @ Elixirr Digital", + "linkedin": "https://www.linkedin.com/in/matiarasetina/", + "twitter": "" + } + ] +} diff --git a/cloudfront-s3-signed-cookies-cognito/lambda/Login/lambda_handler.py b/cloudfront-s3-signed-cookies-cognito/lambda/Login/lambda_handler.py new file mode 100644 index 000000000..c80dc0773 --- /dev/null +++ b/cloudfront-s3-signed-cookies-cognito/lambda/Login/lambda_handler.py @@ -0,0 +1,340 @@ +import json +import os +import base64 +import time +from typing import Any, Dict, Tuple + +import boto3 +from botocore.exceptions import ClientError +from aws_lambda_powertools import Logger +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.backends import default_backend + +# Configure logging +logger = Logger() + +# Initialize AWS clients +cognito_client = boto3.client('cognito-idp') +secrets_client = boto3.client('secretsmanager') + +# Environment variables +USER_POOL_ID = os.environ['USER_POOL_ID'] +USER_POOL_CLIENT_ID = os.environ['USER_POOL_CLIENT_ID'] +ALLOWED_ORIGIN = os.environ.get('ALLOWED_ORIGIN', '*') +PRIVATE_KEY_SECRET_ARN = os.environ.get('PRIVATE_KEY_SECRET_ARN', '') +CLOUDFRONT_DOMAIN = os.environ.get('CLOUDFRONT_DOMAIN', '') +KEY_PAIR_ID = os.environ.get('KEY_PAIR_ID', '') +COOKIE_TTL_SECONDS = int(os.environ.get('COOKIE_TTL_SECONDS', '600')) +COOKIE_DOMAIN = os.environ.get('COOKIE_DOMAIN', '') +COOKIE_SAME_SITE = os.environ.get('COOKIE_SAME_SITE', 'None') + +# Cache for the private key +_private_key_cache: Dict[str, Any] = {} + + +def get_private_key(): + """Retrieve the private key from Secrets Manager with caching.""" + if "key" not in _private_key_cache: + try: + response = secrets_client.get_secret_value(SecretId=PRIVATE_KEY_SECRET_ARN) + private_key_pem = response["SecretString"] + + private_key = serialization.load_pem_private_key( + private_key_pem.encode("utf-8"), + password=None, + backend=default_backend(), + ) + _private_key_cache["key"] = private_key + except Exception as e: + logger.error(f"Error retrieving private key: {e}") + raise + + return _private_key_cache["key"] + + +def cloudfront_safe_base64(data: bytes) -> str: + """Encode bytes to CloudFront-safe base64.""" + encoded = base64.b64encode(data).decode("utf-8") + return encoded.replace("+", "-").replace("=", "_").replace("/", "~") + + +def create_custom_policy(resource: str, expires_epoch: int) -> str: + """Create a CloudFront custom policy JSON.""" + policy = { + "Statement": [ + { + "Resource": resource, + "Condition": { + "DateLessThan": { + "AWS:EpochTime": expires_epoch + } + } + } + ] + } + return json.dumps(policy, separators=(",", ":")) + + +def sign_policy(policy_json: str, private_key) -> str: + """Sign the policy using RSA PKCS1v15 with SHA1.""" + signature = private_key.sign( + policy_json.encode("utf-8"), + padding.PKCS1v15(), + hashes.SHA1(), + ) + return cloudfront_safe_base64(signature) + + +def create_signed_cookies(resource: str, expires_epoch: int) -> Tuple[str, str, str]: + """Create the three CloudFront signed cookies.""" + private_key = get_private_key() + + policy_json = create_custom_policy(resource, expires_epoch) + policy_b64 = cloudfront_safe_base64(policy_json.encode("utf-8")) + signature = sign_policy(policy_json, private_key) + + return ( + f"CloudFront-Policy={policy_b64}", + f"CloudFront-Signature={signature}", + f"CloudFront-Key-Pair-Id={KEY_PAIR_ID}", + ) + + +def get_cors_headers() -> Dict[str, str]: + """Return CORS headers based on configuration.""" + return { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Credentials': 'true', + 'Content-Type': 'application/json' + } + + +@logger.inject_lambda_context(log_event=True) +def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + request_id = context.aws_request_id + logger.append_keys(request_id=request_id) + + # Handle preflight OPTIONS requests for CORS + if event.get('httpMethod') == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Headers': 'Content-Type,Authorization', + 'Access-Control-Allow-Methods': 'OPTIONS,POST', + 'Access-Control-Allow-Credentials': 'true' + }, + 'body': '' + } + + event_body = json.loads(event.get('body')) if 'body' in event else event + email = event_body.get('email') + password = event_body.get('password') + + # Validate input + if not email or not password: + logger.warning("Login validation failed", extra={ + "reason": "missing_credentials", + "has_email": bool(email), + "has_password": bool(event_body.get('password')) + }) + return { + 'statusCode': 400, + 'headers': get_cors_headers(), + 'body': json.dumps({'message': 'Email and password are required'}) + } + + return log_in_user(email, password) + + +def log_in_user(email: str, password: str) -> Dict[str, Any]: + """Authenticate user with Cognito and return tokens.""" + try: + # Authenticate user using Cognito + response = cognito_client.initiate_auth( + ClientId=USER_POOL_CLIENT_ID, + AuthFlow='USER_PASSWORD_AUTH', + AuthParameters={ + 'USERNAME': email, + 'PASSWORD': password + } + ) + + # Extract tokens from the response + id_token = response['AuthenticationResult']['IdToken'] + access_token = response['AuthenticationResult']['AccessToken'] + refresh_token = response['AuthenticationResult']['RefreshToken'] + expires_in = response['AuthenticationResult']['ExpiresIn'] + + # Decode ID token to get user information + user_info = decode_id_token(id_token) + user_email = user_info.get('email', email) + user_full_name = user_info.get('name', '') + + # For local development, remove Secure attribute if using http://localhost + if not ALLOWED_ORIGIN.startswith('https://'): + cookie_settings = "HttpOnly; Path=/; SameSite=Lax" + else: + cookie_settings = "HttpOnly; Secure; Path=/; SameSite=None" + + logger.info("Authentication successful", extra={ + "email": user_email, + "full_name": user_full_name, + "token_expires_in": expires_in + }) + + # Generate CloudFront signed cookies + cf_cookies = [] + if CLOUDFRONT_DOMAIN and KEY_PAIR_ID and PRIVATE_KEY_SECRET_ARN: + try: + current_time = int(time.time()) + cf_expires_epoch = current_time + COOKIE_TTL_SECONDS + resource = f"https://{CLOUDFRONT_DOMAIN}/private/*" + + policy_cookie, signature_cookie, key_pair_cookie = create_signed_cookies( + resource, cf_expires_epoch + ) + + # Build cookie attributes + cf_cookie_attrs = ["HttpOnly", "Secure", "Path=/"] + if COOKIE_SAME_SITE: + cf_cookie_attrs.append(f"SameSite={COOKIE_SAME_SITE}") + if COOKIE_DOMAIN: + cf_cookie_attrs.append(f"Domain={COOKIE_DOMAIN}") + cf_cookie_attrs.append(f"Max-Age={COOKIE_TTL_SECONDS}") + cf_cookie_settings = "; ".join(cf_cookie_attrs) + + cf_cookies = [ + f"{policy_cookie}; {cf_cookie_settings}", + f"{signature_cookie}; {cf_cookie_settings}", + f"{key_pair_cookie}; {cf_cookie_settings}", + ] + + logger.info("CloudFront signed cookies generated", extra={ + "expires_epoch": cf_expires_epoch, + "resource": resource + }) + except Exception as e: + logger.error(f"Failed to generate CloudFront cookies: {e}") + # Continue without CF cookies + + # Build Cognito token cookies + # For local development, remove Secure attribute if using http://localhost + if not ALLOWED_ORIGIN.startswith('https://'): + cookie_settings = "HttpOnly; Path=/; SameSite=Lax" + else: + cookie_settings = "HttpOnly; Secure; Path=/; SameSite=None" + + cognito_cookies = [ + f"accessToken={access_token}; {cookie_settings}; Max-Age={expires_in}", + f"idToken={id_token}; {cookie_settings}; Max-Age={expires_in}", + f"refreshToken={refresh_token}; {cookie_settings}; Max-Age=2592000" + ] + + # Combine all cookies + all_cookies = cognito_cookies + cf_cookies + + # Return the minimal necessary info in the response body plus cookies + return { + 'statusCode': 200, + 'multiValueHeaders': { + 'Set-Cookie': all_cookies, + 'Access-Control-Allow-Origin': [ALLOWED_ORIGIN], + 'Access-Control-Allow-Credentials': ['true'], + 'Content-Type': ['application/json'] + }, + 'body': json.dumps({ + 'message': 'Login successful', + 'isAuthenticated': True, + 'accessToken': access_token, + 'idToken': id_token, + 'cloudfront_cookies_set': len(cf_cookies) > 0, + 'user': { + 'email': user_email, + 'fullName': user_full_name + } + }) + } + + except ClientError as e: + error_code = e.response.get('Error', {}).get('Code', 'UnknownError') + error_message = e.response.get('Error', {}).get('Message', str(e)) + + logger.error("Cognito authentication error", extra={ + "error_code": error_code, + "error_message": error_message + }) + + headers = { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Credentials': 'true', + 'Content-Type': 'application/json' + } + + if error_code == 'NotAuthorizedException': + return { + 'statusCode': 401, + 'headers': headers, + 'body': json.dumps({'message': 'Incorrect username or password'}) + } + elif error_code == 'UserNotFoundException': + return { + 'statusCode': 404, + 'headers': headers, + 'body': json.dumps({'message': 'User does not exist'}) + } + + return { + 'statusCode': 500, + 'headers': headers, + 'body': json.dumps({'message': f'Login error: {error_message}'}) + } + + except Exception as e: + logger.exception("Unexpected error during login", extra={ + "error": str(e) + }) + + return { + 'statusCode': 500, + 'headers': { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Credentials': 'true', + 'Content-Type': 'application/json' + }, + 'body': json.dumps({'message': 'An unexpected error occurred'}) + } + + +def decode_id_token(id_token): + """ + Decode the ID token to extract user information. + Note: This is a basic decode without signature verification for simplicity. + In production, you should verify the signature. + """ + try: + # Split the token into header, payload, and signature + parts = id_token.split('.') + if len(parts) != 3: + return {} + + # Decode the payload (second part) + payload = parts[1] + # Add padding if needed + payload += '=' * (4 - len(payload) % 4) + + # Decode base64 + decoded_bytes = base64.urlsafe_b64decode(payload) + decoded_str = decoded_bytes.decode('utf-8') + + # Parse JSON + claims = json.loads(decoded_str) + + return claims + + except Exception as e: + logger.warning("Failed to decode ID token", extra={ + "error": str(e) + }) + return {} \ No newline at end of file diff --git a/cloudfront-s3-signed-cookies-cognito/lambda/Register/lambda_handler.py b/cloudfront-s3-signed-cookies-cognito/lambda/Register/lambda_handler.py new file mode 100644 index 000000000..0313e2582 --- /dev/null +++ b/cloudfront-s3-signed-cookies-cognito/lambda/Register/lambda_handler.py @@ -0,0 +1,150 @@ +import json +import os +from typing import Any, Dict + +import boto3 +from botocore.exceptions import ClientError +from aws_lambda_powertools import Logger + +logger = Logger() + +# Initialize Cognito client +cognito_client = boto3.client('cognito-idp') + +# Environment variables +USER_POOL_ID = os.environ['USER_POOL_ID'] +USER_POOL_CLIENT_ID = os.environ['USER_POOL_CLIENT_ID'] +ALLOWED_ORIGIN = os.environ.get('ALLOWED_ORIGIN', '*') + + +def get_cors_headers() -> Dict[str, str]: + """Return CORS headers based on configuration.""" + return { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Credentials': 'true', + 'Content-Type': 'application/json' + } + + +@logger.inject_lambda_context(log_event=True) +def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """Lambda handler for user registration.""" + # Extract request information for logging + request_id = context.aws_request_id + logger.append_keys(request_id=request_id) + + # Handle preflight OPTIONS requests for CORS + if event.get('httpMethod') == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Headers': 'Content-Type,Authorization', + 'Access-Control-Allow-Methods': 'OPTIONS,POST', + 'Access-Control-Allow-Credentials': 'true' + }, + 'body': '' + } + + event_body = json.loads(event.get('body')) if 'body' in event else event + + email = event_body.get('email') + password = event_body.get('password') + name = event_body.get('name', '') + + logger.info("Processing registration", extra={ + "email": email, + "has_name": bool(name) + }) + + # Validate input + if not email or not password: + logger.warning("Registration validation failed", extra={ + "reason": "missing_required_fields", + "has_email": bool(email), + "has_password": bool(event_body.get('password')) + }) + + return { + 'statusCode': 400, + 'headers': get_cors_headers(), + 'body': json.dumps({'message': 'Email and password are required'}) + } + + return register_user(email, password, name) + + +def register_user(email: str, password: str, name: str) -> Dict[str, Any]: + """Register a new user in Cognito.""" + headers = get_cors_headers() + + try: + # Register user in Cognito + response = cognito_client.sign_up( + ClientId=USER_POOL_CLIENT_ID, + Username=email, + Password=password, + UserAttributes=[ + { + 'Name': 'email', + 'Value': email + }, + { + 'Name': 'name', + 'Value': name + } + ] + ) + + # Auto confirm the user (for development purposes) + # In production, you might want to use email verification + cognito_client.admin_confirm_sign_up( + UserPoolId=USER_POOL_ID, + Username=email + ) + + logger.info("User sign-up successful", extra={ + "user_sub": response['UserSub'] + }) + + return { + 'statusCode': 200, + 'headers': headers, + 'body': json.dumps({ + 'message': 'User registered successfully', + 'userSub': response['UserSub'] + }) + } + + except ClientError as e: + error_code = e.response['Error']['Code'] + error_message = e.response['Error']['Message'] + + logger.error("Cognito client error", extra={ + "error_code": error_code, + "error_message": error_message, + "operation": "sign_up/admin_confirm_sign_up" + }) + + if error_code == 'UsernameExistsException': + return { + 'statusCode': 409, + 'headers': headers, + 'body': json.dumps({'message': 'User already exists'}) + } + + return { + 'statusCode': 500, + 'headers': headers, + 'body': json.dumps({'message': f'Registration error: {error_message}'}) + } + + except Exception as e: + logger.exception("Unexpected error during registration", extra={ + "error": str(e) + }) + return { + 'statusCode': 500, + 'headers': headers, + 'body': json.dumps({'message': 'An unexpected error occurred'}) + } \ No newline at end of file diff --git a/cloudfront-s3-signed-cookies-cognito/requirements.txt b/cloudfront-s3-signed-cookies-cognito/requirements.txt new file mode 100644 index 000000000..76dfaaea5 --- /dev/null +++ b/cloudfront-s3-signed-cookies-cognito/requirements.txt @@ -0,0 +1,3 @@ +# AWS CDK and related dependencies +aws-cdk-lib==2.234.1 +constructs==10.4.4 \ No newline at end of file From b42aea997c8d91e2ed0ab8c588fd2d9d23b50812 Mon Sep 17 00:00:00 2001 From: mate329 Date: Fri, 16 Jan 2026 09:29:04 +0100 Subject: [PATCH 2/2] readme update --- cloudfront-s3-signed-cookies-cognito/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cloudfront-s3-signed-cookies-cognito/README.md b/cloudfront-s3-signed-cookies-cognito/README.md index cd080ca6a..a29562bab 100644 --- a/cloudfront-s3-signed-cookies-cognito/README.md +++ b/cloudfront-s3-signed-cookies-cognito/README.md @@ -18,15 +18,15 @@ Important: this application uses various AWS services and there are costs associ ``` git clone https://github.com/aws-samples/serverless-patterns ``` -1. Change directory to the pattern directory: +2. Change directory to the pattern directory: ``` cd cloudfront-s3-signed-cookies-cognito ``` -1. Create a virtual environment for Python: +3. Create a virtual environment for Python: ```bash python3 -m venv .venv ``` -1. Activate the virtual environment: +4. Activate the virtual environment: ```bash source .venv/bin/activate ``` @@ -34,19 +34,19 @@ Important: this application uses various AWS services and there are costs associ ```bash .venv\Scripts\activate.bat ``` -1. Install the required dependencies: +5. Install the required dependencies: ```bash pip install -r requirements.txt ``` -1. Bootstrap your AWS account for CDK (if you haven't done so already): +6. Bootstrap your AWS account for CDK (if you haven't done so already): ```bash cdk bootstrap ``` -1. Deploy the stack: +7. Deploy the stack: ```bash cdk deploy ``` -1. Note the outputs from the CDK deployment. These contain the API endpoints, CloudFront distribution URL, and other important resource information. +Note the outputs from the CDK deployment. These contain the API endpoints, CloudFront distribution URL, and other important resource information. ## How it works