Last active
December 3, 2023 17:13
-
-
Save amancevice/22ebc0e766906934b1c39ae19e5a7efb to your computer and use it in GitHub Desktop.
Serverless Slackbot CloudFormation Template
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | |
AWSTemplateFormatVersion: "2010-09-09" | |
Description: Serverless Slackbot | |
Parameters: | |
ApiBasePath: | |
Description: Slack app REST API base path | |
Type: String | |
Default: "" | |
DomainCertificateArn: | |
Description: Slack app REST API domain ACM certificate ARN | |
Type: String | |
DomainName: | |
Description: Slack app REST API domain name | |
Type: String | |
DomainZoneId: | |
Description: Slack app REST API domain Route53 Hosted Zone ID | |
Type: String | |
LogRetentionInDays: | |
Description: CloudWatch log retention period in days | |
Type: Number | |
Default: 0 | |
OAuthTimeoutSeconds: | |
Description: OAuth state TTL | |
Type: Number | |
Default: 300 | |
Name: | |
Description: Slack app name | |
Type: String | |
RestApiLogFormat: | |
Description: API Gateway REST API log format JSON | |
Type: String | |
Default: >- | |
{ | |
"caller": "$context.identity.caller", | |
"extendedRequestId": "$context.extendedRequestId", | |
"httpMethod": "$context.httpMethod", | |
"ip": "$context.identity.sourceIp", | |
"integrationError": "$context.integration.error", | |
"protocol": "$context.protocol", | |
"requestId": "$context.requestId", | |
"requestTime": "$context.requestTime", | |
"resourcePath": "$context.resourcePath", | |
"responseLength": "$context.responseLength", | |
"status": "$context.status", | |
"user": "$context.identity.user" | |
} | |
SlackClientId: | |
Description: Slack OAuth Client ID | |
Type: String | |
SlackClientSecret: | |
Description: Slack OAuth Client Secret | |
Type: String | |
SlackErrorUri: | |
Description: Slack OAuth Error URI | |
Type: String | |
SlackScope: | |
Description: Slack OAuth scopes (comma-separated) | |
Type: String | |
Default: "" | |
SlackSigningSecret: | |
Description: Slack request signing secret | |
Type: String | |
SlackSuccessUri: | |
Description: Slack OAuth Success URI | |
Type: String | |
Default: slack://open | |
SlackToken: | |
Description: Slack API token | |
Type: String | |
SlackUserScope: | |
Description: Slack OAuth user scopes (comma-separated) | |
Type: String | |
Default: "" | |
Resources: | |
################# | |
# IAM ROLES # | |
################# | |
ApiGatewayRole: | |
Type: AWS::IAM::Role | |
Properties: | |
AssumeRolePolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Effect: Allow | |
Action: sts:AssumeRole | |
Principal: | |
Service: apigateway.amazonaws.com | |
Description: !Sub ${Name} API Gateway Role | |
RoleName: !Sub ${Name}-${AWS::Region}-apigateway | |
Policies: | |
- PolicyName: states | |
PolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Sid: StartSyncExecution | |
Effect: Allow | |
Action: states:StartSyncExecution | |
Resource: !Sub arn:${AWS::Partition}:states:${AWS::Region}:${AWS::AccountId}:stateMachine:${Name}-api-* | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
LambdaRole: | |
Type: AWS::IAM::Role | |
Properties: | |
AssumeRolePolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Effect: Allow | |
Action: sts:AssumeRole | |
Principal: | |
Service: lambda.amazonaws.com | |
Description: !Sub ${Name} Lambda role | |
Policies: | |
- PolicyName: logs | |
PolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Sid: Logs | |
Effect: Allow | |
Action: logs:* | |
Resource: "*" | |
RoleName: !Sub ${Name}-${AWS::Region}-lambda | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
StatesRole: | |
Type: AWS::IAM::Role | |
Properties: | |
AssumeRolePolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Effect: Allow | |
Action: sts:AssumeRole | |
Principal: | |
Service: states.amazonaws.com | |
Description: !Sub ${Name} StepFunctions role | |
Policies: | |
- PolicyName: events | |
PolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
Effect: Allow | |
Action: events:PutEvents | |
Resource: !GetAtt EventBus.Arn | |
- PolicyName: lambda | |
PolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
Effect: Allow | |
Action: lambda:InvokeFunction | |
Resource: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${Name}-* | |
- PolicyName: logs | |
PolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Sid: Logs | |
Effect: Allow | |
Action: logs:* | |
Resource: "*" | |
- PolicyName: slack-api | |
PolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Sid: InvokeHttp | |
Effect: Allow | |
Action: states:InvokeHTTPEndpoint | |
Resource: "*" | |
Condition: | |
StringEquals: | |
states:HTTPMethod: | |
- GET | |
- POST | |
StringLike: | |
states:HTTPEndpoint: https://slack.com/api/* | |
- Sid: GetConnection | |
Effect: Allow | |
Action: events:RetrieveConnectionCredentials | |
Resource: !GetAtt EventConnection.Arn | |
- Sid: GetSecret | |
Effect: Allow | |
Resource: !GetAtt EventConnection.SecretArn | |
Action: | |
- secretsmanager:DescribeSecret | |
- secretsmanager:GetSecretValue | |
- PolicyName: states | |
PolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Effect: Allow | |
Action: | |
- states:DescribeExecution | |
- states:StartExecution | |
Resource: | |
- !Sub arn:${AWS::Partition}:states:${AWS::Region}:${AWS::AccountId}:stateMachine:${Name}-api-state | |
- !Sub arn:${AWS::Partition}:states:${AWS::Region}:${AWS::AccountId}:execution:${Name}-api-state:* | |
RoleName: !Sub ${Name}-${AWS::Region}-states | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
############## | |
# EVENTS # | |
############## | |
EventBus: | |
Type: AWS::Events::EventBus | |
Properties: | |
Name: !Ref Name | |
EventConnection: | |
Type: AWS::Events::Connection | |
Properties: | |
AuthorizationType: API_KEY | |
AuthParameters: | |
ApiKeyAuthParameters: | |
ApiKeyName: authorization | |
ApiKeyValue: !Sub Bearer ${SlackToken} | |
Description: !Sub ${Name} Slack API connection | |
Name: !Ref Name | |
################ | |
# REST API # | |
################ | |
RestApi: | |
Type: AWS::ApiGateway::RestApi | |
Properties: | |
Body: | |
openapi: 3.0.1 | |
info: | |
title: !Ref Name | |
description: !Sub ${Name} REST API | |
version: 1.0.0 | |
servers: | |
- url: !Sub https://${DomainName}/${ApiBasePath} | |
x-amazon-apigateway-endpoint-configuration: | |
disableExecuteApiEndpoint: true | |
paths: | |
/callback: | |
post: | |
operationId: postCallback | |
description: Slack interactive component callback | |
parameters: | |
- $ref: "#/components/parameters/x-slack-request-timestamp" | |
- $ref: "#/components/parameters/x-slack-signature" | |
responses: | |
"200": | |
description: 200 response | |
content: | |
application/json: | |
schema: | |
$ref: "#/components/schemas/Empty" | |
x-amazon-apigateway-request-validator: Validate query string parameters and headers | |
x-amazon-apigateway-integration: | |
type: aws | |
uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:states:action/StartSyncExecution | |
httpMethod: POST | |
credentials: !GetAtt ApiGatewayRole.Arn | |
timeoutInMillis: 3000 | |
requestTemplates: | |
application/json: |- | |
{ | |
"stateMachineArn": "$stageVariables.callbackStateMachineArn", | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\",\"signature\":\"$input.params('x-slack-signature')\",\"ts\":\"$input.params('x-slack-request-timestamp')\",\"body\":\"$util.escapeJavaScript($util.escapeJavaScript($input.body))\"}" | |
} | |
application/x-www-form-urlencoded: |- | |
{ | |
"stateMachineArn": "$stageVariables.callbackStateMachineArn", | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\",\"signature\":\"$input.params('x-slack-signature')\",\"ts\":\"$input.params('x-slack-request-timestamp')\",\"body\":\"$util.escapeJavaScript($util.escapeJavaScript($input.body))\"}" | |
} | |
responses: | |
default: | |
statusCode: "200" | |
responseTemplates: | |
application/json: |- | |
#if($input.path('$.status') != "SUCCEEDED") | |
#set($context.responseOverride.status = 403) | |
{"message":"Forbidden"}#else | |
#set($output = $util.parseJson($input.path('$.output'))) | |
#set($context.responseOverride.status = $output.statusCode) | |
#if($output.headers.location)#set($context.responseOverride.header.location = $output.headers.location)#end | |
#if($output.body)$output.body#end | |
#end | |
/event: | |
post: | |
operationId: postEvent | |
description: Slack event callback | |
parameters: | |
- $ref: "#/components/parameters/x-slack-request-timestamp" | |
- $ref: "#/components/parameters/x-slack-signature" | |
responses: | |
"200": | |
description: 200 response | |
content: | |
application/json: | |
schema: | |
$ref: "#/components/schemas/Empty" | |
x-amazon-apigateway-request-validator: Validate query string parameters and headers | |
x-amazon-apigateway-integration: | |
type: aws | |
uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:states:action/StartSyncExecution | |
httpMethod: POST | |
credentials: !GetAtt ApiGatewayRole.Arn | |
timeoutInMillis: 3000 | |
requestTemplates: | |
application/json: |- | |
{ | |
"stateMachineArn": "$stageVariables.eventStateMachineArn",, | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\",\"signature\":\"$input.params('x-slack-signature')\",\"ts\":\"$input.params('x-slack-request-timestamp')\",\"body\":\"$util.escapeJavaScript($util.escapeJavaScript($input.body))\"}" | |
} | |
application/x-www-form-urlencoded: |- | |
{ | |
"stateMachineArn": "$stageVariables.eventStateMachineArn",, | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\",\"signature\":\"$input.params('x-slack-signature')\",\"ts\":\"$input.params('x-slack-request-timestamp')\",\"body\":\"$util.escapeJavaScript($util.escapeJavaScript($input.body))\"}" | |
} | |
responses: | |
default: | |
statusCode: "200" | |
responseTemplates: | |
application/json: |- | |
#if($input.path('$.status') != "SUCCEEDED") | |
#set($context.responseOverride.status = 403) | |
{"message":"Forbidden"}#else | |
#set($output = $util.parseJson($input.path('$.output'))) | |
#set($context.responseOverride.status = $output.statusCode) | |
#if($output.headers.location)#set($context.responseOverride.header.location = $output.headers.location)#end | |
#if($output.body)$output.body#end | |
#end | |
/install: | |
get: | |
operationId: getInstall | |
description: Begin OAuth flow | |
responses: | |
"200": | |
description: 200 response | |
content: | |
application/json: | |
schema: | |
$ref: "#/components/schemas/Empty" | |
x-amazon-apigateway-request-validator: Validate query string parameters and headers | |
x-amazon-apigateway-integration: | |
type: aws | |
uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:states:action/StartSyncExecution | |
httpMethod: POST | |
credentials: !GetAtt ApiGatewayRole.Arn | |
timeoutInMillis: 3000 | |
requestTemplates: | |
application/json: |- | |
{ | |
"stateMachineArn": "$stageVariables.installStateMachineArn", | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\"}" | |
} | |
application/x-www-form-urlencoded: |- | |
{ | |
"stateMachineArn": "$stageVariables.installStateMachineArn", | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\"}" | |
} | |
responses: | |
default: | |
statusCode: "200" | |
responseTemplates: | |
application/json: |- | |
#if($input.path('$.status') != "SUCCEEDED") | |
#set($context.responseOverride.status = 403) | |
{"message":"Forbidden"}#else | |
#set($output = $util.parseJson($input.path('$.output'))) | |
#set($context.responseOverride.status = $output.statusCode) | |
#if($output.headers.location)#set($context.responseOverride.header.location = $output.headers.location)#end | |
#if($output.body)$output.body#end | |
#end | |
/menu: | |
post: | |
operationId: postMenu | |
description: Slack interactive menu request | |
parameters: | |
- $ref: "#/components/parameters/x-slack-request-timestamp" | |
- $ref: "#/components/parameters/x-slack-signature" | |
responses: | |
"200": | |
description: 200 response | |
content: | |
application/json: | |
schema: | |
$ref: "#/components/schemas/Empty" | |
x-amazon-apigateway-request-validator: Validate query string parameters and headers | |
x-amazon-apigateway-integration: | |
type: aws | |
uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:states:action/StartSyncExecution | |
httpMethod: POST | |
credentials: !GetAtt ApiGatewayRole.Arn | |
timeoutInMillis: 3000 | |
requestTemplates: | |
application/json: |- | |
{ | |
"stateMachineArn": "$stageVariables.menuStateMachineArn", | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\",\"signature\":\"$input.params('x-slack-signature')\",\"ts\":\"$input.params('x-slack-request-timestamp')\",\"body\":\"$util.escapeJavaScript($util.escapeJavaScript($input.body))\"}" | |
} | |
application/x-www-form-urlencoded: |- | |
{ | |
"stateMachineArn": "$stageVariables.menuStateMachineArn", | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\",\"signature\":\"$input.params('x-slack-signature')\",\"ts\":\"$input.params('x-slack-request-timestamp')\",\"body\":\"$util.escapeJavaScript($util.escapeJavaScript($input.body))\"}" | |
} | |
responses: | |
default: | |
statusCode: "200" | |
responseTemplates: | |
application/json: |- | |
#if($input.path('$.status') != "SUCCEEDED") | |
#set($context.responseOverride.status = 403) | |
{"message":"Forbidden"}#else | |
#set($output = $util.parseJson($input.path('$.output'))) | |
#set($context.responseOverride.status = $output.statusCode) | |
#if($output.headers.location)#set($context.responseOverride.header.location = $output.headers.location)#end | |
#if($output.body)$output.body#end | |
#end | |
/oauth: | |
get: | |
operationId: getOAuth | |
description: Complete OAuth flow | |
parameters: | |
- $ref: "#/components/parameters/code" | |
- $ref: "#/components/parameters/state" | |
responses: | |
"200": | |
description: 200 response | |
content: | |
application/json: | |
schema: | |
$ref: "#/components/schemas/Empty" | |
x-amazon-apigateway-request-validator: Validate query string parameters and headers | |
x-amazon-apigateway-integration: | |
type: aws | |
uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:states:action/StartSyncExecution | |
httpMethod: POST | |
credentials: !GetAtt ApiGatewayRole.Arn | |
timeoutInMillis: 3000 | |
requestTemplates: | |
application/json: |- | |
{ | |
"stateMachineArn": "$stageVariables.oauthStateMachineArn", | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\",\"code\":\"$input.params('code')\",\"state\":\"$input.params('state')\"}" | |
} | |
application/x-www-form-urlencoded: |- | |
{ | |
"stateMachineArn": "$stageVariables.oauthStateMachineArn", | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\",\"code\":\"$input.params('code')\",\"state\":\"$input.params('state')\"}" | |
} | |
responses: | |
default: | |
statusCode: "200" | |
responseTemplates: | |
application/json: |- | |
#if($input.path('$.status') != "SUCCEEDED") | |
#set($context.responseOverride.status = 403) | |
{"message":"Forbidden"}#else | |
#set($output = $util.parseJson($input.path('$.output'))) | |
#set($context.responseOverride.status = $output.statusCode) | |
#if($output.headers.location)#set($context.responseOverride.header.location = $output.headers.location)#end | |
#if($output.body)$output.body#end | |
#end | |
/slash: | |
post: | |
operationId: postSlash | |
description: Slack slash command | |
parameters: | |
- $ref: "#/components/parameters/x-slack-request-timestamp" | |
- $ref: "#/components/parameters/x-slack-signature" | |
responses: | |
"200": | |
description: 200 response | |
content: | |
application/json: | |
schema: | |
$ref: "#/components/schemas/Empty" | |
x-amazon-apigateway-request-validator: Validate query string parameters and headers | |
x-amazon-apigateway-integration: | |
type: aws | |
uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:states:action/StartSyncExecution | |
httpMethod: POST | |
credentials: !GetAtt ApiGatewayRole.Arn | |
timeoutInMillis: 3000 | |
requestTemplates: | |
application/json: |- | |
{ | |
"stateMachineArn": "$stageVariables.slashStateMachineArn", | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\",\"signature\":\"$input.params('x-slack-signature')\",\"ts\":\"$input.params('x-slack-request-timestamp')\",\"body\":\"$util.escapeJavaScript($util.escapeJavaScript($input.body))\"}" | |
} | |
application/x-www-form-urlencoded: |- | |
{ | |
"stateMachineArn": "$stageVariables.slashStateMachineArn", | |
"input": "{\"routeKey\":\"$context.httpMethod $context.resourcePath\",\"signature\":\"$input.params('x-slack-signature')\",\"ts\":\"$input.params('x-slack-request-timestamp')\",\"body\":\"$util.escapeJavaScript($util.escapeJavaScript($input.body))\"}" | |
} | |
responses: | |
default: | |
statusCode: "200" | |
responseTemplates: | |
application/json: |- | |
#if($input.path('$.status') != "SUCCEEDED") | |
#set($context.responseOverride.status = 403) | |
{"message":"Forbidden"}#else | |
#set($output = $util.parseJson($input.path('$.output'))) | |
#set($context.responseOverride.status = $output.statusCode) | |
#if($output.headers.location)#set($context.responseOverride.header.location = $output.headers.location)#end | |
#if($output.body)$output.body#end | |
#end | |
components: | |
parameters: | |
code: | |
name: code | |
in: query | |
required: true | |
schema: | |
type: string | |
state: | |
name: state | |
in: query | |
required: true | |
schema: | |
type: string | |
x-slack-request-timestamp: | |
name: x-slack-request-timestamp | |
in: header | |
required: true | |
schema: | |
type: string | |
x-slack-signature: | |
name: x-slack-signature | |
in: header | |
required: true | |
schema: | |
type: string | |
schemas: | |
Empty: | |
title: Empty Schema | |
type: object | |
x-amazon-apigateway-request-validators: | |
Validate query string parameters and headers: | |
validateRequestBody: false | |
validateRequestParameters: true | |
Description: !Sub ${Name} REST API | |
DisableExecuteApiEndpoint: true | |
EndpointConfiguration: | |
Types: | |
- REGIONAL | |
Name: !Ref Name | |
RestApiBasePathMapping: | |
Type: AWS::ApiGateway::BasePathMapping | |
DependsOn: RestApiDeployment | |
Properties: | |
BasePath: !Ref ApiBasePath | |
DomainName: !Ref DomainName | |
RestApiId: !Ref RestApi | |
Stage: default | |
RestApiDeployment: | |
Type: AWS::ApiGateway::Deployment | |
Properties: | |
Description: !Sub ${Name} Rest API deployment | |
RestApiId: !Ref RestApi | |
StageDescription: | |
AccessLogSetting: | |
DestinationArn: !GetAtt RestApiLogs.Arn | |
Format: !Ref RestApiLogFormat | |
Description: !Sub ${Name} default stage | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
Variables: | |
callbackStateMachineArn: !Ref Callback | |
eventStateMachineArn: !Ref Event | |
installStateMachineArn: !Ref Install | |
menuStateMachineArn: !Ref Menu | |
oauthStateMachineArn: !Ref OAuth | |
slashStateMachineArn: !Ref Slash | |
StageName: default | |
RestApiDomainName: | |
Type: AWS::ApiGateway::DomainName | |
Properties: | |
DomainName: !Ref DomainName | |
EndpointConfiguration: | |
Types: | |
- REGIONAL | |
RegionalCertificateArn: !Ref DomainCertificateArn | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
########### | |
# DNS # | |
########### | |
Route53Record: | |
Type: AWS::Route53::RecordSet | |
Properties: | |
AliasTarget: | |
DNSName: !GetAtt RestApiDomainName.RegionalDomainName | |
EvaluateTargetHealth: false | |
HostedZoneId: !GetAtt RestApiDomainName.RegionalHostedZoneId | |
HostedZoneId: !Ref DomainZoneId | |
Name: !Ref DomainName | |
Region: !Ref AWS::Region | |
SetIdentifier: !Ref AWS::Region | |
Type: A | |
######################## | |
# LAMBDA FUNCTIONS # | |
######################## | |
AuthorizerFunction: | |
Type: AWS::Lambda::Function | |
Properties: | |
Architectures: | |
- arm64 | |
Code: | |
ZipFile: | | |
import hmac | |
import os | |
from datetime import datetime, UTC | |
from hashlib import sha256 | |
secret = os.environ["SIGNING_SECRET"] | |
def handler(event, *_): | |
# Extract signing details | |
body = event["body"] | |
signature = event["signature"] | |
ts = event["ts"] | |
# Raise if message is older than 5min or in the future | |
try: | |
delta = int(now()) - int(ts) | |
except ValueError: | |
raise Forbidden("Request timestamp invalid") | |
if delta > 5 * 60: | |
raise Forbidden("Request timestamp is too old") | |
elif delta < 0: | |
raise Forbidden("Request timestamp is in the future") | |
# Raise if signatures do not match | |
expected = sign(secret, body, ts) | |
if signature != expected: | |
raise Forbidden("Invalid signature") | |
return True | |
def now(): | |
return datetime.now(UTC).timestamp() | |
def sign(secret, body, ts=None): | |
ts = ts or str(int(now())) | |
data = f"v0:{ts}:{body}" | |
hex = hmac.new(secret.encode(), data.encode(), sha256).hexdigest() | |
signature = f"v0={hex}" | |
return signature | |
class Forbidden(Exception): | |
... | |
Description: !Sub ${Name} HTTP request authorizer | |
Environment: | |
Variables: | |
SIGNING_SECRET: !Ref SlackSigningSecret | |
FunctionName: !Sub ${Name}-api-authorizer | |
Handler: index.handler | |
MemorySize: 1024 | |
Runtime: python3.11 | |
Timeout: 3 | |
Role: !GetAtt LambdaRole.Arn | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
OAuthFunction: | |
Type: AWS::Lambda::Function | |
Properties: | |
Architectures: | |
- arm64 | |
Code: | |
ZipFile: | | |
import json | |
import os | |
from urllib.parse import urlencode | |
from urllib.request import Request, urlopen | |
client_id = os.environ["CLIENT_ID"] | |
client_secret = os.environ["CLIENT_SECRET"] | |
def handler(event, *_): | |
# Set up OAuth request | |
url = "https://slack.com/api/oauth.v2.access" | |
headers = {"content-type": "application/x-www-form-urlencoded"} | |
payload = {"client_id": client_id, "client_secret": client_secret, **event} | |
data = urlencode(payload).encode() | |
# Execute request to complete OAuth workflow | |
req = Request(url, data, headers, method="POST") | |
res = urlopen(req) | |
# Return response | |
resdata = res.read().decode() | |
result = json.loads(resdata) | |
return result | |
Description: !Sub ${Name} OAuth completion | |
Environment: | |
Variables: | |
CLIENT_ID: !Ref SlackClientId | |
CLIENT_SECRET: !Ref SlackClientSecret | |
FunctionName: !Sub ${Name}-api-oauth | |
Handler: index.handler | |
MemorySize: 128 | |
Runtime: python3.11 | |
Timeout: 3 | |
Role: !GetAtt LambdaRole.Arn | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
TransformerFunction: | |
Type: AWS::Lambda::Function | |
Properties: | |
Architectures: | |
- arm64 | |
Code: | |
ZipFile: | | |
import json | |
from urllib.parse import parse_qsl | |
def handler(event, *_): | |
body = event["body"] | |
routeKey = event["routeKey"] | |
# body is a url-encoded JSON string in the 'payload' key | |
if routeKey in ["POST /callback", "POST /menu"]: | |
data = json.loads(dict(parse_qsl(body))["payload"]) | |
# body is a url-encoded string | |
elif routeKey in ["POST /slash"]: | |
data = dict(parse_qsl(body)) | |
data["type"] = "slash_command" | |
# body is a JSON string | |
else: | |
data = json.loads(body) | |
return data | |
Description: !Sub ${Name} HTTP request transfomer | |
FunctionName: !Sub ${Name}-api-transformer | |
Handler: index.handler | |
MemorySize: 1024 | |
Runtime: python3.11 | |
Timeout: 3 | |
Role: !GetAtt LambdaRole.Arn | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
############ | |
# LOGS # | |
############ | |
RestApiLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/apigateway/${Name} | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
AuthorizerFunctionLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/lambda/${Name}-api-authorizer | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
OAuthFunctionLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/lambda/${Name}-api-oauth | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
TransformerFunctionLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/lambda/${Name}-api-transformer | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
CallbackLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/states/${Name}-api-callback | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
EventLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/states/${Name}-api-event | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
InstallLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/states/${Name}-api-install | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
MenuLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/states/${Name}-api-menu | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
OAuthLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/states/${Name}-api-oauth | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
SlashLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/states/${Name}-api-slash | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
StateLogs: | |
Type: AWS::Logs::LogGroup | |
DeletionPolicy: Delete | |
UpdateReplacePolicy: Delete | |
Properties: | |
LogGroupName: !Sub /aws/states/${Name}-api-state | |
RetentionInDays: !Ref LogRetentionInDays | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
###################### | |
# STATE MACHINES # | |
###################### | |
Callback: | |
Type: AWS::StepFunctions::StateMachine | |
Properties: | |
Definition: | |
StartAt: AuthorizeAndTransform | |
States: | |
AuthorizeAndTransform: | |
Type: Parallel | |
Next: PublishEventAndRespond | |
OutputPath: $[1] | |
Branches: | |
- StartAt: Authorize | |
States: | |
Authorize: | |
Type: Task | |
Resource: !GetAtt AuthorizerFunction.Arn | |
End: true | |
Parameters: | |
signature.$: $.signature | |
ts.$: $.ts | |
body.$: $.body | |
- StartAt: Transform | |
States: | |
Transform: | |
Type: Task | |
Resource: !GetAtt TransformerFunction.Arn | |
End: true | |
ResultPath: $.body | |
Parameters: | |
routeKey.$: $.routeKey | |
body.$: $.body | |
PublishEventAndRespond: | |
Type: Parallel | |
End: true | |
OutputPath: $[1] | |
Branches: | |
- StartAt: PublishEvent | |
States: | |
PublishEvent: | |
Type: Task | |
Resource: arn:aws:states:::aws-sdk:eventbridge:putEvents | |
End: true | |
Parameters: | |
Entries: | |
- EventBusName: !Ref EventBus | |
Source: !Ref DomainName | |
DetailType.$: $.routeKey | |
Detail.$: $.body | |
- StartAt: Respond | |
States: | |
Respond: | |
Type: Task | |
Resource: arn:aws:states:::aws-sdk:lambda:invoke | |
End: true | |
OutputPath: $.Payload | |
ResultSelector: | |
Payload.$: States.StringToJson($.Payload) | |
Parameters: | |
FunctionName.$: !Sub States.Format('${Name}-api-{}', $.body.type) | |
Payload.$: States.JsonToString($.body) | |
Catch: | |
- Next: Default | |
ErrorEquals: | |
- Lambda.ResourceNotFoundException | |
Default: | |
Type: Pass | |
End: true | |
Parameters: | |
statusCode: 200 | |
LoggingConfiguration: | |
Destinations: | |
- CloudWatchLogsLogGroup: | |
LogGroupArn: !GetAtt CallbackLogs.Arn | |
RoleArn: !GetAtt StatesRole.Arn | |
StateMachineName: !Sub ${Name}-api-callback | |
StateMachineType: EXPRESS | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
Event: | |
Type: AWS::StepFunctions::StateMachine | |
Properties: | |
Definition: | |
StartAt: AuthorizeAndTransform | |
States: | |
AuthorizeAndTransform: | |
Type: Parallel | |
Next: Challenge? | |
OutputPath: $[1] | |
Branches: | |
- StartAt: Authorize | |
States: | |
Authorize: | |
Type: Task | |
Resource: !GetAtt AuthorizerFunction.Arn | |
End: true | |
Parameters: | |
signature.$: $.signature | |
ts.$: $.ts | |
body.$: $.body | |
- StartAt: Transform | |
States: | |
Transform: | |
Type: Task | |
Resource: !GetAtt TransformerFunction.Arn | |
End: true | |
ResultPath: $.body | |
Parameters: | |
routeKey.$: $.routeKey | |
body.$: $.body | |
Challenge?: | |
Type: Choice | |
Default: PublishEvent | |
Choices: | |
- Next: Respond | |
And: | |
- Variable: $.body.challenge | |
IsPresent: true | |
- Variable: $.body.type | |
IsPresent: true | |
- Variable: $.body.type | |
StringEquals: url_verification | |
PublishEvent: | |
Type: Task | |
Resource: arn:aws:states:::aws-sdk:eventbridge:putEvents | |
End: true | |
ResultSelector: | |
statusCode: 200 | |
Parameters: | |
Entries: | |
- EventBusName: !Ref EventBus | |
Source: !Ref DomainName | |
DetailType.$: $.routeKey | |
Detail.$: $.body | |
Respond: | |
Type: Pass | |
End: true | |
Parameters: | |
statusCode: 200 | |
body: | |
challenge.$: $.challenge | |
LoggingConfiguration: | |
Destinations: | |
- CloudWatchLogsLogGroup: | |
LogGroupArn: !GetAtt EventLogs.Arn | |
RoleArn: !GetAtt StatesRole.Arn | |
StateMachineName: !Sub ${Name}-api-event | |
StateMachineType: EXPRESS | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
Install: | |
Type: AWS::StepFunctions::StateMachine | |
Properties: | |
Definition: | |
StartAt: GetState | |
States: | |
GetState: | |
Type: Task | |
Resource: arn:aws:states:::aws-sdk:sfn:startExecution | |
Next: Redirect | |
OutputPath: $.ArnParts[7] | |
ResultSelector: | |
ArnParts.$: States.StringSplit($.ExecutionArn, ':') | |
Parameters: | |
StateMachineArn: !Sub arn:aws:states:${AWS::Region}:${AWS::AccountId}:stateMachine:${Name}-api-state | |
Redirect: | |
Type: Pass | |
End: true | |
Parameters: | |
statusCode: 302 | |
headers: | |
location.$: !Sub States.Format('https://slack.com/oauth/v2/authorize?client_id=${SlackClientId}&scope=${SlackScope}&user_scope=${SlackUserScope}&state={}&redirect_uri=https://${DomainName}/oauth', $) | |
LoggingConfiguration: | |
Destinations: | |
- CloudWatchLogsLogGroup: | |
LogGroupArn: !GetAtt InstallLogs.Arn | |
RoleArn: !GetAtt StatesRole.Arn | |
StateMachineName: !Sub ${Name}-api-install | |
StateMachineType: EXPRESS | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
Menu: | |
Type: AWS::StepFunctions::StateMachine | |
Properties: | |
Definition: | |
StartAt: AuthorizeAndTransform | |
States: | |
AuthorizeAndTransform: | |
Type: Parallel | |
Next: PublishEventAndRespond | |
OutputPath: $[1] | |
Branches: | |
- StartAt: Authorize | |
States: | |
Authorize: | |
Type: Task | |
Resource: !GetAtt AuthorizerFunction.Arn | |
End: true | |
Parameters: | |
signature.$: $.signature | |
ts.$: $.ts | |
body.$: $.body | |
- StartAt: Transform | |
States: | |
Transform: | |
Type: Task | |
Resource: !GetAtt TransformerFunction.Arn | |
End: true | |
ResultPath: $.body | |
Parameters: | |
routeKey.$: $.routeKey | |
body.$: $.body | |
PublishEventAndRespond: | |
Type: Parallel | |
End: true | |
OutputPath: $[1] | |
Branches: | |
- StartAt: PublishEvent | |
States: | |
PublishEvent: | |
Type: Task | |
Resource: arn:aws:states:::aws-sdk:eventbridge:putEvents | |
End: true | |
Parameters: | |
Entries: | |
- EventBusName: !Ref EventBus | |
Source: !Ref DomainName | |
DetailType.$: $.routeKey | |
Detail.$: $.body | |
- StartAt: Respond | |
States: | |
Respond: | |
Type: Task | |
Resource: arn:aws:states:::aws-sdk:lambda:invoke | |
End: true | |
OutputPath: $.Payload | |
ResultSelector: | |
Payload.$: States.StringToJson($.Payload) | |
Parameters: | |
FunctionName.$: !Sub States.Format('${Name}-api-{}', $.body.type) | |
Payload.$: States.JsonToString($.body) | |
Catch: | |
- Next: Default | |
ErrorEquals: | |
- Lambda.ResourceNotFoundException | |
Default: | |
Type: Pass | |
End: true | |
Parameters: | |
statusCode: 200 | |
LoggingConfiguration: | |
Destinations: | |
- CloudWatchLogsLogGroup: | |
LogGroupArn: !GetAtt MenuLogs.Arn | |
RoleArn: !GetAtt StatesRole.Arn | |
StateMachineName: !Sub ${Name}-api-menu | |
StateMachineType: EXPRESS | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
OAuth: | |
Type: AWS::StepFunctions::StateMachine | |
Properties: | |
Definition: | |
StartAt: GetState | |
States: | |
GetState: | |
Type: Task | |
Resource: arn:aws:states:::aws-sdk:sfn:describeExecution | |
Next: ValidState? | |
ResultPath: $.verification | |
ResultSelector: | |
status.$: $.Status | |
Parameters: | |
ExecutionArn.$: !Sub States.Format('arn:${AWS::Partition}:states:${AWS::Region}:${AWS::AccountId}:execution:${Name}-api-state:{}', $.state) | |
ValidState?: | |
Type: Choice | |
Default: InvalidState | |
Choices: | |
- Next: CompleteOAuth | |
Variable: $.verification.status | |
StringEquals: RUNNING | |
InvalidState: | |
Type: Pass | |
End: true | |
Parameters: | |
statusCode: 302 | |
headers: | |
location: !Ref SlackErrorUri | |
CompleteOAuth: | |
Type: Task | |
Resource: !GetAtt OAuthFunction.Arn | |
Next: OK? | |
Parameters: | |
redirect_uri: !Sub https://${DomainName}/oauth | |
code.$: $.code | |
OK?: | |
Type: Choice | |
Default: PublishEvent | |
Choices: | |
- Next: OAuthError | |
Variable: $.ok | |
BooleanEquals: false | |
OAuthError: | |
Type: Pass | |
End: true | |
Parameters: | |
statusCode: 302 | |
headers: | |
location: !Ref SlackErrorUri | |
PublishEvent: | |
Type: Task | |
Resource: arn:aws:states:::aws-sdk:eventbridge:putEvents | |
End: true | |
ResultSelector: | |
statusCode: 302 | |
headers: | |
location: !Ref SlackSuccessUri | |
Parameters: | |
Entries: | |
- EventBusName: !Ref EventBus | |
Source: !Ref DomainName | |
DetailType: GET /oauth | |
Detail.$: $ | |
LoggingConfiguration: | |
Destinations: | |
- CloudWatchLogsLogGroup: | |
LogGroupArn: !GetAtt OAuthLogs.Arn | |
RoleArn: !GetAtt StatesRole.Arn | |
StateMachineName: !Sub ${Name}-api-oauth | |
StateMachineType: EXPRESS | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
Slash: | |
Type: AWS::StepFunctions::StateMachine | |
Properties: | |
Definition: | |
StartAt: AuthorizeAndTransform | |
States: | |
AuthorizeAndTransform: | |
Type: Parallel | |
Next: PublishEventAndRespond | |
OutputPath: $[1] | |
Branches: | |
- StartAt: Authorize | |
States: | |
Authorize: | |
Type: Task | |
Resource: !GetAtt AuthorizerFunction.Arn | |
End: true | |
Parameters: | |
signature.$: $.signature | |
ts.$: $.ts | |
body.$: $.body | |
- StartAt: Transform | |
States: | |
Transform: | |
Type: Task | |
Resource: !GetAtt TransformerFunction.Arn | |
End: true | |
ResultPath: $.body | |
Parameters: | |
routeKey.$: $.routeKey | |
body.$: $.body | |
PublishEventAndRespond: | |
Type: Parallel | |
End: true | |
OutputPath: $[1] | |
Branches: | |
- StartAt: PublishEvent | |
States: | |
PublishEvent: | |
Type: Task | |
Resource: arn:aws:states:::aws-sdk:eventbridge:putEvents | |
End: true | |
Parameters: | |
Entries: | |
- EventBusName: !Ref EventBus | |
Source: !Ref DomainName | |
DetailType.$: $.routeKey | |
Detail.$: $.body | |
- StartAt: Respond | |
States: | |
Respond: | |
Type: Task | |
Resource: arn:aws:states:::aws-sdk:lambda:invoke | |
End: true | |
OutputPath: $.Payload | |
ResultSelector: | |
Payload.$: States.StringToJson($.Payload) | |
Parameters: | |
FunctionName.$: !Sub States.Format('${Name}-api-{}', $.body.type) | |
Payload.$: States.JsonToString($.body) | |
Catch: | |
- Next: Default | |
ErrorEquals: | |
- Lambda.ResourceNotFoundException | |
Default: | |
Type: Pass | |
End: true | |
Parameters: | |
statusCode: 200 | |
LoggingConfiguration: | |
Destinations: | |
- CloudWatchLogsLogGroup: | |
LogGroupArn: !GetAtt SlashLogs.Arn | |
RoleArn: !GetAtt StatesRole.Arn | |
StateMachineName: !Sub ${Name}-api-slash | |
StateMachineType: EXPRESS | |
Tags: | |
- Key: Name | |
Value: !Ref Name | |
State: | |
Type: AWS::StepFunctions::StateMachine | |
Properties: | |
Definition: | |
StartAt: Wait | |
States: | |
Wait: | |
Type: Wait | |
Seconds: !Ref OAuthTimeoutSeconds | |
End: true | |
LoggingConfiguration: | |
Destinations: | |
- CloudWatchLogsLogGroup: | |
LogGroupArn: !GetAtt StateLogs.Arn | |
RoleArn: !GetAtt StatesRole.Arn | |
StateMachineName: !Sub ${Name}-api-state | |
StateMachineType: STANDARD |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment