Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions lambda/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ name = "ffsync-lambda-handler"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"aws-lambda-powertools==3.34.0",
"boto3==1.43.89",
"aws-lambda-powertools==3.35.0",
"boto3==1.43.96",
"cryptography==50.0.1",
"pydantic==2.13.5",
"mohawk==1.1.0",
"PyJWT==2.13.0",
"PyJWT==2.14.0",
"requests==2.34.2"
]


[project.optional-dependencies]
[dependency-groups]
dev = [
"pytest==9.1.1",
"pytest-cov==7.1.0",
Expand All @@ -23,10 +23,10 @@ dev = [
"flake8==7.3.0",
"Flake8-pyproject==1.2.4",
"mypy==2.3.1",
"types-boto3==1.43.89",
"types-boto3[apigatewaymanagementapi,dynamodb,kms]==1.43.96",
"hypothesis>=6.0.0",
"types-requests==2.33.0.20260712",
"datamodel-code-generator==0.76.2",
"types-requests==v2.33.0.20260906",
"datamodel-code-generator==0.82.0",
]

[tool.pytest.ini_options]
Expand Down Expand Up @@ -118,9 +118,12 @@ select = [

[tool.mypy]
check_untyped_defs = true
show_error_codes = true
disallow_untyped_defs = true
pretty = true
ignore_missing_imports = true
# Deliberately NOT ignore_missing_imports: it turns an uninstalled stub package into a silent
# Any, so an annotation like `table: "Table"` type-checks while checking nothing. Scope the
# fallback per-module below instead, so a missing stub surfaces as an error.
warn_unused_ignores = true # keeps the remaining `type: ignore`s from rotting
files = ["src/", "tests/"]
exclude = [
"__pycache__",
Expand All @@ -129,3 +132,12 @@ exclude = [
"htmlcov",
]
plugins = ["pydantic.mypy"]

# mohawk ships no stubs and has no types-* package on PyPI.
[[tool.mypy.overrides]]
module = ["mohawk", "mohawk.*"]
ignore_missing_imports = true

[tool.pydantic-mypy]
init_typed = true # Use field names rather than aliases for constructors

65 changes: 36 additions & 29 deletions lambda/src/environment/service_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
import json
import os
from functools import cached_property
from typing import TYPE_CHECKING, Any, Callable, Optional

import boto3
from aws_lambda_powertools.event_handler import CORSConfig, Response
from aws_lambda_powertools.logging import Logger
from aws_lambda_powertools.metrics import Metrics
from aws_lambda_powertools.utilities.typing import LambdaContext

from src.middlewares.hawk_auth import HawkAuthenticationError, HawkAuthMiddleware, UidMismatchError
from src.middlewares.request_logging import RequestLoggingMiddleware
Expand Down Expand Up @@ -59,6 +61,9 @@
from src.services.token_generator import TokenGenerator
from src.services.user_manager import UserManager

if TYPE_CHECKING:
from types_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table


@functools.lru_cache(maxsize=1)
def create_service_provider() -> "ServiceProvider": # pragma: nocover
Expand All @@ -70,15 +75,17 @@ def create_service_provider() -> "ServiceProvider": # pragma: nocover
return ServiceProvider()


def lambda_entrypoint(fn):
def lambda_entrypoint(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator that injects a cached ServiceProvider when none is provided.

In production, creates/reuses a cached ServiceProvider via lru_cache.
In tests, pass service_provider directly to inject a mock.
"""

@functools.wraps(fn)
def wrapper(event, context, service_provider=None):
def wrapper(
event: dict, context: LambdaContext, service_provider: Optional["ServiceProvider"] = None
) -> Any:
if service_provider is None: # pragma: nocover
service_provider = create_service_provider()
try:
Expand All @@ -102,24 +109,24 @@ def user_agent(self) -> str:
return "layertwo-ffsync/1.0"

@cached_property
def aws_region(self): # pragma: nocover
def aws_region(self) -> Optional[str]: # pragma: nocover
return os.environ.get("AWS_REGION")

@cached_property
def session(self): # pragma: nocover
def session(self) -> boto3.Session: # pragma: nocover
return boto3.Session(region_name=self.aws_region)

@cached_property
def table_name(self):
return os.environ.get("STORAGE_TABLE_NAME")
def table_name(self) -> str:
return os.environ["STORAGE_TABLE_NAME"]

@cached_property
def dynamodb_resource(self): # pragma: nocover
def dynamodb_resource(self) -> "DynamoDBServiceResource": # pragma: nocover
"""Shared DynamoDB resource — reuses a single connection pool."""
return self.session.resource("dynamodb")

@cached_property
def dynamodb_table(self):
def dynamodb_table(self) -> "Table":
"""Create DynamoDB Table resource"""
return self.dynamodb_resource.Table(self.table_name)

Expand All @@ -128,11 +135,11 @@ def storage_manager(self) -> StorageManager:
return StorageManager(table=self.dynamodb_table)

@cached_property
def token_users_table_name(self):
return os.environ.get("TOKEN_USERS_TABLE_NAME")
def token_users_table_name(self) -> str:
return os.environ["TOKEN_USERS_TABLE_NAME"]

@cached_property
def token_users_table(self):
def token_users_table(self) -> "Table":
"""Create DynamoDB Table resource for token users"""
return self.dynamodb_resource.Table(self.token_users_table_name)

Expand All @@ -142,14 +149,14 @@ def user_manager(self) -> UserManager:

@cached_property
def _storage_exception_handlers(self) -> dict:
def handle_hawk_auth(ex):
def handle_hawk_auth(ex: Exception) -> Response:
return Response(
status_code=401,
content_type="application/json",
body='{"error": "Unauthorized"}',
)

def handle_uid_mismatch(ex):
def handle_uid_mismatch(ex: Exception) -> Response:
return Response(
status_code=403,
content_type="application/json",
Expand All @@ -163,7 +170,7 @@ def handle_uid_mismatch(ex):

@cached_property
def _auth_exception_handlers(self) -> dict:
def handle_hawk_auth(ex):
def handle_hawk_auth(ex: Exception) -> Response:
return Response(
status_code=401,
content_type="application/json",
Expand All @@ -175,7 +182,7 @@ def handle_hawk_auth(ex):
}

@cached_property
def storage_api_router(self):
def storage_api_router(self) -> ApiRouter:
return ApiRouter(
routes=[
DeleteAllRootRoute(self.storage_manager),
Expand Down Expand Up @@ -214,7 +221,7 @@ def oidc_client_id(self) -> str:
return os.environ["OIDC_CLIENT_ID"]

@cached_property
def base_domain(self):
def base_domain(self) -> Optional[str]:
return os.environ.get("BASE_DOMAIN")

@cached_property
Expand Down Expand Up @@ -270,11 +277,11 @@ def token_generator(self) -> TokenGenerator:
# Auth API properties

@cached_property
def auth_table_name(self):
return os.environ.get("AUTH_TABLE_NAME")
def auth_table_name(self) -> str:
return os.environ["AUTH_TABLE_NAME"]

@cached_property
def auth_table(self):
def auth_table(self) -> "Table":
"""DynamoDB Table for auth accounts, sessions, and OAuth codes"""
return self.dynamodb_resource.Table(self.auth_table_name)

Expand All @@ -283,7 +290,7 @@ def auth_signing_key_id(self) -> str:
return os.environ["AUTH_SIGNING_KEY_ID"]

@cached_property
def kms_client(self): # pragma: nocover
def kms_client(self) -> Any: # pragma: nocover
return self.session.client("kms")

@cached_property
Expand Down Expand Up @@ -327,7 +334,7 @@ def cors_config(self) -> CORSConfig:
)

@cached_property
def auth_api_router(self):
def auth_api_router(self) -> ApiRouter:
"""Create API router for Auth API with all FxA-compatible routes"""
return ApiRouter(
routes=[
Expand Down Expand Up @@ -404,7 +411,7 @@ def auth_api_router(self):
)

@cached_property
def token_api_router(self):
def token_api_router(self) -> ApiRouter:
"""Create API router for Token API (sync token issuance)"""
return ApiRouter(
routes=[
Expand All @@ -422,7 +429,7 @@ def token_api_router(self):
)

@cached_property
def profile_api_router(self):
def profile_api_router(self) -> ApiRouter:
"""Create API router for Profile API (OAuth Bearer auth)"""
return ApiRouter(
routes=[
Expand All @@ -440,15 +447,15 @@ def profile_api_router(self):
# HAWK Authorizer properties

@cached_property
def token_cache_table_name(self):
return os.environ.get("TOKEN_CACHE_TABLE_NAME")
def token_cache_table_name(self) -> str:
return os.environ["TOKEN_CACHE_TABLE_NAME"]

@cached_property
def token_duration(self) -> int:
return int(os.environ["TOKEN_DURATION"])

@cached_property
def token_cache_table(self):
def token_cache_table(self) -> "Table":
"""Create DynamoDB Table resource for token cache"""
return self.dynamodb_resource.Table(self.token_cache_table_name)

Expand All @@ -464,11 +471,11 @@ def hawk_service(self) -> HawkService:
# Channel Service properties

@cached_property
def channel_table_name(self):
return os.environ.get("CHANNEL_TABLE_NAME")
def channel_table_name(self) -> str:
return os.environ["CHANNEL_TABLE_NAME"]

@cached_property
def channel_table(self):
def channel_table(self) -> "Table":
"""DynamoDB Table for pairing channel state"""
resource = self.session.resource("dynamodb")
return resource.Table(self.channel_table_name)
Expand Down
21 changes: 19 additions & 2 deletions lambda/src/middlewares/hawk_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response
from aws_lambda_powertools.event_handler.middlewares import BaseMiddlewareHandler, NextMiddleware
from aws_lambda_powertools.metrics import Metrics, MetricUnit
from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent

from src.services.fxa_token_manager import FxATokenManager
from src.services.hawk_service import HawkService
Expand Down Expand Up @@ -65,7 +66,15 @@ def handler(self, app: APIGatewayRestResolver, next_middleware: NextMiddleware)
self._metrics.add_metric("HawkAuthSuccess", MetricUnit.Count, 1)
return next_middleware(app)

def _validate_storage_hawk(self, event, auth_header, method, path, host, port):
def _validate_storage_hawk(
self,
event: APIGatewayProxyEvent,
auth_header: str,
method: str,
path: str,
host: str,
port: int,
) -> None:
"""Validate storage Hawk token and check URL uid matches authenticated user."""
assert self._hawk_service is not None
try:
Expand All @@ -86,7 +95,15 @@ def _validate_storage_hawk(self, event, auth_header, method, path, host, port):

event["requestContext"]["hawk_uid"] = creds.user_id

def _validate_session_hawk(self, event, auth_header, method, path, host, port):
def _validate_session_hawk(
self,
event: APIGatewayProxyEvent,
auth_header: str,
method: str,
path: str,
host: str,
port: int,
) -> None:
"""Validate FxA session Hawk token."""
assert self._token_manager is not None
uid = self._token_manager.verify_session_hawk(auth_header, method, path, host, port)
Expand Down
2 changes: 1 addition & 1 deletion lambda/src/middlewares/request_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def handler(self, app: APIGatewayRestResolver, next_middleware: NextMiddleware)
method = event.get("httpMethod", "UNKNOWN")
path = event.get("path", "UNKNOWN")

user_id = event.get("requestContext", {}).get("hawk_uid", "anonymous") # type: ignore
user_id = (event.get("requestContext") or {}).get("hawk_uid", "anonymous")

logger.info(
"Request received",
Expand Down
9 changes: 5 additions & 4 deletions lambda/src/routes/auth/account_attached_clients.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""AccountAttachedClients route — GET /v1/account/attached_clients"""

from typing import Sequence
from typing import Any, Sequence

from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response
from aws_lambda_powertools.event_handler.middlewares import BaseMiddlewareHandler
from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent

from src.services.device_manager import DeviceManager
from src.shared.base_route import BaseRoute
Expand All @@ -21,12 +22,12 @@ def __init__(
self._device_manager = device_manager
self.middlewares = middlewares

def bind(self, app: APIGatewayRestResolver):
def bind(self, app: APIGatewayRestResolver) -> None:
@app.get("/v1/account/attached_clients", middlewares=list(self.middlewares))
def handle_account_attached_clients():
def handle_account_attached_clients() -> Response[Any]:
return self.handle(app.current_event)

def handle(self, event) -> Response:
def handle(self, event: APIGatewayProxyEvent) -> Response:
uid = event["requestContext"]["hawk_uid"]
session_token_id = event["requestContext"].get("hawk_token_id", "")

Expand Down
10 changes: 6 additions & 4 deletions lambda/src/routes/auth/account_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import json
import re
import uuid
from typing import Any

from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response
from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent

from src.services.auth_account_manager import AuthAccountManager
from src.services.fxa_crypto import derive_verify_hash, generate_random_bytes
Expand All @@ -30,14 +32,14 @@ def __init__(
self._token_manager = token_manager
self._oidc_validator = oidc_validator

def bind(self, app: APIGatewayRestResolver):
def bind(self, app: APIGatewayRestResolver) -> None:
@app.post("/v1/account/create")
def handle_account_create():
def handle_account_create() -> Response[Any]:
return self.handle(app.current_event)

def handle(self, event) -> Response:
def handle(self, event: APIGatewayProxyEvent) -> Response:
# Validate OIDC Bearer token
headers = event.headers or {}
headers = event.headers
auth_header = headers.get("authorization")
if not auth_header:
return self._error(401, 110, "Missing Authorization header")
Expand Down
Loading
Loading