Skip to content

Authentication & Authorization

The order API is protected with Amazon Cognito access tokens. Each Lambda function verifies the token itself with the Powertools for AWS Lambda JWT verifier (aws_lambda_powertools.utilities.auth_alpha.JWTVerifier), there is no API Gateway authorizer.

Every signed in user can create, get and list their own orders. Deleting an order also requires the orders/manage scope, which only members of the orders-managers group get. On top of that, users only ever reach their own orders, see Order ownership.

Alpha utility

The auth_alpha utilities may change before they are generally available. Pin the Powertools version and install the extra: aws-lambda-powertools[jwt].

How it works

  1. A caller signs in to the Cognito user pool and gets an access token.
  2. The caller sends the token in the Authorization: Bearer <token> header.
  3. The route middleware created by verifier.require() verifies the signature (with the user pool JWKS), the issuer, the expiration, the app client and the audience. On the delete route, verifier.require(scopes=['orders/manage']) also checks the scope.
  4. The response is 401 for a missing or invalid token (an ID token is rejected too) and 403 on delete without the orders/manage scope. The /swagger endpoints stay public.
  5. The handler reads the caller's user id from the verified token and only reaches that user's orders, see Order ownership.
Route Who can call it Middleware
POST /api/orders, GET /api/orders, GET /api/orders/{order_id} Any signed in user (valid access token), on their own orders REQUIRE_SIGNED_IN
DELETE /api/orders/{order_id} Members of orders-managers (orders/manage scope), on their own orders REQUIRE_MANAGE

Verifier and routes

service/handlers/utils/auth.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from collections.abc import Callable
from typing import Any

from aws_lambda_env_modeler import get_environment_variables
from aws_lambda_powertools.event_handler.openapi.types import OpenAPIResponse
from aws_lambda_powertools.utilities.auth_alpha import JWTVerifier

from service.handlers.models.env_vars import Authentication
from service.handlers.utils.rest_api_resolver import BEARER_SECURITY_SCHEME_NAME
from service.logic.token_scopes import MANAGE_SCOPE
from service.models.output import ForbiddenOutput, UnauthorizedOutput

_env_vars: Authentication = get_environment_variables(model=Authentication)

# Created once per execution environment, so the cached JWKS is reused across warm invocations.
# The pre token generation trigger adds the app client id as the 'aud' claim (see cdk/service/auth_construct.py),
# which is what the Cognito profile of the verifier validates as the resource audience.
# When COGNITO_JWKS is set (functions without internet access), the verifier uses those keys and never fetches the JWKS URL.
verifier: JWTVerifier = JWTVerifier.cognito(
    user_pool_id=_env_vars.COGNITO_USER_POOL_ID,
    client_id=_env_vars.COGNITO_CLIENT_ID,
    audience=_env_vars.COGNITO_CLIENT_ID,
    jwks=_env_vars.COGNITO_JWKS,
)

# Route middlewares, pass as `middlewares=` to a route. Every user only reaches their own orders (see caller.py).
# REQUIRE_SIGNED_IN: any valid access token of the user pool, 401 otherwise (create, get and list your own orders).
# REQUIRE_MANAGE: also the orders/manage scope, 403 without it (delete your own orders).
REQUIRE_SIGNED_IN: list[Callable[..., Any]] = [verifier.require()]
REQUIRE_MANAGE: list[Callable[..., Any]] = [verifier.require(scopes=[MANAGE_SCOPE])]


# OpenAPI: the security requirement and the auth failure responses to document on a protected route.
BEARER_SECURITY: list[dict[str, list[str]]] = [{BEARER_SECURITY_SCHEME_NAME: []}]
SIGNED_IN_AUTH_RESPONSES: dict[int, OpenAPIResponse] = {
    401: {
        'description': 'Missing or invalid access token',
        'content': {'application/json': {'model': UnauthorizedOutput}},
    },
}
MANAGE_AUTH_RESPONSES: dict[int, OpenAPIResponse] = {
    **SIGNED_IN_AUTH_RESPONSES,
    403: {
        'description': f'The access token does not have the {MANAGE_SCOPE} scope',
        'content': {'application/json': {'model': ForbiddenOutput}},
    },
}

The verifier is created once per execution environment. It caches the user pool JWKS for 5 minutes (the Powertools default) and then fetches it again, so warm invocations don't download the keys on every request. A route uses the verifier with middlewares=REQUIRE_SIGNED_IN (create, get, list) or middlewares=REQUIRE_MANAGE (delete).

