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
133 changes: 133 additions & 0 deletions infrastructure/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,31 @@ Resources:
- Key: Project
Value: nat

# OAuthStateTable - single-use CSRF nonces for the NationBuilder OAuth flow.
# Each record is written at OAuth init and deleted on callback (single-use);
# the TTL on expires_at evicts any that are never consumed.
OAuthStateTable:
Type: AWS::DynamoDB::Table
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
TableName: !Sub 'nat-oauth-state-${Environment}'
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: nonce
AttributeType: S
KeySchema:
- AttributeName: nonce
KeyType: HASH
TimeToLiveSpecification:
AttributeName: expires_at
Enabled: true
Tags:
- Key: Environment
Value: !Ref Environment
- Key: Project
Value: nat

# =============================================================================
# IAM Roles
# =============================================================================
Expand Down Expand Up @@ -163,6 +188,7 @@ Resources:
- !Sub '${TenantsTable.Arn}/index/*'
- !GetAtt UsersTable.Arn
- !Sub '${UsersTable.Arn}/index/*'
- !GetAtt OAuthStateTable.Arn
- PolicyName: SecretsManagerAccess
PolicyDocument:
Version: '2012-10-17'
Expand Down Expand Up @@ -355,6 +381,9 @@ Resources:
Variables:
NATIONS_TABLE: !Ref NationsTable
USERS_TABLE: !Ref UsersTable
OAUTH_STATE_TABLE: !Ref OAuthStateTable
OAUTH_STATE_TTL_SECONDS: '600'
OAUTH_REDIRECT_URI_ALLOWLIST: !Sub 'https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/auth/nationbuilder/callback'
NB_CLIENT_ID_SECRET: !Sub 'nat/nb-client-id-${Environment}'
NB_CLIENT_SECRET_SECRET: !Sub 'nat/nb-client-secret-${Environment}'
SESSION_JWT_SECRET_NAME: !Sub 'nat/session-jwt-secret-${Environment}'
Expand All @@ -375,6 +404,45 @@ Resources:
Principal: apigateway.amazonaws.com
SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGateway}/*/*/*'

# NationBuilder OAuth Init Lambda - issues the single-use CSRF state nonce and
# redirects the browser to NationBuilder's authorize endpoint.
NBOAuthInitFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub 'nat-nb-oauth-init-${Environment}'
Runtime: python3.11
Handler: handler.handler
Role: !GetAtt LambdaExecutionRole.Arn
Code:
ZipFile: |
# Placeholder - actual code deployed separately
def handler(event, context):
return {"statusCode": 200, "body": "OK"}
Timeout: 30
MemorySize: 256
Environment:
Variables:
OAUTH_STATE_TABLE: !Ref OAuthStateTable
OAUTH_STATE_TTL_SECONDS: '600'
OAUTH_REDIRECT_URI_ALLOWLIST: !Sub 'https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/auth/nationbuilder/callback'
OAUTH_CALLBACK_URL: !Sub 'https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/auth/nationbuilder/callback'
NB_CLIENT_ID_SECRET: !Sub 'nat/nb-client-id-${Environment}'
ERROR_REDIRECT_URL: !Sub 'https://natassistant.com/connection-error?env=${Environment}'
Tags:
- Key: Environment
Value: !Ref Environment
- Key: Project
Value: nat

# Permission for API Gateway to invoke NB OAuth Init Lambda
NBOAuthInitLambdaPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref NBOAuthInitFunction
Action: lambda:InvokeFunction
Principal: apigateway.amazonaws.com
SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGateway}/*/*/*'

# Token Refresh Lambda (EventBridge triggered)
TokenRefreshFunction:
Type: AWS::Lambda::Function
Expand Down Expand Up @@ -560,6 +628,51 @@ Resources:
ParentId: !Ref AuthResource
PathPart: nationbuilder

# GET /auth/nationbuilder method - starts the OAuth flow (issues CSRF state)
AuthNBInitMethod:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApiGateway
ResourceId: !Ref AuthNBResource
HttpMethod: GET
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${NBOAuthInitFunction.Arn}/invocations'
MethodResponses:
- StatusCode: '302'
ResponseParameters:
method.response.header.Location: true
method.response.header.Access-Control-Allow-Origin: true

# OPTIONS /auth/nationbuilder method for CORS
AuthNBInitOptionsMethod:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApiGateway
ResourceId: !Ref AuthNBResource
HttpMethod: OPTIONS
AuthorizationType: NONE
Integration:
Type: MOCK
RequestTemplates:
application/json: '{"statusCode": 200}'
IntegrationResponses:
- StatusCode: '200'
ResponseParameters:
method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'"
method.response.header.Access-Control-Allow-Origin: "'*'"
MethodResponses:
- StatusCode: '200'
ResponseParameters:
method.response.header.Access-Control-Allow-Headers: true
method.response.header.Access-Control-Allow-Methods: true
method.response.header.Access-Control-Allow-Origin: true
ResponseModels:
application/json: Empty

