From 82607d3b2af0ecdc3b52b290b8e79a6682046ea2 Mon Sep 17 00:00:00 2001 From: Emanuel Arguinarena Date: Thu, 24 Sep 2026 11:31:22 -0300 Subject: [PATCH 1/2] test: define Cognito authorizer example --- .../tests/test_handlers.py | 167 +++++++++++++++ .../tests/test_stack.py | 201 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 rest-api-cognito-authorizer/tests/test_handlers.py create mode 100644 rest-api-cognito-authorizer/tests/test_stack.py diff --git a/rest-api-cognito-authorizer/tests/test_handlers.py b/rest-api-cognito-authorizer/tests/test_handlers.py new file mode 100644 index 0000000..4c933cf --- /dev/null +++ b/rest-api-cognito-authorizer/tests/test_handlers.py @@ -0,0 +1,167 @@ +import ast +import importlib +import json +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).parents[1] +LAMBDA_SOURCE = ROOT / "lambdas" / "auth.py" + + +def parse_source(): + return ast.parse(LAMBDA_SOURCE.read_text(), filename=str(LAMBDA_SOURCE)) + + +def function_nodes(): + return { + node.name: node + for node in parse_source().body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def decorator_name(node): + target = node.func if isinstance(node, ast.Call) else node + return target.id if isinstance(target, ast.Name) else None + + +def route_decorators(function): + return [ + decorator + for decorator in function.decorator_list + if isinstance(decorator, ast.Call) + and decorator_name(decorator) in {"GET", "POST", "PUT", "DELETE", "ANY"} + ] + + +def handler_module(monkeypatch): + monkeypatch.syspath_prepend(str(ROOT)) + sys.modules.pop("lambdas.auth", None) + return importlib.import_module("lambdas.auth") + + +def invoke(module, name, event): + result = getattr(module, name)(event, None) + assert set(result) == {"statusCode", "headers", "body"} + assert result["headers"] == {"Content-Type": "application/json"} + assert isinstance(result["statusCode"], int) + assert isinstance(result["body"], str) + return result + + +def payload(result): + return json.loads(result["body"]) + + +def test_auth_module_contains_exactly_health_and_me_handlers(): + assert LAMBDA_SOURCE.is_file() + assert set(function_nodes()) == {"health", "me"} + + +def test_handlers_have_one_route_and_expected_entrypoints(): + functions = function_nodes() + expected = {"health": ("GET", "/health"), "me": ("GET", "/me")} + for name, (method, path) in expected.items(): + routes = route_decorators(functions[name]) + assert len(routes) == 1 + assert decorator_name(routes[0]) == method + assert len(routes[0].args) == 1 + assert routes[0].args[0].value == path + + +def test_health_is_explicitly_public_and_me_inherits_without_authorizer_override(): + functions = function_nodes() + health_decorators = {decorator_name(node) for node in functions["health"].decorator_list} + me_decorators = {decorator_name(node) for node in functions["me"].decorator_list} + assert "public" in health_decorators + assert "authorizer" not in me_decorators + + +def test_health_returns_a_valid_public_json_proxy_response(monkeypatch): + module = handler_module(monkeypatch) + result = invoke(module, "health", {}) + assert result["statusCode"] == 200 + assert payload(result) == {"status": "ok"} + + +def test_health_does_not_require_request_context(monkeypatch): + module = handler_module(monkeypatch) + assert payload(invoke(module, "health", {"resource": "/health"})) == {"status": "ok"} + + +def test_me_returns_only_required_identifiers_from_rest_claims(monkeypatch): + module = handler_module(monkeypatch) + event = { + "requestContext": { + "authorizer": { + "claims": { + "sub": "user-id", + "cognito:username": "alice", + "email": "alice@example.com", + "scope": "openid", + } + } + } + } + result = invoke(module, "me", event) + assert result["statusCode"] == 200 + assert payload(result) == {"sub": "user-id", "username": "alice"} + + +def test_me_uses_cognito_username_as_username(monkeypatch): + module = handler_module(monkeypatch) + event = {"requestContext": {"authorizer": {"claims": {"sub": "s", "cognito:username": "alice"}}}} + assert payload(invoke(module, "me", event))["username"] == "alice" + + +def test_me_accepts_missing_cognito_username_when_sub_exists(monkeypatch): + module = handler_module(monkeypatch) + event = {"requestContext": {"authorizer": {"claims": {"sub": "user-id"}}}} + result = invoke(module, "me", event) + assert result["statusCode"] == 200 + assert payload(result) == {"sub": "user-id"} + + +def test_me_returns_defensive_401_when_claims_are_missing(monkeypatch): + module = handler_module(monkeypatch) + result = invoke(module, "me", {}) + assert result["statusCode"] == 401 + assert payload(result) == {"message": "Unauthorized"} + + +def test_me_does_not_return_tokens_authorization_header_or_all_claims(monkeypatch): + module = handler_module(monkeypatch) + claims = { + "sub": "s", + "cognito:username": "alice", + "access_token": "access-secret", + "id_token": "id-secret", + "refresh_token": "refresh-secret", + "Authorization": "Bearer secret", + "email": "alice@example.com", + } + result = invoke(module, "me", {"requestContext": {"authorizer": {"claims": claims}}}) + body = result["body"] + assert result["statusCode"] == 200 + assert payload(result) == {"sub": "s", "username": "alice"} + assert all(secret not in body for secret in ("access-secret", "id-secret", "refresh-secret", "Bearer secret")) + + +def test_handlers_do_not_import_or_call_aws_services(): + tree = parse_source() + forbidden = {"boto3", "botocore", "aws_sdk"} + assert not any( + isinstance(node, ast.Import) and any(alias.name.split(".")[0] in forbidden for alias in node.names) + or isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] in forbidden + for node in tree.body + ) + assert not any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in {"client", "resource", "get_user", "admin_get_user"} + for node in ast.walk(tree) + ) + diff --git a/rest-api-cognito-authorizer/tests/test_stack.py b/rest-api-cognito-authorizer/tests/test_stack.py new file mode 100644 index 0000000..1dde315 --- /dev/null +++ b/rest-api-cognito-authorizer/tests/test_stack.py @@ -0,0 +1,201 @@ +import ast +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).parents[1] +STACK_SOURCE = ROOT / "rest_api_cognito_authorizer" / "rest_api_cognito_authorizer_stack.py" +DOCKER_AVAILABLE = bool(shutil.which("docker")) and subprocess.run( + ["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False +).returncode == 0 + + +def source_text(): + assert STACK_SOURCE.is_file() + return STACK_SOURCE.read_text() + + +def stack_template(monkeypatch): + if not DOCKER_AVAILABLE: + pytest.skip("Docker is unavailable for CDK PythonFunction bundling") + os.environ.setdefault("JSII_RUNTIME_PACKAGE_CACHE", "/tmp/codex-jsii-cache") + monkeypatch.syspath_prepend(str(ROOT)) + monkeypatch.chdir(ROOT) + from aws_cdk import App + from aws_cdk.assertions import Template + from rest_api_cognito_authorizer.rest_api_cognito_authorizer_stack import ( + RestApiCognitoAuthorizerStack, + ) + + stack = RestApiCognitoAuthorizerStack(App(), "TestRestApiCognitoAuthorizerStack") + template = Template.from_stack(stack) + template._test_stack = stack + return template + + +def resources(template, resource_type): + return template.find_resources(resource_type) + + +def refs(value, logical_id): + return logical_id in json.dumps(value) + + +def test_stack_source_uses_published_authorizer_configuration_contract(): + tree = ast.parse(source_text(), filename=str(STACK_SOURCE)) + configs = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "LambdaApiConfig" + ] + assert len(configs) == 1 + keywords = {keyword.arg: keyword.value for keyword in configs[0].keywords} + assert keywords["default_runtime"].value == "python3.14" + assert keywords["default_authorizer"].value == "cognito" + registry = keywords["authorizer_registry"] + assert isinstance(registry, ast.Dict) + assert [key.value for key in registry.keys] == ["cognito"] + assert [value.id for value in registry.values] == ["cognito_authorizer"] + + +def test_stack_creates_one_pool_client_and_cognito_authorizer(monkeypatch): + template = stack_template(monkeypatch) + template.resource_count_is("AWS::Cognito::UserPool", 1) + template.resource_count_is("AWS::Cognito::UserPoolClient", 1) + template.resource_count_is("AWS::ApiGateway::Authorizer", 1) + pools = resources(template, "AWS::Cognito::UserPool") + pool_id = next(iter(pools)) + clients = resources(template, "AWS::Cognito::UserPoolClient") + client = next(iter(clients.values())) + assert client["Properties"]["GenerateSecret"] is False + authorizer = next(iter(resources(template, "AWS::ApiGateway::Authorizer").values())) + assert authorizer["Properties"]["Type"] == "COGNITO_USER_POOLS" + assert refs(authorizer["Properties"]["ProviderARNs"], pool_id) + assert authorizer["Properties"]["IdentitySource"] == "method.request.header.Authorization" + + +def test_pool_is_destroyable_and_client_has_no_secret_or_credentials(monkeypatch): + template = stack_template(monkeypatch) + pool_id = next(iter(resources(template, "AWS::Cognito::UserPool"))) + pool = resources(template, "AWS::Cognito::UserPool")[pool_id] + assert pool["DeletionPolicy"] == "Delete" + if "UpdateReplacePolicy" in pool: + assert pool["UpdateReplacePolicy"] == "Delete" + client = next(iter(resources(template, "AWS::Cognito::UserPoolClient").values())) + assert client["Properties"]["GenerateSecret"] is False + assert resources(template, "AWS::Cognito::UserPoolUser") == {} + assert not any( + key.lower() in {"password", "token", "accesstoken", "idtoken", "refreshtoken"} + for key in client["Properties"] + ) + + +def api_resource_paths(template): + resources_by_id = resources(template, "AWS::ApiGateway::Resource") + paths = {} + unresolved = dict(resources_by_id) + while unresolved: + progressed = False + for logical_id, resource in list(unresolved.items()): + parent = resource["Properties"]["ParentId"] + parent_id = parent.get("Ref") if isinstance(parent, dict) else None + if parent_id and parent_id in paths: + paths[logical_id] = f"{paths[parent_id]}/{resource['Properties']['PathPart']}" + del unresolved[logical_id] + progressed = True + elif isinstance(parent, dict) and "Fn::GetAtt" in parent: + paths[logical_id] = f"/{resource['Properties']['PathPart']}" + del unresolved[logical_id] + progressed = True + if not progressed: + raise AssertionError(f"unresolved API resources: {unresolved}") + return paths + + +def method_by_route(template): + paths = api_resource_paths(template) + methods = resources(template, "AWS::ApiGateway::Method") + return { + (method["Properties"]["HttpMethod"], paths[method["Properties"]["ResourceId"]["Ref"]]): method + for method in methods.values() + } + + +def test_api_has_exactly_two_effective_get_routes(monkeypatch): + template = stack_template(monkeypatch) + template.resource_count_is("AWS::ApiGateway::RestApi", 1) + template.resource_count_is("AWS::ApiGateway::Method", 2) + routes = method_by_route(template) + assert set(routes) == {("GET", "/health"), ("GET", "/me")} + health = routes[("GET", "/health")]["Properties"] + me = routes[("GET", "/me")]["Properties"] + assert health["AuthorizationType"] == "NONE" + assert "AuthorizerId" not in health + assert me["AuthorizationType"] == "COGNITO_USER_POOLS" + assert "AuthorizerId" in me + authorizer_id = next(iter(resources(template, "AWS::ApiGateway::Authorizer"))) + assert refs(me["AuthorizerId"], authorizer_id) + + +def test_api_has_no_api_keys_or_usage_plans(monkeypatch): + template = stack_template(monkeypatch) + assert resources(template, "AWS::ApiGateway::ApiKey") == {} + assert resources(template, "AWS::ApiGateway::UsagePlan") == {} + assert resources(template, "AWS::ApiGateway::UsagePlanKey") == {} + + +def test_stack_has_two_independent_python_314_lambda_entrypoints(monkeypatch): + template = stack_template(monkeypatch) + template.resource_count_is("AWS::Lambda::Function", 2) + functions = list(resources(template, "AWS::Lambda::Function").values()) + assert {function["Properties"]["Runtime"] for function in functions} == {"python3.14"} + assert {function["Properties"]["Handler"] for function in functions} == { + "auth.health", + "auth.me", + } + + +def test_stack_has_only_expected_outputs_without_credentials(monkeypatch): + template = stack_template(monkeypatch) + outputs = template.to_json().get("Outputs", {}) + assert {name.lower() for name in outputs} >= {"apiurl", "userpoolid", "userpoolclientid"} + assert not any( + any(word in name.lower() or word in json.dumps(value).lower() for word in ("password", "secret", "token", "credential")) + for name, value in outputs.items() + ) + + +def test_registry_preserves_cognito_object_and_documents_only_health_override(monkeypatch): + text = source_text() + assert "authorizer_registry" in text + assert '"cognito"' in text or "'cognito'" in text + assert "default_authorizer=\"cognito\"" in text or "default_authorizer='cognito'" in text + auth_source = (ROOT / "lambdas" / "auth.py").read_text() + assert "@public" in auth_source + assert "@authorizer(" not in auth_source + + +def test_diagnostics_have_no_public_default_and_one_health_override(monkeypatch): + if not DOCKER_AVAILABLE: + pytest.skip("Docker is unavailable for CDK PythonFunction bundling") + from aws_cdk.assertions import Annotations, Match + + template = stack_template(monkeypatch) + annotations = Annotations.from_stack(template._test_stack) + public_default = annotations.find_warning("*", Match.string_like_regexp("LAD_AUTH_PUBLIC_DEFAULT")) + assert not public_default + overrides = annotations.find_info("*", Match.string_like_regexp("Authorization overrides")) + assert len(overrides) <= 1 + if overrides: + message = overrides[0].entry.data + assert all(part in message for part in ("GET", "/health", "PUBLIC", "cognito")) + assert "/me" not in message + assert not any(secret in message.lower() for secret in ("arn:", "token", "physical", "id:")) From 054f9327a1b161a509b4ede76fbdce7657d06bcc Mon Sep 17 00:00:00 2001 From: Emanuel Arguinarena Date: Thu, 24 Sep 2026 13:20:01 -0300 Subject: [PATCH 2/2] feat: add Cognito authorizer example --- .../workflows/rest-api-cognito-authorizer.yml | 39 ++ rest-api-cognito-authorizer/README.md | 364 ++++++++++++++++++ rest-api-cognito-authorizer/app.py | 11 + rest-api-cognito-authorizer/cdk.json | 3 + .../lambdas/__init__.py | 19 + rest-api-cognito-authorizer/lambdas/auth.py | 40 ++ .../lambdas/requirements.txt | 1 + .../requirements-dev.txt | 4 + rest-api-cognito-authorizer/requirements.txt | 3 + .../rest_api_cognito_authorizer/__init__.py | 0 .../rest_api_cognito_authorizer_stack.py | 55 +++ 11 files changed, 539 insertions(+) create mode 100644 .github/workflows/rest-api-cognito-authorizer.yml create mode 100644 rest-api-cognito-authorizer/README.md create mode 100644 rest-api-cognito-authorizer/app.py create mode 100644 rest-api-cognito-authorizer/cdk.json create mode 100644 rest-api-cognito-authorizer/lambdas/__init__.py create mode 100644 rest-api-cognito-authorizer/lambdas/auth.py create mode 100644 rest-api-cognito-authorizer/lambdas/requirements.txt create mode 100644 rest-api-cognito-authorizer/requirements-dev.txt create mode 100644 rest-api-cognito-authorizer/requirements.txt create mode 100644 rest-api-cognito-authorizer/rest_api_cognito_authorizer/__init__.py create mode 100644 rest-api-cognito-authorizer/rest_api_cognito_authorizer/rest_api_cognito_authorizer_stack.py diff --git a/.github/workflows/rest-api-cognito-authorizer.yml b/.github/workflows/rest-api-cognito-authorizer.yml new file mode 100644 index 0000000..1f563f0 --- /dev/null +++ b/.github/workflows/rest-api-cognito-authorizer.yml @@ -0,0 +1,39 @@ +name: rest-api-cognito-authorizer + +on: + push: + paths: + - "rest-api-cognito-authorizer/**" + - ".github/workflows/rest-api-cognito-authorizer.yml" + pull_request: + paths: + - "rest-api-cognito-authorizer/**" + - ".github/workflows/rest-api-cognito-authorizer.yml" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install AWS CDK CLI + run: npm install --global aws-cdk + - name: Verify Docker + run: docker info + - name: Install dependencies + working-directory: rest-api-cognito-authorizer + run: python -m pip install --upgrade pip && python -m pip install -r requirements-dev.txt + - name: Verify published CDK integration version + working-directory: rest-api-cognito-authorizer + run: python -c "from importlib.metadata import version; assert version('lambda-api-decorators-cdk') == '0.4.5'" + - name: Run tests + working-directory: rest-api-cognito-authorizer + run: pytest -q tests -rs + - name: Synthesize CloudFormation + working-directory: rest-api-cognito-authorizer + run: cdk synth --quiet diff --git a/rest-api-cognito-authorizer/README.md b/rest-api-cognito-authorizer/README.md new file mode 100644 index 0000000..96e8715 --- /dev/null +++ b/rest-api-cognito-authorizer/README.md @@ -0,0 +1,364 @@ +# REST API with a Cognito authorizer + +This independent AWS CDK application demonstrates a REST API with Cognito as +the default authorizer. `/health` is public by explicit `@public` override; +`/me` inherits Cognito from `LambdaApiConfig`. Both handlers share +`lambdas/auth.py`, while the CDK integration creates two independent Lambda +functions. + +## Architecture + +```text +Client + ├─ GET /health → explicit @public override → health Lambda + └─ GET /me → Cognito authorizer → me Lambda +``` + +## Project structure + +```text +rest-api-cognito-authorizer/ +├── app.py +├── cdk.json +├── requirements.txt +├── requirements-dev.txt +├── README.md +├── lambdas/ +│ ├── __init__.py +│ ├── auth.py +│ └── requirements.txt +├── rest_api_cognito_authorizer/ +│ ├── __init__.py +│ └── rest_api_cognito_authorizer_stack.py +└── tests/ + ├── test_handlers.py + └── test_stack.py +``` + +## Routes + +| Function | Entrypoint | Method | Path | Effective authorization | +| --- | --- | --- | --- | --- | +| `health` | `auth.health` | GET | `/health` | Public through `@public` | +| `me` | `auth.me` | GET | `/me` | Cognito inherited from `default_authorizer` | + +## Stack + +The stack creates one User Pool, one secret-free client, one Cognito REST +authorizer, one REST API, and two Lambda functions: + +The CDK integration dependency is pinned to `lambda-api-decorators-cdk==0.4.5`. + +```python +from aws_cdk import CfnOutput, RemovalPolicy, Stack +from aws_cdk import aws_apigateway as apigateway +from aws_cdk import aws_cognito as cognito +from constructs import Construct +from lambda_api_decorators_cdk import LambdaApi, LambdaApiConfig + + +class RestApiCognitoAuthorizerStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + user_pool = cognito.UserPool( + self, + "UserPool", + sign_in_aliases=cognito.SignInAliases(username=True), + self_sign_up_enabled=False, + password_policy=cognito.PasswordPolicy( + min_length=8, + require_lowercase=True, + require_uppercase=True, + require_digits=True, + require_symbols=False, + ), + removal_policy=RemovalPolicy.DESTROY, + ) + user_pool_client = user_pool.add_client( + "UserPoolClient", + auth_flows=cognito.AuthFlow(user_password=True), + generate_secret=False, + ) + rest_api = apigateway.RestApi(self, "RestApi") + cognito_authorizer = apigateway.CognitoUserPoolsAuthorizer( + self, + "CognitoAuthorizer", + cognito_user_pools=[user_pool], + ) + + config = LambdaApiConfig( + default_runtime="python3.14", + authorizer_registry={ + "cognito": cognito_authorizer, + }, + default_authorizer="cognito", + ) + api = LambdaApi( + self, + "Api", + lambda_path="lambdas", + api=rest_api, + config=config, + ) + + CfnOutput(self, "ApiUrl", value=api.api.url) + CfnOutput(self, "UserPoolId", value=user_pool.user_pool_id) + CfnOutput(self, "UserPoolClientId", value=user_pool_client.user_pool_client_id) +``` + +`self_sign_up_enabled=False` leaves user creation administrative. No user, +password, token, or client secret is placed in CloudFormation. The pool uses +`RemovalPolicy.DESTROY`, and the client enables `USER_PASSWORD_AUTH` with +`GenerateSecret: false`. + +## Handlers + +The complete `lambdas/auth.py` is: + +```python +import json + +from lambda_api_decorators import GET, public + + +JSON_HEADERS = {"Content-Type": "application/json"} + + +@GET("/health") +@public +def health(event, context): + return { + "statusCode": 200, + "headers": JSON_HEADERS, + "body": json.dumps({"status": "ok"}), + } + + +@GET("/me") +def me(event, context): + claims = ( + event.get("requestContext", {}) + .get("authorizer", {}) + .get("claims") + ) + if not isinstance(claims, dict) or not claims.get("sub"): + return { + "statusCode": 401, + "headers": JSON_HEADERS, + "body": json.dumps({"message": "Unauthorized"}), + } + + body = {"sub": claims["sub"]} + if claims.get("cognito:username"): + body["username"] = claims["cognito:username"] + return { + "statusCode": 200, + "headers": JSON_HEADERS, + "body": json.dumps(body), + } +``` + +## Security model + +`authorizer_registry` maps the logical key `cognito` to the real CDK +`CognitoUserPoolsAuthorizer`. `default_authorizer="cognito"` makes an +undecorated route inherit that authorizer, so `/me` does not repeat +`@authorizer("cognito")`. `@public` is an explicit per-route override that +makes only `/health` use `AuthorizationType: NONE`. + +API Gateway validates `/me` before invoking the Lambda. The handler reads REST +API claims from `event["requestContext"]["authorizer"]["claims"]` and returns +only `sub` and the optional `cognito:username`. Its defensive `401` is for +direct invocation or malformed events. No handler calls AWS services. + +## Tests + +The handler tests cover proxy shape, status codes, JSON bodies, REST claims, +missing claims, sensitive-data exclusion, exact decorators, one route per +function, and absence of AWS calls. The stack tests cover Cognito resources, +deletion policies, secret-free client configuration, REST authorization for +both paths, two Python 3.14 entrypoints, outputs, registry inheritance, and +consolidated diagnostics. + +## Prerequisites + +- Python 3.14 +- Node.js 22 +- AWS CDK CLI +- Docker running and reachable by the current user +- AWS CLI, credentials, and a configured Region + +Docker is required for CDK's Lambda bundling. AWS credentials are required for +bootstrap, deploy, Cognito CLI commands, and destroy, but not for tests or +synthesis. + +## Installation + +### Linux/macOS + +```bash +cd rest-api-cognito-authorizer +python3.14 -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements-dev.txt +``` + +### PowerShell + +```powershell +Set-Location rest-api-cognito-authorizer +py -3.14 -m venv .venv +.venv\Scripts\Activate.ps1 +python -m pip install -r requirements-dev.txt +``` + +### CMD + +```bat +cd rest-api-cognito-authorizer +py -3.14 -m venv .venv +.venv\Scripts\activate.bat +python -m pip install -r requirements-dev.txt +``` + +## Validate, bootstrap, and deploy + +From `rest-api-cognito-authorizer`: + +```text +pytest +cdk synth +cdk bootstrap +cdk deploy +``` + +The stack name is `RestApiCognitoAuthorizerStack`. Its outputs are +`ApiUrl`, `UserPoolId`, and `UserPoolClientId`. + +## Create a user + +The client uses `USER_PASSWORD_AUTH`. The following commands create an +administrative user without sending an invitation, then make its password +permanent. Do not commit passwords or tokens. + +### Linux/macOS + +```bash +STACK_NAME=RestApiCognitoAuthorizerStack +USER_POOL_ID=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --query 'Stacks[0].Outputs[?OutputKey==`UserPoolId`].OutputValue' --output text) +CLIENT_ID=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --query 'Stacks[0].Outputs[?OutputKey==`UserPoolClientId`].OutputValue' --output text) +API_URL=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' --output text) +read -r USERNAME +read -r -s PASSWORD +printf '\n' +aws cognito-idp admin-create-user --user-pool-id "$USER_POOL_ID" --username "$USERNAME" --temporary-password "$PASSWORD" --message-action SUPPRESS +aws cognito-idp admin-set-user-password --user-pool-id "$USER_POOL_ID" --username "$USERNAME" --password "$PASSWORD" --permanent +export USER_POOL_ID CLIENT_ID API_URL USERNAME PASSWORD +``` + +Read the username and password interactively; do not paste the password into a +tracked file or script. + +### PowerShell + +```powershell +$StackName = "RestApiCognitoAuthorizerStack" +$UserPoolId = aws cloudformation describe-stacks --stack-name $StackName --query 'Stacks[0].Outputs[?OutputKey==`UserPoolId`].OutputValue' --output text +$ClientId = aws cloudformation describe-stacks --stack-name $StackName --query 'Stacks[0].Outputs[?OutputKey==`UserPoolClientId`].OutputValue' --output text +$ApiUrl = aws cloudformation describe-stacks --stack-name $StackName --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' --output text +$Username = Read-Host "Cognito username" +$SecurePassword = Read-Host "Cognito password" -AsSecureString +$Bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecurePassword) +try { $Password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($Bstr) } +finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Bstr) } +aws cognito-idp admin-create-user --user-pool-id $UserPoolId --username $Username --temporary-password $Password --message-action SUPPRESS +aws cognito-idp admin-set-user-password --user-pool-id $UserPoolId --username $Username --password $Password --permanent +``` + +The plain password string exists only temporarily for the AWS CLI process. + +### CMD + +```bat +set STACK_NAME=RestApiCognitoAuthorizerStack +for /f "delims=" %i in ('aws cloudformation describe-stacks --stack-name %STACK_NAME% --query "Stacks[0].Outputs[?OutputKey==`UserPoolId`].OutputValue" --output text') do set USER_POOL_ID=%i +for /f "delims=" %i in ('aws cloudformation describe-stacks --stack-name %STACK_NAME% --query "Stacks[0].Outputs[?OutputKey==`UserPoolClientId`].OutputValue" --output text') do set CLIENT_ID=%i +for /f "delims=" %i in ('aws cloudformation describe-stacks --stack-name %STACK_NAME% --query "Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue" --output text') do set API_URL=%i +set /p USERNAME=Cognito username: +set /p PASSWORD=Cognito password: +aws cognito-idp admin-create-user --user-pool-id %USER_POOL_ID% --username %USERNAME% --temporary-password %PASSWORD% --message-action SUPPRESS +aws cognito-idp admin-set-user-password --user-pool-id %USER_POOL_ID% --username %USERNAME% --password %PASSWORD% --permanent +``` + +`set /p` may echo the password in CMD. Use a terminal with hidden input for +real credentials and clear the variables afterward. + +## Obtain an ID token + +Cognito returns an ID token, access token, and refresh token. This example uses +the ID token in `Authorization`, because it carries the identity claims read by +the REST Cognito authorizer. Keep it in a temporary variable only. + +### Linux/macOS + +```bash +export ID_TOKEN=$(aws cognito-idp initiate-auth --client-id "$CLIENT_ID" --auth-flow USER_PASSWORD_AUTH --auth-parameters USERNAME="$USERNAME",PASSWORD="$PASSWORD" --query 'AuthenticationResult.IdToken' --output text) +``` + +### PowerShell + +```powershell +$Auth = aws cognito-idp initiate-auth --client-id $ClientId --auth-flow USER_PASSWORD_AUTH --auth-parameters USERNAME=$Username,PASSWORD=$Password | ConvertFrom-Json +$IdToken = $Auth.AuthenticationResult.IdToken +``` + +### CMD + +```bat +for /f "delims=" %i in ('aws cognito-idp initiate-auth --client-id %CLIENT_ID% --auth-flow USER_PASSWORD_AUTH --auth-parameters USERNAME=%USERNAME%,PASSWORD=%PASSWORD% --query "AuthenticationResult.IdToken" --output text') do set ID_TOKEN=%i +``` + +## Invoke the endpoints + +`/health` without a token returns `200`. `/me` without a token is rejected by +API Gateway; with the ID token it returns the user's identifiers. + +### Linux/macOS + +```bash +curl --fail "$API_URL/health" +curl -i "$API_URL/me" +curl --fail -H "Authorization: $ID_TOKEN" "$API_URL/me" +``` + +### PowerShell + +```powershell +Invoke-RestMethod "$ApiUrl/health" +Invoke-WebRequest "$ApiUrl/me" -SkipHttpErrorCheck +Invoke-RestMethod "$ApiUrl/me" -Headers @{ Authorization = $IdToken } +``` + +### CMD + +```bat +curl --fail "%API_URL%/health" +curl -i "%API_URL%/me" +curl --fail -H "Authorization: %ID_TOKEN%" "%API_URL%/me" +``` + +The successful protected body is: + +```json +{"sub": "user-id", "username": "alice"} +``` + +## Cleanup + +```text +cdk destroy +``` + +Destroying the stack permanently deletes the Cognito User Pool and all users +stored in it. diff --git a/rest-api-cognito-authorizer/app.py b/rest-api-cognito-authorizer/app.py new file mode 100644 index 0000000..409f04d --- /dev/null +++ b/rest-api-cognito-authorizer/app.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +import aws_cdk as cdk + +from rest_api_cognito_authorizer.rest_api_cognito_authorizer_stack import ( + RestApiCognitoAuthorizerStack, +) + + +app = cdk.App() +RestApiCognitoAuthorizerStack(app, "RestApiCognitoAuthorizerStack") +app.synth() diff --git a/rest-api-cognito-authorizer/cdk.json b/rest-api-cognito-authorizer/cdk.json new file mode 100644 index 0000000..f7134b3 --- /dev/null +++ b/rest-api-cognito-authorizer/cdk.json @@ -0,0 +1,3 @@ +{ + "app": "python app.py" +} diff --git a/rest-api-cognito-authorizer/lambdas/__init__.py b/rest-api-cognito-authorizer/lambdas/__init__.py new file mode 100644 index 0000000..765315b --- /dev/null +++ b/rest-api-cognito-authorizer/lambdas/__init__.py @@ -0,0 +1,19 @@ +import os +import sys + + +class _DynamicPackagePath(list): + def __iter__(self): + seen = set() + for entry in super().__iter__(): + if entry not in seen: + seen.add(entry) + yield entry + for entry in sys.path: + candidate = os.path.join(entry, "lambdas") + if os.path.isdir(candidate) and candidate not in seen: + seen.add(candidate) + yield candidate + + +__path__ = _DynamicPackagePath(__path__) diff --git a/rest-api-cognito-authorizer/lambdas/auth.py b/rest-api-cognito-authorizer/lambdas/auth.py new file mode 100644 index 0000000..c1c07f3 --- /dev/null +++ b/rest-api-cognito-authorizer/lambdas/auth.py @@ -0,0 +1,40 @@ +import json + +from lambda_api_decorators import GET, public + + +JSON_HEADERS = {"Content-Type": "application/json"} + + +@GET("/health") +@public +def health(event, context): + return { + "statusCode": 200, + "headers": JSON_HEADERS, + "body": json.dumps({"status": "ok"}), + } + + +@GET("/me") +def me(event, context): + claims = ( + event.get("requestContext", {}) + .get("authorizer", {}) + .get("claims") + ) + if not isinstance(claims, dict) or not claims.get("sub"): + return { + "statusCode": 401, + "headers": JSON_HEADERS, + "body": json.dumps({"message": "Unauthorized"}), + } + + body = {"sub": claims["sub"]} + if claims.get("cognito:username"): + body["username"] = claims["cognito:username"] + return { + "statusCode": 200, + "headers": JSON_HEADERS, + "body": json.dumps(body), + } diff --git a/rest-api-cognito-authorizer/lambdas/requirements.txt b/rest-api-cognito-authorizer/lambdas/requirements.txt new file mode 100644 index 0000000..9338554 --- /dev/null +++ b/rest-api-cognito-authorizer/lambdas/requirements.txt @@ -0,0 +1 @@ +lambda-api-decorators==0.3.2 diff --git a/rest-api-cognito-authorizer/requirements-dev.txt b/rest-api-cognito-authorizer/requirements-dev.txt new file mode 100644 index 0000000..fe4e169 --- /dev/null +++ b/rest-api-cognito-authorizer/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +-r lambdas/requirements.txt + +pytest diff --git a/rest-api-cognito-authorizer/requirements.txt b/rest-api-cognito-authorizer/requirements.txt new file mode 100644 index 0000000..786f94c --- /dev/null +++ b/rest-api-cognito-authorizer/requirements.txt @@ -0,0 +1,3 @@ +aws-cdk-lib>=2.0.0,<3.0.0 +constructs>=10.0.0,<11.0.0 +lambda-api-decorators-cdk==0.4.5 diff --git a/rest-api-cognito-authorizer/rest_api_cognito_authorizer/__init__.py b/rest-api-cognito-authorizer/rest_api_cognito_authorizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rest-api-cognito-authorizer/rest_api_cognito_authorizer/rest_api_cognito_authorizer_stack.py b/rest-api-cognito-authorizer/rest_api_cognito_authorizer/rest_api_cognito_authorizer_stack.py new file mode 100644 index 0000000..3c9bbf0 --- /dev/null +++ b/rest-api-cognito-authorizer/rest_api_cognito_authorizer/rest_api_cognito_authorizer_stack.py @@ -0,0 +1,55 @@ +from aws_cdk import CfnOutput, RemovalPolicy, Stack +from aws_cdk import aws_apigateway as apigateway +from aws_cdk import aws_cognito as cognito +from constructs import Construct +from lambda_api_decorators_cdk import LambdaApi, LambdaApiConfig + + +class RestApiCognitoAuthorizerStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + user_pool = cognito.UserPool( + self, + "UserPool", + sign_in_aliases=cognito.SignInAliases(username=True), + self_sign_up_enabled=False, + password_policy=cognito.PasswordPolicy( + min_length=8, + require_lowercase=True, + require_uppercase=True, + require_digits=True, + require_symbols=False, + ), + removal_policy=RemovalPolicy.DESTROY, + ) + user_pool_client = user_pool.add_client( + "UserPoolClient", + auth_flows=cognito.AuthFlow(user_password=True), + generate_secret=False, + ) + rest_api = apigateway.RestApi(self, "RestApi") + cognito_authorizer = apigateway.CognitoUserPoolsAuthorizer( + self, + "CognitoAuthorizer", + cognito_user_pools=[user_pool], + ) + + config = LambdaApiConfig( + default_runtime="python3.14", + authorizer_registry={ + "cognito": cognito_authorizer, + }, + default_authorizer="cognito", + ) + api = LambdaApi( + self, + "Api", + lambda_path="lambdas", + api=rest_api, + config=config, + ) + + CfnOutput(self, "ApiUrl", value=api.api.url) + CfnOutput(self, "UserPoolId", value=user_pool.user_pool_id) + CfnOutput(self, "UserPoolClientId", value=user_pool_client.user_pool_client_id)