The LIST function is the exception: it runs in the Lambda Managed Instances VPC, which has no route to the public JWKS URL. A CloudFormation custom resource fetches the JWKS at deploy time and passes it to the function in the COGNITO_JWKS environment variable, and the verifier uses those keys instead of downloading them. A new user pool triggers the custom resource again, which fetches the new keys.

Signing key rotation

Cognito might rotate the signing keys of a user pool and does not publish a schedule. After a rotation the LIST function only has the keys fetched at deploy time, so it rejects new tokens with 401 until the next deployment fetches the new keys. A production service should refresh the keys periodically, for example with a scheduled function that stores the JWKS in SSM Parameter Store, read by the LIST function through a VPC endpoint.

Validation runs before authorization

The Event Handler validates the request before it runs route middleware. A caller without a token that sends an invalid request gets a 422 before it gets a 401. A valid request gets the 401.

Order ownership

Every order belongs to the user that created it, and users can only get, delete and list their own orders.

service/handlers/utils/caller.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from service.handlers.utils.rest_api_resolver import app
from service.models.exceptions import InternalServerException


def get_caller_id() -> str:
    """The verified caller's user id: the access token 'sub' claim, which identifies the owner of an order.

    Call it only from a route protected by REQUIRE_SIGNED_IN or REQUIRE_MANAGE (see auth.py), their middleware adds the verified claims to the
    resolver context. 'sub' is immutable and unique per user pool user, unlike the username which can be reused after a user is deleted.
    Kept out of auth.py, which builds the verifier from environment variables at import time.
    """
    caller_id = (app.context.get('claims') or {}).get('sub')
    if not isinstance(caller_id, str) or not caller_id:
        raise InternalServerException('verified access token claims with a sub claim are missing')
    return caller_id
  • The owner is the access token sub claim, read with get_caller_id() from the claims the auth middleware adds to the resolver context. sub is immutable and unique, unlike the username, which can be reused after a user is deleted.
  • The orders table is keyed by owner_id (partition key) and id (sort key). Get and delete read the item by both keys, so an order of another user returns 404, exactly like an order that does not exist. This does not reveal which order ids exist.
  • List runs a strongly consistent Query on the caller's partition instead of a Scan of the whole table. A next_token of another user, or a forged one, returns 400.
  • The owner is part of the create order idempotency key, so the same request body sent by two users creates two orders.

Ownership comes first

The orders/manage scope does not grant access to other users' orders: a manager deletes only their own orders, and gets 404 for an order of another user. To let a support or admin role read or delete across owners, add a separate scope and an explicit code path for it.

The audience claim

JWTVerifier.cognito() requires the aud claim of the access token to match the resource audience. Cognito only adds a resource bound aud to access tokens in Managed Login flows, not to tokens issued by the Cognito API (AdminInitiateAuth), and those tokens only have the aws.cognito.signin.user.admin scope.

To get tokens without a browser, the user pool has a pre token generation trigger (event version 2, which requires the Essentials feature plan) that adds to the access token:

  • aud, set to the app client id.
  • The scopes of the user's groups: orders-managers gets orders/manage. A user in no group gets no scope, and can still create, get and list their own orders.
service/handlers/handle_pre_token_generation.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from typing import Any

from service.logic.token_scopes import access_token_customization


def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
    """Cognito pre token generation (V2) trigger, see access_token_customization.

    This function is deployed without the common layer, so it must not import third party packages.
    """
    client_id: str = event['callerContext']['clientId']
    groups: list[str] = event.get('request', {}).get('groupConfiguration', {}).get('groupsToOverride') or []
    event['response'] = access_token_customization(client_id, groups)
    return event

The audience is therefore the app client id, which is weaker than a resource bound audience: every API that trusts this app client accepts the same token, so a token issued for one of them can be replayed against another. If you use Managed Login, request resource binding and pass your resource URL as the audience.

Testing with real tokens

The integration and e2e tests do not mock the verifier. They use real tokens from the deployed user pool.

  • The helpers in tests/auth_utils.py create a user with the admin API, add it to a group, sign it in to get a real access token and delete it.
  • The fixtures in tests/conftest.py create three users once per test session: member_user (no group, the regular user), manager_user (in orders-managers) and other_manager_user (to test order ownership). They are deleted when the session ends, also when a test fails.
  • The bearer token is passed to the tests as ready to use Authorization headers: member_headers, manager_headers and other_manager_headers.
  • order_factory(owner, name, item_count) creates orders owned by a test user (owner.sub) directly in DynamoDB.

The identity that runs the tests needs these permissions on the user pool: cognito-idp:AdminCreateUser, AdminSetUserPassword, AdminAddUserToGroup, AdminInitiateAuth and AdminDeleteUser.

More Details