# /auth/nationbuilder/callback resource
AuthNBCallbackResource:
Type: AWS::ApiGateway::Resource
Expand Down Expand Up @@ -813,6 +926,8 @@ Resources:
- StripeWebhookOptionsMethod
- StripeCheckoutMethod
- StripeCheckoutOptionsMethod
- AuthNBInitMethod
- AuthNBInitOptionsMethod
- AuthNBCallbackMethod
- AuthNBCallbackOptionsMethod
- AgentQueryMethod
Expand Down Expand Up @@ -944,6 +1059,24 @@ Outputs:
Export:
Name: !Sub '${AWS::StackName}-NBOAuthCallbackUrl'

NBOAuthInitFunctionArn:
Description: ARN of the NationBuilder OAuth Init Lambda function
Value: !GetAtt NBOAuthInitFunction.Arn
Export:
Name: !Sub '${AWS::StackName}-NBOAuthInitFunctionArn'

NBOAuthInitUrl:
Description: URL of the NationBuilder OAuth init endpoint (starts the flow)
Value: !Sub 'https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/auth/nationbuilder'
Export:
Name: !Sub '${AWS::StackName}-NBOAuthInitUrl'

OAuthStateTableName:
Description: Name of the OAuth state (CSRF nonce) DynamoDB table
Value: !Ref OAuthStateTable
Export:
Name: !Sub '${AWS::StackName}-OAuthStateTableName'

TokenRefreshFunctionArn:
Description: ARN of the Token Refresh Lambda function
Value: !GetAtt TokenRefreshFunction.Arn
Expand Down
40 changes: 23 additions & 17 deletions src/lambdas/nb_oauth_callback/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,19 @@
from botocore.exceptions import ClientError

try: # Resolve in both pytest (repo root) and flattened Lambda packages.
from src.lambdas.shared.oauth_state import (
OAuthStateError,
validate_oauth_state,
)
from src.lambdas.shared.session_token import (
get_session_secret,
mint_session_token,
)
except ModuleNotFoundError: # pragma: no cover - exercised only in Lambda
from shared.oauth_state import ( # type: ignore[no-redef]
OAuthStateError,
validate_oauth_state,
)
from shared.session_token import ( # type: ignore[no-redef]
get_session_secret,
mint_session_token,
Expand Down Expand Up @@ -415,27 +423,25 @@ def handler(event: dict[str, Any], context: Any) -> LambdaResponse:
error_url = f"{ERROR_REDIRECT_URL}?error=missing_state"
return create_redirect_response(error_url)

# Parse state parameter
# Validate and consume the OAuth state. This enforces CSRF protection
# (single-use, server-side nonce bound to the issued identity) and the
# redirect_uri allowlist. Tampered, replayed, or expired state is
# rejected here. The returned fields come from the trusted server-side
# record, not the forgeable client copy.
try:
import base64
state_json = base64.urlsafe_b64decode(state).decode("utf-8")
state_data = json.loads(state_json)
except (json.JSONDecodeError, ValueError) as e:
logger.error(f"Invalid state parameter: {e}")
error_url = f"{ERROR_REDIRECT_URL}?error=invalid_state"
state_data = validate_oauth_state(state)
except OAuthStateError as e:
logger.error(f"Invalid OAuth state: {e}")
error_url = f"{ERROR_REDIRECT_URL}?error={e.error_slug}"
return create_redirect_response(error_url)

user_id = state_data.get("user_id")
nb_slug = state_data.get("nb_slug")
redirect_uri = state_data.get("redirect_uri")

if not user_id or not nb_slug or not redirect_uri:
logger.error("State missing required fields")
error_url = f"{ERROR_REDIRECT_URL}?error=invalid_state"
return create_redirect_response(error_url)
user_id = state_data["user_id"]
nb_slug = state_data["nb_slug"]
redirect_uri = state_data["redirect_uri"]

# Extract optional email from state for admin user creation
user_email = state_data.get("email")
# admin_email is no longer carried in the (now trusted, single-use)
# state. Populating it from NationBuilder userinfo is a follow-up.
user_email = None

logger.info(f"Processing OAuth callback for user {user_id}, nation {nb_slug}")

Expand Down
1 change: 1 addition & 0 deletions src/lambdas/nb_oauth_init/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# NationBuilder OAuth Init Lambda
Loading
Loading