├── .eslintignore ├── .prettierignore ├── .npmignore ├── images ├── sign-in-demo.png └── thread-demo.png ├── .prettierrc.json ├── .gitignore ├── deploy.sh ├── CODE_OF_CONDUCT.md ├── tst ├── mocks │ ├── amazon-q │ │ ├── valid-response-2.json │ │ └── valid-response-1.json │ ├── slack │ │ ├── app-mention-no-thread.json │ │ └── app-mention-thread.json │ └── mocks.ts ├── utils.test.ts ├── helpers │ ├── slack │ │ └── slack-helpers.test.ts │ └── amazon-q │ │ └── amazon-q-helpers.test.ts └── functions │ └── slack-event-handler.test.ts ├── init.sh ├── .eslintrc ├── src ├── logging.ts ├── helpers │ ├── dynamodb-client.ts │ ├── idc │ │ ├── encryption-helpers.ts │ │ └── session-helpers.ts │ ├── amazon-q │ │ ├── amazon-q-client.ts │ │ └── amazon-q-helpers.ts │ ├── chat.ts │ └── slack │ │ └── slack-helpers.ts ├── utils.ts └── functions │ ├── slack-command-handler.ts │ ├── oidc-callback-handler.ts │ ├── slack-interaction-handler.ts │ └── slack-event-handler.ts ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── LICENSE ├── tsconfig.json ├── bin ├── precheck.sh ├── create-trusted-token-issuer.sh ├── my-amazon-q-slack-bot.ts ├── manifest.sh ├── environment.sh ├── create-idc-application.sh └── convert-cfn-template.js ├── slack-manifest-template.json ├── cdk.json ├── package.json ├── CONTRIBUTING.md ├── CHANGELOG.md ├── publish.sh ├── jest.config.ts ├── README_DEVELOPERS.md ├── lib └── my-amazon-q-slack-bot-stack.ts └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package.lock.json 3 | dist 4 | cdk.out 5 | build -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package.lock.json 3 | dist 4 | cdk.out 5 | build -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /images/sign-in-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-q-slack-gateway/HEAD/images/sign-in-demo.png -------------------------------------------------------------------------------- /images/thread-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-q-slack-gateway/HEAD/images/thread-demo.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "trailingComma": "none", 8 | "endOfLine": "auto" 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !jest.config.js 2 | *.d.ts 3 | node_modules 4 | 5 | # CDK asset staging directory 6 | .cdk.staging 7 | cdk.out 8 | dist 9 | .idea 10 | cdk-outputs.json 11 | environment.json* 12 | slack-manifest-output.json 13 | build 14 | awsrelease.sh -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on error 4 | set -e 5 | 6 | echo "Running cdk deploy..." 7 | cdk deploy -c environment=./environment.json --outputs-file ./cdk-outputs.json 8 | 9 | echo "Updating slack app manifest..." 10 | ./bin/manifest.sh 11 | 12 | echo "All done!" -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /tst/mocks/amazon-q/valid-response-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "systemMessage": "This is a simple text\n and now with a \n### header\n# another header", 3 | "conversationId": "80a6642c-8b3d-433e-a9cb-233b42a0d63a", 4 | "sourceAttributions": [], 5 | "systemMessageId": "e5a23752-3f31-4fee-83fe-56fbd7803540", 6 | "userMessageId": "616fefbc-48bc-442d-a618-497bbbde3d66", 7 | "$metadata": {} 8 | } -------------------------------------------------------------------------------- /init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on error 4 | set -e 5 | 6 | echo "Running precheck..." 7 | ./bin/precheck.sh 8 | 9 | echo "Setting up environment..." 10 | ./bin/environment.sh 11 | 12 | echo "Running npm install and build..." 13 | npm install && npm run build 14 | 15 | echo "Running cdk bootstrap..." 16 | cdk bootstrap -c environment=./environment.json 17 | 18 | echo "All done!" -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | "prettier" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:prettier/recommended" 13 | ], 14 | "env": { 15 | "node": true 16 | }, 17 | "overrides": [ 18 | { 19 | "files": ["*.js"], 20 | "rules": { 21 | "@typescript-eslint/no-var-requires": "off" 22 | } 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from 'winston'; 2 | 3 | export const makeLogger = (loggerName: string) => { 4 | return createLogger({ 5 | transports: [new transports.Console()], 6 | format: format.combine( 7 | format.timestamp(), 8 | format.errors({ stack: true }), 9 | format.printf(({ timestamp, level, message, loggerName }) => { 10 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 11 | return `[${timestamp}][${level}] ${loggerName}: ${message}`; 12 | }) 13 | ), 14 | defaultMeta: { 15 | loggerName 16 | }, 17 | level: 'debug' 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/helpers/dynamodb-client.ts: -------------------------------------------------------------------------------- 1 | import * as AWS from 'aws-sdk'; 2 | 3 | export const client = new AWS.DynamoDB.DocumentClient({ 4 | region: process.env.AWS_REGION, 5 | paramValidation: false, // Avoid extra latency 6 | convertResponseTypes: false // Avoid extra latency 7 | }); 8 | 9 | export const deleteItem = async (args: AWS.DynamoDB.DocumentClient.DeleteItemInput) => 10 | await client.delete(args).promise(); 11 | 12 | export const putItem = async (args: AWS.DynamoDB.DocumentClient.PutItemInput) => 13 | await client.put(args).promise(); 14 | 15 | export const getItem = async (args: AWS.DynamoDB.DocumentClient.GetItemInput) => 16 | await client.get(args).promise(); 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /tst/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import * as Utils from '@src/utils'; 3 | 4 | describe('Test global utilities', () => { 5 | test('Test is empty', () => { 6 | expect(Utils.isEmpty(0)).toBeFalsy(); 7 | expect(Utils.isEmpty(false)).toBeFalsy(); 8 | expect(Utils.isEmpty(undefined)).toBeTruthy(); 9 | expect(Utils.isEmpty(new Date())).toBeFalsy(); 10 | expect(Utils.isEmpty({})).toBeTruthy(); 11 | expect(Utils.isEmpty([])).toBeTruthy(); 12 | expect(Utils.isEmpty({ a: [] })).toBeFalsy(); 13 | expect(Utils.isEmpty(['a'])).toBeFalsy(); 14 | expect(Utils.isEmpty('')).toBeTruthy(); 15 | expect(Utils.isEmpty([{}, {}])).toBeTruthy(); 16 | expect(Utils.isEmpty([[]])).toBeTruthy(); 17 | }); 18 | 19 | test('get or throw if empty', () => { 20 | expect(() => Utils.getOrThrowIfEmpty('')).toThrow(); 21 | expect(() => Utils.getOrThrowIfEmpty(undefined)).toThrow(); 22 | expect(Utils.getOrThrowIfEmpty(['a'])).toEqual(['a']); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-explicit-any: "off" */ 2 | 3 | export const isEmpty = (value: T | undefined, checkAttributes = false): value is undefined => { 4 | if (value === undefined || value === null) { 5 | return true; 6 | } 7 | 8 | if (typeof value === 'number' || typeof value === 'boolean') { 9 | return false; 10 | } 11 | 12 | if (value instanceof Date) { 13 | return false; 14 | } 15 | 16 | if (Array.isArray(value)) { 17 | return value.length === 0 || value.every((item) => isEmpty(item)); 18 | } 19 | 20 | if (value instanceof Object) { 21 | if (Object.keys(value).length === 0) { 22 | return true; 23 | } 24 | 25 | if (checkAttributes) { 26 | return Object.values(value).every((item) => isEmpty(item)); 27 | } 28 | } 29 | 30 | return value === ''; 31 | }; 32 | 33 | export const getOrThrowIfEmpty = (value: T | undefined, name = 'element') => { 34 | if (isEmpty(value)) { 35 | throw new Error(`InvalidArgumentException: ${name} can't be empty`); 36 | } 37 | 38 | return value; 39 | }; 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "rootDir": "./", 5 | "target": "ES2018", 6 | "module": "commonjs", 7 | "lib": [ 8 | "es2022", "dom" 9 | ], 10 | "declaration": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "noUnusedLocals": false, 17 | "noUnusedParameters": false, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": false, 20 | "inlineSourceMap": true, 21 | "inlineSources": true, 22 | "experimentalDecorators": true, 23 | "strictPropertyInitialization": false, 24 | "typeRoots": [ 25 | "./node_modules/@types" 26 | ], 27 | "baseUrl": ".", 28 | "paths": { 29 | "@src/*": ["src/*"], 30 | "@tst/*": ["tst/*"], 31 | "@functions/*": ["src/functions/*"], 32 | "@helpers/*": ["src/helpers/*"] 33 | }, 34 | "resolveJsonModule": true, 35 | "esModuleInterop": true 36 | }, 37 | "exclude": [ 38 | "node_modules", 39 | "cdk.out", 40 | "./dist/**/*" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Important:** Bug reports submitted here should relate specifically to the open source sample amazon-q-slack-gateway project. Do not submit bugs related to the Amazon Q service itself. 11 | If your issue relates to Amazon Q setup, user provisioning, document ingestion, accuracy, or other aspects of the service, then first check the Amazon Q documentation, and then reproduce the problem using the Amazon Q console web experience before engaging AWS support directly. Thank you. 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /bin/precheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # helper script called by $ROOT/init.sh - check for required packages 5 | # 6 | 7 | command_exists() { 8 | type "$1" &> /dev/null 9 | } 10 | 11 | version_gt() { 12 | test "$(echo "$@" | tr " " "\n" | sort -V | head -n 1)" != "$1"; 13 | } 14 | 15 | extract_version() { 16 | echo "$1" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n 1 17 | } 18 | 19 | # Commands and minimum versions 20 | commands="node npm tsc esbuild jq aws cdk" 21 | node_version="18.0.0" 22 | npm_version="9.5.1" 23 | tsc_version="3.8.0" 24 | esbuild_version="0.19.0" 25 | jq_version="1.5" 26 | aws_version="2.10.2" 27 | cdk_version="2.94.0" 28 | 29 | status=0 30 | 31 | for cmd in $commands; do 32 | min_version_var="${cmd}_version" 33 | min_version=${!min_version_var} 34 | if command_exists "$cmd"; then 35 | installed_version=$(extract_version "$($cmd --version)") 36 | if version_gt $min_version $installed_version; then 37 | echo "$cmd v$installed_version installed, v$min_version required - NOT OK" 38 | status=1 39 | else 40 | echo "$cmd v$installed_version installed - OK" 41 | fi 42 | else 43 | echo "$cmd is NOT installed." 44 | status=1 45 | fi 46 | done 47 | 48 | exit $status 49 | 50 | -------------------------------------------------------------------------------- /tst/mocks/slack/app-mention-no-thread.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "sJ5QIJFwDrymvHxcq9ec0aWL", 3 | "team_id": "T044E46HKJ6", 4 | "enterprise_id": "E01C2B11VN2", 5 | "api_app_id": "A05SC845KRN", 6 | "event": { 7 | "client_msg_id": "06f79b6a-9afe-4571-8568-edd6ad1966c5", 8 | "type": "app_mention", 9 | "text": "<@U05R8PST0H5> hello", 10 | "user": "U043ZH2AG95", 11 | "ts": "1696251376.734439", 12 | "blocks": [ 13 | { 14 | "type": "rich_text", 15 | "block_id": "bGCs", 16 | "elements": [ 17 | { 18 | "type": "rich_text_section", 19 | "elements": [ 20 | { 21 | "type": "user", 22 | "user_id": "U05R8PST0H5" 23 | }, 24 | { 25 | "type": "text", 26 | "text": " hello" 27 | } 28 | ] 29 | } 30 | ] 31 | } 32 | ], 33 | "team": "T044E46HKJ6", 34 | "channel": "C05URD5PT43", 35 | "event_ts": "1696251376.734439" 36 | }, 37 | "type": "event_callback", 38 | "event_id": "Ev0600H8NYHE", 39 | "event_time": 1696251376, 40 | "authorizations": [ 41 | { 42 | "enterprise_id": "E01C2B11VN2", 43 | "team_id": "T044E46HKJ6", 44 | "user_id": "U05R8PST0H5", 45 | "is_bot": true, 46 | "is_enterprise_install": false 47 | } 48 | ], 49 | "is_ext_shared_channel": false, 50 | "event_context": "4-eyJldCI6ImFwcF9tZW50aW9uIiwidGlkIjoiVDA0NEU0NkhLSjYiLCJhaWQiOiJBMDVTQzg0NUtSTiIsImNpZCI6IkMwNVVSRDVQVDQzIn0" 51 | } -------------------------------------------------------------------------------- /slack-manifest-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "display_information": { 3 | "name": "!!! [SlackBotName] !!!", 4 | "description": "This is an example of a Slack app interfacing with ExpertQ using AWS CDK.", 5 | "background_color": "#1d7c00" 6 | }, 7 | "features": { 8 | "bot_user": { 9 | "display_name": "!!! [SlackBotName] !!!", 10 | "always_online": true 11 | }, 12 | "slash_commands": [ 13 | { 14 | "command": "/new_conversation", 15 | "url": "!!! [SlackCommandApiOutput] !!!", 16 | "description": "Start a new conversation", 17 | "should_escape": false 18 | } 19 | ] 20 | }, 21 | "oauth_config": { 22 | "redirect_urls": [ 23 | "!!! [SlackEventHandlerApiOutput] !!!" 24 | ], 25 | "scopes": { 26 | "bot": [ 27 | "app_mentions:read", 28 | "channels:history", 29 | "chat:write", 30 | "files:read", 31 | "groups:history", 32 | "im:history", 33 | "im:read", 34 | "users:read", 35 | "users:read.email", 36 | "commands" 37 | ] 38 | } 39 | }, 40 | "settings": { 41 | "event_subscriptions": { 42 | "request_url": "!!! [SlackEventHandlerApiOutput] !!!", 43 | "bot_events": [ 44 | "app_mention", 45 | "message.im" 46 | ] 47 | }, 48 | "interactivity": { 49 | "is_enabled": true, 50 | "request_url": "!!! [SlackInteractionHandlerApiOutput] !!!" 51 | }, 52 | "org_deploy_enabled": false, 53 | "socket_mode_enabled": false, 54 | "token_rotation_enabled": false 55 | } 56 | } -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/my-amazon-q-slack-bot.ts", 3 | "build": "tsc", 4 | "watch": { 5 | "include": [ 6 | "**" 7 | ], 8 | "exclude": [ 9 | "README.md", 10 | "cdk*.json", 11 | "**/*.d.ts", 12 | "**/*.js", 13 | "tsconfig.json", 14 | "package*.json", 15 | "yarn.lock", 16 | "node_modules", 17 | "test" 18 | ] 19 | }, 20 | "context": { 21 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 22 | "@aws-cdk/core:stackRelativeExports": true, 23 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 24 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 25 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 26 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 27 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 28 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 29 | "@aws-cdk/core:checkSecretUsage": true, 30 | "@aws-cdk/aws-iam:minimizePolicies": true, 31 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 32 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 33 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 34 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 35 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 36 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 37 | "@aws-cdk/core:enablePartitionLiterals": true, 38 | "@aws-cdk/core:target-partitions": [ 39 | "aws", 40 | "aws-cn" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tst/mocks/slack/app-mention-thread.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "sJ5QIJFwDrymvHxcq9ec0aWL", 3 | "team_id": "T044E46HKJ6", 4 | "enterprise_id": "E01C2B11VN2", 5 | "api_app_id": "A05SC845KRN", 6 | "event": { 7 | "client_msg_id": "4a70e6ef-349e-4db4-af22-7c22f9800551", 8 | "type": "app_mention", 9 | "text": "<@U05R8PST0H5> please answer", 10 | "user": "U043ZH2AG95", 11 | "ts": "1696251625.803109", 12 | "blocks": [ 13 | { 14 | "type": "rich_text", 15 | "block_id": "h3d", 16 | "elements": [ 17 | { 18 | "type": "rich_text_section", 19 | "elements": [ 20 | { 21 | "type": "user", 22 | "user_id": "U05R8PST0H5" 23 | }, 24 | { 25 | "type": "text", 26 | "text": " please answer" 27 | } 28 | ] 29 | } 30 | ] 31 | } 32 | ], 33 | "team": "T044E46HKJ6", 34 | "thread_ts": "1696251609.158979", 35 | "parent_user_id": "U043ZH2AG95", 36 | "channel": "C05URD5PT43", 37 | "event_ts": "1696251625.803109" 38 | }, 39 | "type": "event_callback", 40 | "event_id": "Ev05V47HQ6C9", 41 | "event_time": 1696251625, 42 | "authorizations": [ 43 | { 44 | "enterprise_id": "E01C2B11VN2", 45 | "team_id": "T044E46HKJ6", 46 | "user_id": "U05R8PST0H5", 47 | "is_bot": true, 48 | "is_enterprise_install": false 49 | } 50 | ], 51 | "is_ext_shared_channel": false, 52 | "event_context": "4-eyJldCI6ImFwcF9tZW50aW9uIiwidGlkIjoiVDA0NEU0NkhLSjYiLCJhaWQiOiJBMDVTQzg0NUtSTiIsImNpZCI6IkMwNVVSRDVQVDQzIn0" 53 | } -------------------------------------------------------------------------------- /bin/create-trusted-token-issuer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Ensure required arguments are passed 4 | if [ $# -lt 1 ]; then 5 | echo "Usage: $0 " 6 | exit 1 7 | fi 8 | 9 | OIDC_ISSUER_URL="$1" 10 | IDC_REGION="$2" 11 | 12 | # Retrieve the IdC instance ARN. 13 | IDC_INSTANCE_ARN=$(aws sso-admin list-instances --query 'Instances[0].InstanceArn' --region $IDC_REGION | tr -d '"') 14 | 15 | # Check if there is TTI_ARN for the issuer url. 16 | for arn in $(aws sso-admin list-trusted-token-issuers --instance-arn "$IDC_INSTANCE_ARN" --region $IDC_REGION --query 'TrustedTokenIssuers[].TrustedTokenIssuerArn[]' | tr -d '[",]') 17 | do 18 | current_issuer_url=$(aws sso-admin describe-trusted-token-issuer --trusted-token-issuer-arn $arn --region $IDC_REGION --query 'TrustedTokenIssuerConfiguration.OidcJwtConfiguration.IssuerUrl' --output text) 19 | if [[ "$current_issuer_url" == "$OIDC_ISSUER_URL" ]] 20 | then 21 | echo "Trusted token issuer already exists for $OIDC_ISSUER_URL, ARN: $arn" 22 | exit 0 23 | fi 24 | done 25 | 26 | # Create Trusted token issuer 27 | TTI_ARN=$(aws sso-admin create-trusted-token-issuer --cli-input-json '{ 28 | "InstanceArn": '\"$IDC_INSTANCE_ARN\"', 29 | "Name": "okta-issuer", 30 | "TrustedTokenIssuerConfiguration": { 31 | "OidcJwtConfiguration": { 32 | "ClaimAttributePath": "email", 33 | "IdentityStoreAttributePath": "emails.value", 34 | "IssuerUrl": '\"$OIDC_ISSUER_URL\"', 35 | "JwksRetrievalOption": "OPEN_ID_DISCOVERY" 36 | } 37 | }, 38 | "TrustedTokenIssuerType": "OIDC_JWT" 39 | }' --region $IDC_REGION --query 'TrustedTokenIssuerArn' | tr -d '"') 40 | 41 | echo "TTI_ARN=$TTI_ARN" 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my_amazon_q_slack_bot", 3 | "version": "0.2.1", 4 | "bin": { 5 | "my_enterprise_q_slack_bot": "bin/my_amazon_q_slack_bot.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "release": "npm run lint:fix && npm run build && npm run test", 10 | "watch": "tsc -w", 11 | "test": "jest", 12 | "cdk": "cdk", 13 | "lint": "eslint . --ext .ts,.js", 14 | "lint:fix": "npm run lint -- --fix" 15 | }, 16 | "devDependencies": { 17 | "@types/jest": "^29.5.10", 18 | "@types/jsonwebtoken": "^9.0.6", 19 | "@types/node": "^20.12.7", 20 | "@types/pako": "^2.0.0", 21 | "@types/prettier": "2.6.0", 22 | "@types/uuid": "^9.0.6", 23 | "@typescript-eslint/eslint-plugin": "^6.6.0", 24 | "@typescript-eslint/parser": "^6.6.0", 25 | "aws-cdk": "^2.110.1", 26 | "esbuild": "^0.20.2", 27 | "eslint": "^8.48.0", 28 | "eslint-config-prettier": "^9.0.0", 29 | "eslint-plugin-prettier": "^5.0.0", 30 | "jest": "^29.0.0", 31 | "jszip": "^3.10.1", 32 | "prettier": "^3.0.3", 33 | "ts-jest": "^29.1.1", 34 | "ts-node": "^10.9.1", 35 | "typescript": "~4.6.3" 36 | }, 37 | "dependencies": { 38 | "@aws-crypto/client-node": "^4.0.0", 39 | "@aws-sdk/client-cloudformation": "^3.465.0", 40 | "@aws-sdk/client-qbusiness": "^3.565.0", 41 | "@aws-sdk/client-s3": "^3.465.0", 42 | "@aws-sdk/client-secrets-manager": "^3.565.0", 43 | "@aws-sdk/client-sso-oidc": "^3.565.0", 44 | "@aws-sdk/client-sts": "^3.565.0", 45 | "@aws-sdk/lib-dynamodb": "^3.465.0", 46 | "@slack/web-api": "^6.10.0", 47 | "@types/aws-lambda": "^8.10.119", 48 | "archiver": "^6.0.1", 49 | "aws-cdk-lib": "^2.110.1", 50 | "aws-sdk": "^2.1452.0", 51 | "constructs": "^10.0.0", 52 | "jsonwebtoken": "^9.0.2", 53 | "jszip": "^3.10.1", 54 | "pako": "^2.1.0", 55 | "qs": "^6.10.1", 56 | "source-map-support": "^0.5.21", 57 | "uuid": "^9.0.1", 58 | "winston": "^3.10.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /bin/my-amazon-q-slack-bot.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import { MyAmazonQSlackBotStack } from '../lib/my-amazon-q-slack-bot-stack'; 5 | import { readFileSync } from 'fs'; 6 | 7 | export interface StackEnvironment { 8 | StackName: string; 9 | AmazonQAppId: string; 10 | AmazonQRegion: string; 11 | ContextDaysToLive: string; 12 | OIDCIdPName: string; 13 | OIDCClientId: string; 14 | OIDCIssuerURL: string; 15 | GatewayIdCAppARN: string; 16 | AWSIAMIdCRegion: string; 17 | } 18 | 19 | const app = new cdk.App(); 20 | const inputEnvFile = app.node.tryGetContext('environment'); 21 | if (inputEnvFile === undefined) { 22 | throw new Error('An input environment file is required'); 23 | } 24 | 25 | const environment = JSON.parse(readFileSync(inputEnvFile).toString()) as StackEnvironment; 26 | if (environment.StackName === undefined) { 27 | throw new Error('StackName is required'); 28 | } 29 | if (environment.AmazonQAppId === undefined) { 30 | throw new Error('AmazonQAppId is required'); 31 | } 32 | if (environment.AmazonQRegion === undefined) { 33 | throw new Error('AmazonQRegion is required'); 34 | } 35 | if (environment.ContextDaysToLive === undefined) { 36 | throw new Error('ContextDaysToLive is required'); 37 | } 38 | if (environment.OIDCIdPName === undefined) { 39 | throw new Error('OIDCIdPName is required'); 40 | } 41 | if (environment.OIDCClientId === undefined) { 42 | throw new Error('OIDCClientId is required'); 43 | } 44 | if (environment.OIDCIssuerURL === undefined) { 45 | throw new Error('OIDCIssuerURL is required'); 46 | } 47 | if (environment.GatewayIdCAppARN === undefined) { 48 | throw new Error('GatewayIdCAppARN is required'); 49 | } 50 | if (environment.AWSIAMIdCRegion === undefined) { 51 | throw new Error('AWSIAMIdCRegion is required'); 52 | } 53 | 54 | new MyAmazonQSlackBotStack( 55 | app, 56 | 'AmazonQSlackGatewayStack', 57 | { 58 | stackName: environment.StackName 59 | }, 60 | environment 61 | ); 62 | -------------------------------------------------------------------------------- /tst/helpers/slack/slack-helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { validateSlackRequest, verifySlackSignature } from '@helpers/slack/slack-helpers'; 2 | import { MOCK_ENV } from '@tst/mocks/mocks'; 3 | 4 | describe('Slack helpers test', () => { 5 | test('Should verify a valid signature against an invalid secret and failed', async () => { 6 | const headers = { 7 | 'X-Slack-Request-Timestamp': '1698765388', 8 | 'X-Slack-Signature': 'v0=a14b9bafb83da2adf043ba3384c693e84507cb7e7b27b6266ee4679696fd8a59' 9 | }; 10 | 11 | const body = 12 | '{"token":"sJ5QIJFwDrymvHxcq9ec0aWL","team_id":"team_id","enterprise_id":"enterprise_id","api_app_id":"api_app_id","event":{"client_msg_id":"20bfd3b0-6d99-4e15-bff0-60af3d3865c1","type":"app_mention","text":"<@U05R8PST0H5> which one is best?","user":"user","ts":"1698765387.549099","blocks":[{"type":"rich_text","block_id":"HalYY","elements":[{"type":"rich_text_section","elements":[{"type":"user","user_id":"user_id"},{"type":"text","text":" which one is best?"}]}]}],"team":"team","thread_ts":"1698765376.332119","parent_user_id":"parent_user_id","channel":"channel","event_ts":"1698765387.549099"},"type":"event_callback","event_id":"Ev063ZKYPR6D","event_time":1698765387,"authorizations":[{"enterprise_id":"enterprise_id","team_id":"team_id","user_id":"user_id","is_bot":true,"is_enterprise_install":false}],"is_ext_shared_channel":false,"event_context":"4-eyJldCI6ImFwcF9tZW50aW9uIiwidGlkIjoiVDA0NEU0NkhLSjYiLCJhaWQiOiJBMDVTQzg0NUtSTiIsImNpZCI6IkMwNVVSRDVQVDQzIn0"}'; 13 | const secret = 'INVALID'; 14 | const date = new Date(1698765388); 15 | 16 | expect(verifySlackSignature(headers, body, secret, date)).toBeFalsy(); 17 | expect( 18 | await validateSlackRequest(headers, body, MOCK_ENV, { 19 | getSlackSecret: () => 20 | Promise.resolve({ 21 | SlackSigningSecret: secret, 22 | SlackClientSecret: 'SlackClientSecret', 23 | SlackClientId: 'SlackClientId', 24 | SlackBotUserOAuthToken: 'SlackBotUserOAuthToken' 25 | }) 26 | }) 27 | ).toBeFalsy(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/helpers/idc/encryption-helpers.ts: -------------------------------------------------------------------------------- 1 | import { buildClient, CommitmentPolicy, KmsKeyringNode } from '@aws-crypto/client-node'; 2 | 3 | type EncryptionContext = { 4 | [key: string]: string; 5 | }; 6 | 7 | const { encrypt, decrypt } = buildClient(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT); 8 | 9 | // Encrypts the given data using the provided keyId and slackUserId 10 | export const encryptData = async (data: string, keyId: string, slackUserId: string) => { 11 | if (!data || !keyId || !slackUserId) { 12 | throw new Error('Invalid arguments'); 13 | } 14 | const keyring = new KmsKeyringNode({ generatorKeyId: keyId }); 15 | const contextData = buildContextData(slackUserId); 16 | const encryptionContext = buildEncryptionContext(contextData); 17 | 18 | const { result } = await encrypt(keyring, data, { encryptionContext: encryptionContext }); 19 | 20 | return result.toString('base64'); 21 | }; 22 | 23 | // Decrypts the given cipherText using the provided keyId and slackUserId 24 | export const decryptData = async (cipherText: string, keyId: string, slackUserId: string) => { 25 | // decode base64 26 | const data = Buffer.from(cipherText, 'base64'); 27 | const keyring = new KmsKeyringNode({ generatorKeyId: keyId }); 28 | 29 | const { plaintext, messageHeader } = await decrypt(keyring, data); 30 | 31 | // validate encryption context 32 | const { encryptionContext } = messageHeader; 33 | if (encryptionContext.slackUserId !== slackUserId) { 34 | throw new Error('Invalid encryption context - slackUserId mismatch'); 35 | } 36 | 37 | return plaintext.toString(); 38 | }; 39 | 40 | // Builds an encryption context from the given contextData 41 | const buildEncryptionContext = (contextData: Record): EncryptionContext => { 42 | const encryptionContext: EncryptionContext = {}; 43 | 44 | for (const key in contextData) { 45 | if (Object.prototype.hasOwnProperty.call(contextData, key)) { 46 | encryptionContext[key] = contextData[key]; 47 | } 48 | } 49 | 50 | return encryptionContext; 51 | }; 52 | 53 | // Builds context data for the given slackUserId 54 | const buildContextData = (slackUserId: string): Record => { 55 | return { 56 | slackUserId: slackUserId 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /bin/manifest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # helper script called by $ROOT/deploy.sh - updates slack manifest file with URLs from stack output 5 | # 6 | 7 | # Exit on error 8 | set -e 9 | 10 | # The path to the JSON file containing the stack name (= bot name) 11 | ENVIRONMENT_FILE="environment.json" 12 | 13 | # The path to the JSON file containing the stack output keys and values 14 | CDK_OUT_FILE="cdk-outputs.json" 15 | 16 | # The path to the original template manifest file 17 | TEMPLATE_FILE="slack-manifest-template.json" 18 | 19 | # The path to the new output manifest file 20 | OUTPUT_FILE="slack-manifest-output.json" 21 | 22 | # Extract the StackName from environment file 23 | STACKNAME=$(jq -r ".StackName" "$ENVIRONMENT_FILE" 2> /dev/null) 24 | 25 | # Extract the API Endpoint Urls based on the known substrings using jq 26 | CDK_OUT_CONTENT=$(<"$CDK_OUT_FILE") 27 | SLACK_EVENT_HANDLER_API_OUTPUT=$(echo "$CDK_OUT_CONTENT" | jq -r '.[] | to_entries[] | select(.key | contains("SlackEventHandlerApiEndpoint")) | .value') 28 | SLACK_INTERACTION_HANDLER_API_OUTPUT=$(echo "$CDK_OUT_CONTENT" | jq -r '.[] | to_entries[] | select(.key | contains("SlackInteractionHandlerApiEndpoint")) | .value') 29 | SLACK_COMMAND_HANDLER_API_OUTPUT=$(echo "$CDK_OUT_CONTENT" | jq -r '.[] | to_entries[] | select(.key | contains("SlackCommandHandlerApiEndpoint")) | .value') 30 | SLACK_SECRET_URL_OUTPUT=$(echo "$CDK_OUT_CONTENT" | jq -r '.[] | to_entries[] | select(.key | contains("SlackSecretConsoleUrl")) | .value') 31 | 32 | # Use sed to replace the tokens with the extracted values in the template file and write to the new file 33 | if sed --version 2>/dev/null | grep -q GNU; then # GNU sed 34 | sed "s|\"!!! \[SlackBotName\] !!!\"|\"$STACKNAME\"|g" "$TEMPLATE_FILE" > "$OUTPUT_FILE" 35 | sed -i "s|\"!!! \[SlackEventHandlerApiOutput\] !!!\"|\"$SLACK_EVENT_HANDLER_API_OUTPUT\"|g" "$OUTPUT_FILE" 36 | sed -i "s|\"!!! \[SlackInteractionHandlerApiOutput\] !!!\"|\"$SLACK_INTERACTION_HANDLER_API_OUTPUT\"|g" "$OUTPUT_FILE" 37 | sed -i "s|\"!!! \[SlackCommandApiOutput\] !!!\"|\"$SLACK_COMMAND_HANDLER_API_OUTPUT\"|g" "$OUTPUT_FILE" 38 | else 39 | sed "s|\"!!! \[SlackBotName\] !!!\"|\"$STACKNAME\"|g" "$TEMPLATE_FILE" > "$OUTPUT_FILE" 40 | sed -i "" "s|\"!!! \[SlackEventHandlerApiOutput\] !!!\"|\"$SLACK_EVENT_HANDLER_API_OUTPUT\"|g" "$OUTPUT_FILE" 41 | sed -i "" "s|\"!!! \[SlackInteractionHandlerApiOutput\] !!!\"|\"$SLACK_INTERACTION_HANDLER_API_OUTPUT\"|g" "$OUTPUT_FILE" 42 | sed -i "" "s|\"!!! \[SlackCommandApiOutput\] !!!\"|\"$SLACK_COMMAND_HANDLER_API_OUTPUT\"|g" "$OUTPUT_FILE" 43 | fi 44 | 45 | # Display a message to show completion 46 | echo "Slack app manifest created: $OUTPUT_FILE." 47 | 48 | # Output URL to Secrets Manager 49 | echo URL for your slack bot secrets: $SLACK_SECRET_URL_OUTPUT 50 | 51 | -------------------------------------------------------------------------------- /src/helpers/amazon-q/amazon-q-client.ts: -------------------------------------------------------------------------------- 1 | import { SlackEventsEnv } from '@functions/slack-event-handler'; 2 | import { SlackInteractionsEnv } from '@functions/slack-interaction-handler'; 3 | import { makeLogger } from '@src/logging'; 4 | import { v4 as uuid } from 'uuid'; 5 | import { 6 | AttachmentInput, 7 | ChatSyncCommand, 8 | ChatSyncCommandOutput, 9 | MessageUsefulness, 10 | MessageUsefulnessReason, 11 | PutFeedbackCommand, 12 | PutFeedbackCommandInput, 13 | PutFeedbackCommandOutput, 14 | QBusinessClient 15 | } from '@aws-sdk/client-qbusiness'; 16 | import { Credentials } from 'aws-sdk'; 17 | 18 | const logger = makeLogger('amazon-q-client'); 19 | 20 | export const getClient = ( 21 | env: SlackEventsEnv, 22 | slackUserId: string, 23 | iamSessionCreds: Credentials 24 | ) => { 25 | logger.debug(`Initiating AmazonQ client with region ${env.AMAZON_Q_REGION}`); 26 | 27 | const newClient = new QBusinessClient({ 28 | credentials: iamSessionCreds, 29 | region: env.AMAZON_Q_REGION 30 | }); 31 | 32 | return newClient; 33 | }; 34 | 35 | export const callClient = async ( 36 | slackUserId: string, 37 | message: string, 38 | attachments: AttachmentInput[], 39 | env: SlackEventsEnv, 40 | iamSessionCreds: Credentials, 41 | context?: { 42 | conversationId: string; 43 | parentMessageId: string; 44 | } 45 | ): Promise => { 46 | const input = { 47 | applicationId: env.AMAZON_Q_APP_ID, 48 | clientToken: uuid(), 49 | userMessage: message, 50 | ...(attachments.length > 0 && { attachments }), 51 | ...context 52 | }; 53 | 54 | logger.debug(`callClient input ${JSON.stringify(input)}`); 55 | return await getClient(env, slackUserId, iamSessionCreds).send(new ChatSyncCommand(input)); 56 | }; 57 | 58 | export const submitFeedbackRequest = async ( 59 | slackUserId: string, 60 | env: SlackInteractionsEnv, 61 | iamSessionCreds: Credentials, 62 | context: { 63 | conversationId: string; 64 | messageId: string; 65 | }, 66 | usefulness: MessageUsefulness, 67 | reason: MessageUsefulnessReason, 68 | submittedAt: string 69 | ): Promise => { 70 | const input: PutFeedbackCommandInput = { 71 | applicationId: env.AMAZON_Q_APP_ID, 72 | ...context, 73 | messageUsefulness: { 74 | usefulness: usefulness, 75 | reason: reason, 76 | // Slack ts format E.g. 1702282895.883219 77 | submittedAt: new Date(Number(submittedAt) * 1000) 78 | } 79 | }; 80 | 81 | logger.debug(`putFeedbackRequest input ${JSON.stringify(input)}`); 82 | const response = await getClient(env, slackUserId, iamSessionCreds).send( 83 | new PutFeedbackCommand(input) 84 | ); 85 | logger.debug(`putFeedbackRequest output ${JSON.stringify(response)}`); 86 | 87 | return response; 88 | }; 89 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /tst/mocks/mocks.ts: -------------------------------------------------------------------------------- 1 | import { Credentials, HttpResponse } from 'aws-sdk'; 2 | import amazonQValidResponse1TextTable from '@tst/mocks/amazon-q/valid-response-1.json'; 3 | import { getFeedbackBlocks, getResponseAsBlocks } from '@helpers/amazon-q/amazon-q-helpers'; 4 | import { ChatSyncCommandOutput, PutFeedbackCommandOutput } from '@aws-sdk/client-qbusiness'; 5 | 6 | export const MOCK_ENV = { 7 | SLACK_SECRET_NAME: 'SLACK_SECRET_NAME', 8 | REGION: 'REGION', 9 | AMAZON_Q_ENDPOINT: 'AMAZON_Q_ENDPOINT', 10 | AMAZON_Q_APP_ID: 'AMAZON_Q_APP_ID', 11 | AMAZON_Q_USER_ID: 'AMAZON_Q_USER_ID', 12 | CONTEXT_DAYS_TO_LIVE: 'N', 13 | CACHE_TABLE_NAME: `CACHE_TABLE_NAME`, 14 | AMAZON_Q_REGION: 'AMAZON_Q_REGION', 15 | MESSAGE_METADATA_TABLE_NAME: 'MESSAGE_METADATA_TABLE_NAME', 16 | OIDC_STATE_TABLE_NAME: 'OIDC_STATE_TABLE_NAME', 17 | IAM_SESSION_TABLE_NAME: 'IAM_SESSION_TABLE_NAME', 18 | OIDC_IDP_NAME: 'OIDC_IDP_NAME', 19 | OIDC_ISSUER_URL: 'OIDC_ISSUER_URL', 20 | OIDC_CLIENT_ID: 'OIDC_CLIENT_ID', 21 | OIDC_REDIRECT_URL: 'OIDC_REDIRECT_URL', 22 | KMS_KEY_ARN: 'KMS_KEY_ARN', 23 | OIDC_CLIENT_SECRET_NAME: 'OIDC_CLIENT_SECRET_NAME', 24 | Q_USER_API_ROLE_ARN: 'Q_USER_API_ROLE_ARN', 25 | GATEWAY_IDC_APP_ARN: 'GATEWAY_IDC_APP_ARN', 26 | AWS_IAM_IDC_REGION: 'AWS_IAM_IDC_REGION' 27 | }; 28 | 29 | /* eslint @typescript-eslint/no-explicit-any: "off" */ 30 | export const MOCK_AWS_RESPONSE = { 31 | $response: { 32 | hasNextPage: () => false, 33 | nextPage: () => ({}) as any, 34 | data: {}, 35 | error: undefined, 36 | requestId: 'requestId', 37 | redirectCount: 0, 38 | retryCount: 0, 39 | httpResponse: {} as HttpResponse 40 | } 41 | }; 42 | 43 | export const MOCK_DEPENDENCIES = { 44 | callClient: () => Promise.resolve(amazonQValidResponse1TextTable as ChatSyncCommandOutput), 45 | submitFeedbackRequest: () => Promise.resolve({} as PutFeedbackCommandOutput), 46 | deleteItem: async () => MOCK_AWS_RESPONSE, 47 | putItem: async () => MOCK_AWS_RESPONSE, 48 | validateSlackRequest: () => Promise.resolve(true), 49 | retrieveThreadHistory: () => 50 | Promise.resolve({ 51 | ok: true, 52 | messages: [] 53 | }), 54 | retrieveAttachment: () => Promise.resolve('mock attachment'), 55 | sendSlackMessage: () => Promise.resolve({} as any), 56 | updateSlackMessage: () => Promise.resolve({} as any), 57 | openModal: () => Promise.resolve({} as any), 58 | getResponseAsBlocks, 59 | getFeedbackBlocks, 60 | getUserInfo: () => 61 | Promise.resolve({ 62 | ok: true, 63 | user: { 64 | id: 'W012A3CDE', 65 | team_id: 'T012AB3C4', 66 | name: 'spengler', 67 | real_name: 'Gregory Spengler' 68 | } 69 | }), 70 | getItem: async () => 71 | Promise.resolve({ 72 | Item: undefined, 73 | ...MOCK_AWS_RESPONSE 74 | }), 75 | getSessionCreds: () => Promise.resolve({} as any), 76 | startSession: () => Promise.resolve({} as any) 77 | }; 78 | 79 | export const MOCK_IAM_SESSION_CREDS: Credentials = { 80 | accessKeyId: 'accessKeyId', 81 | secretAccessKey: 'secretAccessKey', 82 | sessionToken: 'sessionToken', 83 | expired: false, 84 | expireTime: new Date(), 85 | refreshPromise(): Promise { 86 | return Promise.resolve(); 87 | }, 88 | get: function (callback) { 89 | callback(undefined); 90 | }, 91 | getPromise: function () { 92 | return Promise.resolve(); 93 | }, 94 | needsRefresh: function () { 95 | return false; 96 | }, 97 | refresh: function (callback) { 98 | callback(undefined); 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /bin/environment.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # helper script called by $ROOT/init.sh - interactively create environment.json 5 | # 6 | 7 | echo "This script will interactively create or update environment.json" 8 | 9 | json_file="environment.json" 10 | 11 | prompt_for_value() { 12 | local value 13 | local existing_value 14 | local default_value 15 | local regex 16 | local message 17 | local var_name 18 | 19 | var_name=$1 20 | message=$2 21 | default_value=$3 22 | regex=$4 23 | existing_value=$(jq -r ".$var_name" "$json_file" 2> /dev/null) 24 | 25 | # override default_value with any existing value 26 | if [ -n "$existing_value" ] && [ "$existing_value" != "null" ]; then 27 | default_value=$existing_value 28 | fi 29 | 30 | # Prompt the user for input or to accept default. If default is "none" force user to enter a value 31 | while true; do 32 | 33 | read -p "$message [$default_value]: " value 34 | 35 | if [ -z "$value" ] && [ "$default_value" == "none" ]; then 36 | # user did not enter a value, but it's required 37 | echo "No default. Please enter a value." >&2 38 | elif [ -z "$value" ]; then 39 | # user did not enter a value, but it's not required.. use default 40 | value=$default_value 41 | break 42 | elif [[ $value =~ $regex ]]; then 43 | # value matches the regex - all good. 44 | break 45 | else 46 | # value does not match the regex.. ask user to try again 47 | echo "Value entered does not match required regex: $regex. Please enter a valid value." >&2 48 | fi 49 | done 50 | 51 | echo $value 52 | } 53 | 54 | # Read or update values 55 | stack_name=$(prompt_for_value "StackName" "Name for slack bot" "AmazonQBot" "^[A-Za-z][A-Za-z0-9-]{0,127}$") 56 | # From Bash 3 - testing with MacOS - m and n must be in the range from 0 to RE_DUP_MAX (default 255), inclusive. 57 | app_id=$(prompt_for_value "AmazonQAppId" "Amazon Q Application ID (copy from AWS console)" "none" "^[a-zA-Z0-9][a-zA-Z0-9-]{35}$") 58 | region=$(prompt_for_value "AmazonQRegion" "Amazon Q Region" $(aws configure get region) "^[a-z]{2}-[a-z]+-[0-9]+$") 59 | ttl_days=$(prompt_for_value "ContextDaysToLive" "Number of days to keep conversation context" "90" "^[1-9][0-9]{0,3}$") 60 | oidc_idp_name=$(prompt_for_value "OIDCIdPName" "Name of Identity Provider (Okta, Cognito, Other)" "Okta" "^[a-zA-Z]{1,255}$") 61 | oidc_client_id=$(prompt_for_value "OIDCClientId" "OIDC Client ID" "none" "^[a-zA-Z0-9_-]{1,255}$") 62 | oidc_issuer_url=$(prompt_for_value "OIDCIssuerURL" "OIDC Issuer URL" "none" "^https://[a-zA-Z0-9.-]+(:[0-9]+)?(/.*)?$") 63 | gateway_idc_app_arn=$(prompt_for_value "GatewayIdCAppARN" "Q Gateway IdC App Arn" "none" "^arn:aws[a-zA-Z-]*:[a-zA-Z0-9-]*:[a-z0-9-]*:[0-9]{12}:[a-zA-Z0-9:/._-]+$") 64 | aws_iam_idc_region=$(prompt_for_value "AWSIAMIdCRegion" "AWS IAM Identity Center Region" $(aws configure get region) "^[a-z]{2}-[a-z]+-[0-9]+$") 65 | 66 | # Create or update the JSON file 67 | cp $json_file $json_file.bak 2> /dev/null 68 | jq -n \ 69 | --arg stack_name "$stack_name" \ 70 | --arg app_id "$app_id" \ 71 | --arg region "$region" \ 72 | --arg endpoint "$endpoint" \ 73 | --arg ttl_days "$ttl_days" \ 74 | --arg oidc_idp_name "$oidc_idp_name" \ 75 | --arg oidc_client_id "$oidc_client_id" \ 76 | --arg oidc_issuer_url "$oidc_issuer_url" \ 77 | --arg gateway_idc_app_arn "$gateway_idc_app_arn" \ 78 | --arg aws_iam_idc_region "$aws_iam_idc_region" \ 79 | '{ 80 | StackName: $stack_name, 81 | AmazonQAppId: $app_id, 82 | AmazonQRegion: $region, 83 | ContextDaysToLive: $ttl_days, 84 | OIDCIdPName: $oidc_idp_name, 85 | OIDCClientId: $oidc_client_id, 86 | OIDCIssuerURL: $oidc_issuer_url, 87 | GatewayIdCAppARN: $gateway_idc_app_arn, 88 | AWSIAMIdCRegion: $aws_iam_idc_region 89 | }' > "$json_file" 90 | 91 | echo "Configuration saved to $json_file" 92 | -------------------------------------------------------------------------------- /src/helpers/chat.ts: -------------------------------------------------------------------------------- 1 | import { SlackEventsEnv } from '@functions/slack-event-handler'; 2 | import { Block } from '@slack/web-api'; 3 | import { callClient, submitFeedbackRequest } from '@helpers/amazon-q/amazon-q-client'; 4 | import { deleteItem, getItem, putItem } from '@helpers/dynamodb-client'; 5 | import { 6 | getUserInfo, 7 | retrieveThreadHistory, 8 | retrieveAttachment, 9 | sendSlackMessage, 10 | updateSlackMessage 11 | } from '@helpers/slack/slack-helpers'; 12 | import { getFeedbackBlocks, getResponseAsBlocks } from '@helpers/amazon-q/amazon-q-helpers'; 13 | import { ChatSyncCommandOutput } from '@aws-sdk/client-qbusiness'; 14 | 15 | export interface ChatResponse { 16 | systemMessage: string; 17 | } 18 | 19 | export const chatDependencies = { 20 | callClient, 21 | submitFeedbackRequest, 22 | deleteItem, 23 | getItem, 24 | putItem, 25 | sendSlackMessage, 26 | updateSlackMessage, 27 | getResponseAsBlocks, 28 | getFeedbackBlocks, 29 | retrieveThreadHistory, 30 | retrieveAttachment, 31 | getUserInfo 32 | }; 33 | 34 | export type ChatDependencies = typeof chatDependencies; 35 | 36 | export type getResponseAsBlocks = (response: ChatResponse) => Block[] | undefined; 37 | 38 | export const getChannelKey = ( 39 | type: 'message' | 'app_mention', 40 | team: string, 41 | channel: string, 42 | event_ts: string, 43 | thread_ts?: string 44 | ) => (type === 'message' ? `${team}:${channel}` : `${team}:${channel}:${thread_ts ?? event_ts}`); 45 | 46 | export const getChannelMetadata = async ( 47 | channel: string, 48 | dependencies: ChatDependencies, 49 | env: SlackEventsEnv 50 | ) => 51 | ( 52 | await dependencies.getItem({ 53 | TableName: env.CACHE_TABLE_NAME, 54 | Key: { 55 | channel: channel 56 | } 57 | }) 58 | ).Item; 59 | 60 | export const deleteChannelMetadata = async ( 61 | channel: string, 62 | dependencies: ChatDependencies, 63 | env: SlackEventsEnv 64 | ) => 65 | await dependencies.deleteItem({ 66 | TableName: env.CACHE_TABLE_NAME, 67 | Key: { 68 | channel 69 | } 70 | }); 71 | 72 | const expireAt = (env: SlackEventsEnv) => { 73 | const contextTTL = Number(env.CONTEXT_DAYS_TO_LIVE) * 24 * 60 * 60 * 1000; // milliseconds 74 | return Math.floor((Date.now() + contextTTL) / 1000); // Unix time (seconds); 75 | }; 76 | 77 | export const saveChannelMetadata = async ( 78 | channel: string, 79 | conversationId: string, 80 | systemMessageId: string, 81 | dependencies: ChatDependencies, 82 | env: SlackEventsEnv 83 | ) => { 84 | await dependencies.putItem({ 85 | TableName: env.CACHE_TABLE_NAME, 86 | Item: { 87 | channel, 88 | conversationId, 89 | systemMessageId, 90 | latestTs: Date.now(), 91 | expireAt: expireAt(env) 92 | } 93 | }); 94 | }; 95 | 96 | export const saveMessageMetadata = async ( 97 | amazonQResponse: ChatSyncCommandOutput, 98 | dependencies: ChatDependencies, 99 | env: SlackEventsEnv 100 | ) => { 101 | await dependencies.putItem({ 102 | TableName: env.MESSAGE_METADATA_TABLE_NAME, 103 | Item: { 104 | messageId: amazonQResponse.systemMessageId, 105 | conversationId: amazonQResponse.conversationId, 106 | sourceAttributions: amazonQResponse.sourceAttributions, 107 | systemMessageId: amazonQResponse.systemMessageId, 108 | userMessageId: amazonQResponse.userMessageId, 109 | ts: Date.now(), 110 | expireAt: expireAt(env) 111 | } 112 | }); 113 | }; 114 | 115 | export const getMessageMetadata = async ( 116 | systemMessageId: string, 117 | dependencies: ChatDependencies, 118 | env: SlackEventsEnv 119 | ) => 120 | ( 121 | await dependencies.getItem({ 122 | TableName: env.MESSAGE_METADATA_TABLE_NAME, 123 | Key: { 124 | messageId: systemMessageId 125 | } 126 | }) 127 | ).Item; 128 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.2.1] - 2024-09-27 10 | ### Added 11 | Add support for cross-region IAM Identity Center (IDC) where the IDC instance is in a different region from the Amazon Q Business application. The IDC region can now be specified when deploying the gateway. 12 | 13 | ## [0.2.0] - 2024-05-28 14 | ### Added 15 | Add support for Q Business Apps integrated with IdC 16 | - The gateway registers the Amazon Q Business Slack Gateway as an OpenID Connect (OIDC) app with Okta (or other OIDC compliant Identity Providers). 17 | - This registration allows the gateway to invoke the Q Business ChatSync API on behalf of the end-user. 18 | - The gateway provisions an OIDC callback handler for the Identity Provider (IdP) to return an authorization code after the end-user authenticates using the authorization grant flow. 19 | - The callback handler exchanges the authorization code for IAM session credentials through a series of interactions with the IdP, IdC, and AWS Security Token Service (STS). 20 | - The IAM session credentials, which are short-lived (15-minute duration), are encrypted and stored in a DynamoDB table along with the refresh token from the IdP. 21 | - The IAM session credentials are then used to invoke the Q Business ChatSync and PutFeedback APIs. 22 | - If the IAM session credentials expire, the refresh token from the IdP is used to obtain new IAM session credentials without requiring the end-user to sign in again. 23 | 24 | ## [0.1.3] - 2024-01-26 25 | ### Fixed 26 | - Merged #25 - adds flexibility to accomodate multiple slack applications with unique variations of the 27 | `/new_conversation` slash command. The command may be now customized to have any name starting with `/new_conv` - 28 | e.g. `/new_conv_appa`, `/new_conv_appb`, etc. 29 | 30 | 31 | ## [0.1.2] - 2024-01-11 32 | ### Fixed 33 | - Prebuild CloudFormation template region parameter default now matches template region for us-west-2 34 | - Merged #21 - Remove SDK JSON model injection in favor of @aws-sdk/client-qbusiness 35 | - Merged dependabot #22 - Bump follow-redirects from 1.15.3 to 1.15.4 36 | 37 | ## [0.1.1] - 2023-12-04 38 | ### Added 39 | - New 'Easy Button' option for deployment and update using pre-built CloudFormation templates (with no dependency on dev shell, cdk, etc.) - see [README - Deploy the stack](./README.md#1-deploy-the-stack). 40 | - New `publish.sh` script used to create and publish standalone CloudFormation templates in an S3 bucket - see [README_DEVELOPERS - Publish the solution](./README_DEVELOPERS.md#publish-the-solution). 41 | 42 | ## [0.1.0] - 2023-11-27 43 | ### Added 44 | Initial release 45 | - In DMs it responds to all messages 46 | - In channels it responds only to @mentions, and always replies in thread 47 | - Renders answers containing markdown - e.g. headings, lists, bold, italics, tables, etc. 48 | - Provides thumbs up / down buttons to track user sentiment and help improve performance over time 49 | - Provides Source Attribution - see references to sources used by Amazon Q 50 | - Aware of conversation context - it tracks the conversation and applies context 51 | - Aware of multiple users - when it's tagged in a thread, it knows who said what, and when - so it can contribute in context and accurately summarize the thread when asked. 52 | - Process up to 5 attached files for document question answering, summaries, etc. 53 | - Reset and start new conversation in DM channel by using `/new_conversation` 54 | 55 | [Unreleased]: https://github.com/aws-samples/qnabot-on-aws-plugin-samples/compare/main...develop 56 | [0.2.1]: https://github.com/aws-samples/qnabot-on-aws-plugin-samples/releases/tag/v0.2.1 57 | [0.2.0]: https://github.com/aws-samples/qnabot-on-aws-plugin-samples/releases/tag/v0.2.0 58 | [0.1.3]: https://github.com/aws-samples/qnabot-on-aws-plugin-samples/releases/tag/v0.1.3 59 | [0.1.2]: https://github.com/aws-samples/qnabot-on-aws-plugin-samples/releases/tag/v0.1.2 60 | [0.1.1]: https://github.com/aws-samples/qnabot-on-aws-plugin-samples/releases/tag/v0.1.1 61 | [0.1.0]: https://github.com/aws-samples/qnabot-on-aws-plugin-samples/releases/tag/v0.1.0 62 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ############################################################################################## 4 | # Create new Cfn artifacts bucket if not already existing 5 | # Build artifacts and template with CDK 6 | # Convert templates from CDK dependent to standalone 7 | # Upload artifacts to S3 bucket for deployment with CloudFormation 8 | ############################################################################################## 9 | 10 | # Stop the publish process on failures 11 | set -e 12 | 13 | USAGE="$0 [public]" 14 | 15 | BUCKET_BASENAME=$1 16 | [ -z "$BUCKET_BASENAME" ] && echo "Cfn bucket name is a required parameter. Usage $USAGE" && exit 1 17 | 18 | PREFIX=$2 19 | [ -z "$PREFIX" ] && echo "Prefix is a required parameter. Usage $USAGE" && exit 1 20 | 21 | REGION=$3 22 | [ -z "$REGION" ] && echo "Region is a required parameter. Usage $USAGE" && exit 1 23 | export AWS_DEFAULT_REGION=$REGION 24 | 25 | ACL=$4 26 | if [ "$ACL" == "public" ]; then 27 | echo "Published S3 artifacts will be acessible by public (read-only)" 28 | PUBLIC=true 29 | else 30 | echo "Published S3 artifacts will NOT be acessible by public." 31 | PUBLIC=false 32 | fi 33 | 34 | # Remove trailing slash from prefix if needed, and append VERSION 35 | VERSION=$(jq -r '.version' package.json) 36 | [[ "${PREFIX}" == */ ]] && PREFIX="${PREFIX%?}" 37 | PREFIX_AND_VERSION=${PREFIX}/${VERSION} 38 | echo $PREFIX_AND_VERSION 39 | 40 | # Append region to bucket basename 41 | BUCKET=${BUCKET_BASENAME}-${REGION} 42 | 43 | echo "Running precheck..." 44 | ./bin/precheck.sh 45 | 46 | # Create bucket if it doesn't already exist 47 | if [ -x $(aws s3api list-buckets --query 'Buckets[].Name' | grep "\"$BUCKET\"") ]; then 48 | echo "Creating s3 bucket: $BUCKET" 49 | aws s3 mb s3://${BUCKET} || exit 1 50 | aws s3api put-bucket-versioning --bucket ${BUCKET} --versioning-configuration Status=Enabled || exit 1 51 | else 52 | echo "Using existing bucket: $BUCKET" 53 | fi 54 | 55 | echo "Create temp environment file" 56 | envfile=/tmp/environment.json 57 | cat > /tmp/environment.json << _EOF 58 | { 59 | "StackName": "AQSG", 60 | "AmazonQAppId": "QAPPID", 61 | "AmazonQRegion": "QREGION", 62 | "AmazonQEndpoint": "QENDPOINT", 63 | "ContextDaysToLive": "QCONTEXTTTL", 64 | "OIDCIdPName": "QOIDCIDPNAME", 65 | "OIDCClientId": "QOIDCCLIENTID", 66 | "OIDCIssuerURL": "QOIDCISSUERURL", 67 | "GatewayIdCAppARN": "QGATEWAYIDCAPPARN", 68 | "AWSIAMIdCRegion": "AWSIAMIDCREGION" 69 | } 70 | _EOF 71 | 72 | echo "Running npm install and build..." 73 | npm install && npm run build 74 | 75 | echo "Running cdk bootstrap..." 76 | cdk bootstrap -c environment=$envfile 77 | 78 | echo "Running cdk synthesize to create artifacts and template" 79 | cdk synthesize --staging -c environment=$envfile > /dev/null 80 | 81 | echo "Converting and uploading Cfn artifacts to S3" 82 | CDKTEMPLATE="AmazonQSlackGatewayStack.template.json" 83 | echo node ./bin/convert-cfn-template.js $CDKTEMPLATE $BUCKET $PREFIX_AND_VERSION $REGION 84 | node ./bin/convert-cfn-template.js $CDKTEMPLATE $BUCKET $PREFIX_AND_VERSION $REGION 85 | 86 | MAIN_TEMPLATE="AmazonQSlackGateway.json" 87 | aws s3 cp s3://${BUCKET}/${PREFIX_AND_VERSION}/${CDKTEMPLATE} s3://${BUCKET}/${PREFIX}/${MAIN_TEMPLATE} || exit 1 88 | 89 | template="https://s3.${REGION}.amazonaws.com/${BUCKET}/${PREFIX}/${MAIN_TEMPLATE}" 90 | echo "Validating converted template: $template" 91 | aws cloudformation validate-template --template-url $template > /dev/null || exit 1 92 | 93 | if $PUBLIC; then 94 | echo "Setting public read ACLs on published artifacts" 95 | files=$(aws s3api list-objects --bucket ${BUCKET} --prefix ${PREFIX_AND_VERSION} --query "(Contents)[].[Key]" --output text) 96 | for file in $files 97 | do 98 | aws s3api put-object-acl --acl public-read --bucket ${BUCKET} --key $file --region us-east-1 99 | done 100 | aws s3api put-object-acl --acl public-read --bucket ${BUCKET} --key ${PREFIX}/${MAIN_TEMPLATE} --region us-east-1 101 | fi 102 | 103 | echo "OUTPUTS" 104 | echo Template URL: $template 105 | echo CF Launch URL: https://${REGION}.console.aws.amazon.com/cloudformation/home?region=${REGION}#/stacks/create/review?templateURL=${template}\&stackName=AMAZON-Q-SLACK-GATEWAY 106 | echo Done 107 | exit 0 108 | 109 | -------------------------------------------------------------------------------- /src/functions/slack-command-handler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyResult, Callback, Context } from 'aws-lambda'; 2 | import { getMarkdownBlock, validateSlackRequest } from '@helpers/slack/slack-helpers'; 3 | import { chatDependencies, deleteChannelMetadata, getChannelKey } from '@helpers/chat'; 4 | import { getOrThrowIfEmpty, isEmpty } from '@src/utils'; 5 | import { makeLogger } from '@src/logging'; 6 | 7 | const logger = makeLogger('slack-command-handler'); 8 | 9 | const processSlackEventsEnv = (env: NodeJS.ProcessEnv) => ({ 10 | REGION: getOrThrowIfEmpty(env.AWS_REGION ?? env.AWS_DEFAULT_REGION), 11 | SLACK_SECRET_NAME: getOrThrowIfEmpty(env.SLACK_SECRET_NAME), 12 | AMAZON_Q_APP_ID: getOrThrowIfEmpty(env.AMAZON_Q_APP_ID), 13 | AMAZON_Q_REGION: getOrThrowIfEmpty(env.AMAZON_Q_REGION), 14 | CONTEXT_DAYS_TO_LIVE: getOrThrowIfEmpty(env.CONTEXT_DAYS_TO_LIVE), 15 | CACHE_TABLE_NAME: getOrThrowIfEmpty(env.CACHE_TABLE_NAME), 16 | MESSAGE_METADATA_TABLE_NAME: getOrThrowIfEmpty(env.MESSAGE_METADATA_TABLE_NAME), 17 | OIDC_STATE_TABLE_NAME: getOrThrowIfEmpty(env.OIDC_STATE_TABLE_NAME), 18 | IAM_SESSION_TABLE_NAME: getOrThrowIfEmpty(env.IAM_SESSION_CREDENTIALS_TABLE_NAME), 19 | OIDC_IDP_NAME: getOrThrowIfEmpty(env.OIDC_IDP_NAME), 20 | OIDC_ISSUER_URL: getOrThrowIfEmpty(env.OIDC_ISSUER_URL), 21 | OIDC_CLIENT_ID: getOrThrowIfEmpty(env.OIDC_CLIENT_ID), 22 | OIDC_CLIENT_SECRET_NAME: getOrThrowIfEmpty(env.OIDC_CLIENT_SECRET_NAME), 23 | OIDC_REDIRECT_URL: getOrThrowIfEmpty(env.OIDC_REDIRECT_URL), 24 | KMS_KEY_ARN: getOrThrowIfEmpty(env.KEY_ARN), 25 | Q_USER_API_ROLE_ARN: getOrThrowIfEmpty(env.Q_USER_API_ROLE_ARN), 26 | GATEWAY_IDC_APP_ARN: getOrThrowIfEmpty(env.GATEWAY_IDC_APP_ARN), 27 | AWS_IAM_IDC_REGION: getOrThrowIfEmpty(env.AWS_IAM_IDC_REGION) 28 | }); 29 | 30 | export type SlackEventsEnv = ReturnType; 31 | 32 | export const handler = async ( 33 | event: { 34 | body: string; 35 | headers: { [key: string]: string | undefined }; 36 | }, 37 | _context: Context, 38 | _callback: Callback, 39 | dependencies = { 40 | ...chatDependencies, 41 | validateSlackRequest 42 | }, 43 | slackEventsEnv: SlackEventsEnv = processSlackEventsEnv(process.env) 44 | ): Promise => { 45 | logger.debug(`Received event: ${JSON.stringify(event)}`); 46 | 47 | logger.debug(`dependencies ${JSON.stringify(dependencies)}`); 48 | if (isEmpty(event.body)) { 49 | return { 50 | statusCode: 400, 51 | body: JSON.stringify({ 52 | error: 'Bad request' 53 | }) 54 | }; 55 | } 56 | 57 | // You would want to ensure that this method is always here before you start parsing the request 58 | // For extra safety it is recommended to have a Synthetic test (aka Canary) via AWS that will 59 | // Call this method with an invalid signature and verify that the status code is 403 60 | // You can define a CDK construct for it. 61 | if (!(await dependencies.validateSlackRequest(event.headers, event.body, slackEventsEnv))) { 62 | logger.warn(`Invalid request`); 63 | return { 64 | statusCode: 403, 65 | body: JSON.stringify({ 66 | error: 'Forbidden' 67 | }) 68 | }; 69 | } 70 | 71 | // body is an url encoded string for slash commands. 72 | const body = event.body.split('&').reduce( 73 | (obj, pair) => { 74 | const [key, value] = pair.split('=').map(decodeURIComponent); 75 | obj[key] = value; 76 | return obj; 77 | }, 78 | {} as Record 79 | ); 80 | logger.debug(`Received slash command body ${JSON.stringify(body)}`); 81 | 82 | let commandStatus; 83 | if (body.command.startsWith('/new_conv')) { 84 | const channelKey = getChannelKey('message', body.team_id, body.channel_id, 'n/a'); 85 | logger.debug(`Slash command: ${body.command} - deleting channel metadata for '${channelKey}'`); 86 | await deleteChannelMetadata(channelKey, dependencies, slackEventsEnv); 87 | await dependencies.sendSlackMessage( 88 | slackEventsEnv, 89 | body.channel_id, 90 | `Starting New Conversation`, 91 | [getMarkdownBlock(`_*Starting New Conversation*_`)] 92 | ); 93 | commandStatus = 'OK'; 94 | } else { 95 | logger.error(`ERROR - unsupported slash command: ${body.command}`); 96 | commandStatus = 'Unsupported'; 97 | } 98 | return { 99 | statusCode: 200, 100 | body: `${body.command} - ${commandStatus}` 101 | }; 102 | }; 103 | -------------------------------------------------------------------------------- /bin/create-idc-application.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Ensure required arguments are passed 4 | if [ $# -lt 2 ]; then 5 | echo "Usage: $0 [application_name]" 6 | exit 1 7 | else 8 | OIDC_CLIENT_ID="$1" 9 | TTI_ARN="$2" 10 | IDC_REGION="$3" 11 | if [ -n "$4" ]; then 12 | APPLICATION_NAME="$4" 13 | else 14 | APPLICATION_NAME="AmazonQSlackGateway" 15 | fi 16 | fi 17 | 18 | # Retrieve AWS Account ID 19 | AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text) 20 | if [ $? -ne 0 ]; then 21 | echo "Failed to retrieve AWS account ID." 22 | exit 1 23 | fi 24 | 25 | # Retrieve the IdC instance ARN. 26 | IDC_INSTANCE_ARN=$(aws sso-admin list-instances --region $IDC_REGION --query 'Instances[0].InstanceArn' | tr -d '""') 27 | if [ $? -ne 0 ] || [ -z "$IDC_INSTANCE_ARN" ]; then 28 | echo "Error: IDC_INSTANCE_ARN is empty or failed to retrieve. Please check your AWS SSO configuration." 29 | exit 1 30 | fi 31 | 32 | # Check if the application already exists 33 | echo "Checking if the $APPLICATION_NAME exists..." 34 | APPLICATION_EXISTS=0 35 | GATEWAY_IDC_ARN="" 36 | RESPONSE=$(aws sso-admin list-applications --instance-arn $IDC_INSTANCE_ARN --region $IDC_REGION --query 'Applications[*].ApplicationArn' | tr -d '[",]') 37 | for ARN in $RESPONSE; do 38 | CURRENT_NAME=$(aws sso-admin describe-application --application-arn $ARN --region $IDC_REGION --query 'Name' | tr -d '"') 39 | if [ "$CURRENT_NAME" == "$APPLICATION_NAME" ]; then 40 | GATEWAY_IDC_ARN=$ARN 41 | APPLICATION_EXISTS=1 42 | echo "$APPLICATION_NAME already exists with GATEWAY_IDC_ARN: $GATEWAY_IDC_ARN" 43 | break 44 | fi 45 | done 46 | 47 | # Create the application if it does not exist 48 | CUSTOM_APPLICATION_PROVIDER_ARN="arn:aws:sso::aws:applicationProvider/custom" 49 | if [ $APPLICATION_EXISTS -eq 0 ]; then 50 | echo "Creating $APPLICATION_NAME..." 51 | GATEWAY_IDC_ARN=$(aws sso-admin create-application --application-provider-arn $CUSTOM_APPLICATION_PROVIDER_ARN --instance-arn $IDC_INSTANCE_ARN --name "$APPLICATION_NAME" --region $IDC_REGION --query 'ApplicationArn' | tr -d '"') 52 | if [ $? -ne 0 ] || [ -z "$GATEWAY_IDC_ARN" ]; then 53 | echo "Error: GATEWAY_IDC_ARN could not be created. Please check your inputs and AWS permissions." 54 | exit 1 55 | fi 56 | echo "Created GATEWAY_IDC_ARN: $GATEWAY_IDC_ARN" 57 | fi 58 | 59 | # Disable assignment 60 | aws sso-admin put-application-assignment-configuration --application-arn $GATEWAY_IDC_ARN --region $IDC_REGION --no-assignment-required 61 | if [ $? -ne 0 ]; then 62 | echo "Failed to disable assignment for the application." 63 | exit 1 64 | fi 65 | 66 | # Put grant 67 | json_input='{ 68 | "ApplicationArn": "'$GATEWAY_IDC_ARN'", 69 | "Grant": { 70 | "JwtBearer": { 71 | "AuthorizedTokenIssuers": [ 72 | { 73 | "AuthorizedAudiences": [ 74 | "'$OIDC_CLIENT_ID'" 75 | ], 76 | "TrustedTokenIssuerArn": "'$TTI_ARN'" 77 | } 78 | ] 79 | } 80 | }, 81 | "GrantType": "urn:ietf:params:oauth:grant-type:jwt-bearer" 82 | }' 83 | aws sso-admin put-application-grant --region $IDC_REGION --cli-input-json "$json_input" 84 | if [ $? -ne 0 ]; then 85 | echo "Failed to put application grant." 86 | exit 1 87 | fi 88 | 89 | # Put application authentication method 90 | json_input='{ 91 | "ApplicationArn": "'$GATEWAY_IDC_ARN'", 92 | "AuthenticationMethod": { 93 | "Iam": { 94 | "ActorPolicy": { 95 | "Version": "2012-10-17", 96 | "Statement": [ 97 | { 98 | "Effect": "Allow", 99 | "Principal": { 100 | "AWS": "'$AWS_ACCOUNT_ID'" 101 | }, 102 | "Action": "sso-oauth:CreateTokenWithIAM", 103 | "Resource": "*" 104 | } 105 | ] 106 | } 107 | } 108 | }, 109 | "AuthenticationMethodType": "IAM" 110 | }' 111 | aws sso-admin put-application-authentication-method --region $IDC_REGION --cli-input-json "$json_input" 112 | if [ $? -ne 0 ]; then 113 | echo "Failed to set authentication method." 114 | exit 1 115 | fi 116 | 117 | # Put application access scopes 118 | if ! aws sso-admin put-application-access-scope --application-arn $GATEWAY_IDC_ARN --scope "qbusiness:conversations:access" --region $IDC_REGION; then 119 | echo "Failed to set access scope for conversations." 120 | exit 1 121 | fi 122 | 123 | # Echo GATEWAY_IDC_ARN at the end 124 | echo "$APPLICATION_NAME is setup with GATEWAY_IDC_ARN: $GATEWAY_IDC_ARN" 125 | -------------------------------------------------------------------------------- /src/functions/oidc-callback-handler.ts: -------------------------------------------------------------------------------- 1 | import { getOrThrowIfEmpty } from '@src/utils'; 2 | import { makeLogger } from '@src/logging'; 3 | import { finishSession, SessionManagerEnv } from '@helpers/idc/session-helpers'; 4 | import { APIGatewayProxyEvent, APIGatewayProxyResult, Callback, Context } from 'aws-lambda'; 5 | import { CloudFormation } from 'aws-sdk'; 6 | 7 | const logger = makeLogger('oidc-callback-handler'); 8 | 9 | const processOIDCCallbackEventEnv = (env: NodeJS.ProcessEnv) => ({ 10 | CFN_STACK_NAME: getOrThrowIfEmpty(env.CFN_STACK_NAME), 11 | CALLBACK_API_ENDPOINT_EXPORTED_NAME: getOrThrowIfEmpty(env.CALLBACK_API_ENDPOINT_EXPORTED_NAME), 12 | AMAZON_Q_REGION: getOrThrowIfEmpty(env.AMAZON_Q_REGION), 13 | OIDC_STATE_TABLE_NAME: getOrThrowIfEmpty(env.OIDC_STATE_TABLE_NAME), 14 | IAM_SESSION_TABLE_NAME: getOrThrowIfEmpty(env.IAM_SESSION_CREDENTIALS_TABLE_NAME), 15 | OIDC_IDP_NAME: getOrThrowIfEmpty(env.OIDC_IDP_NAME), 16 | OIDC_ISSUER_URL: getOrThrowIfEmpty(env.OIDC_ISSUER_URL), 17 | OIDC_CLIENT_ID: getOrThrowIfEmpty(env.OIDC_CLIENT_ID), 18 | OIDC_CLIENT_SECRET_NAME: getOrThrowIfEmpty(env.OIDC_CLIENT_SECRET_NAME), 19 | KMS_KEY_ARN: getOrThrowIfEmpty(env.KEY_ARN), 20 | Q_USER_API_ROLE_ARN: getOrThrowIfEmpty(env.Q_USER_API_ROLE_ARN), 21 | GATEWAY_IDC_APP_ARN: getOrThrowIfEmpty(env.GATEWAY_IDC_APP_ARN), 22 | AWS_IAM_IDC_REGION: getOrThrowIfEmpty(env.AWS_IAM_IDC_REGION) 23 | }); 24 | 25 | export const handler = async ( 26 | event: APIGatewayProxyEvent, 27 | _context: Context, 28 | _callback: Callback, 29 | dependencies = { 30 | finishSession 31 | }, 32 | oidcCallbackEventEnv: ReturnType< 33 | typeof processOIDCCallbackEventEnv 34 | > = processOIDCCallbackEventEnv(process.env) 35 | ): Promise => { 36 | logger.debug(`Received GET request query parameters: ${JSON.stringify(event)}`); 37 | logger.debug(`env: ${JSON.stringify(oidcCallbackEventEnv)}`); 38 | 39 | const oidcRedirectURL = await getExportedValue( 40 | oidcCallbackEventEnv.CFN_STACK_NAME, 41 | oidcCallbackEventEnv.CALLBACK_API_ENDPOINT_EXPORTED_NAME 42 | ); 43 | 44 | const sessionManagerEnv: SessionManagerEnv = { 45 | oidcStateTableName: oidcCallbackEventEnv.OIDC_STATE_TABLE_NAME, 46 | iamSessionCredentialsTableName: oidcCallbackEventEnv.IAM_SESSION_TABLE_NAME, 47 | oidcIdPName: oidcCallbackEventEnv.OIDC_IDP_NAME, 48 | oidcClientId: oidcCallbackEventEnv.OIDC_CLIENT_ID, 49 | oidcClientSecretName: oidcCallbackEventEnv.OIDC_CLIENT_SECRET_NAME, 50 | oidcIssuerUrl: oidcCallbackEventEnv.OIDC_ISSUER_URL, 51 | kmsKeyArn: oidcCallbackEventEnv.KMS_KEY_ARN, 52 | region: oidcCallbackEventEnv.AMAZON_Q_REGION, 53 | qUserAPIRoleArn: oidcCallbackEventEnv.Q_USER_API_ROLE_ARN, 54 | gatewayIdCAppArn: oidcCallbackEventEnv.GATEWAY_IDC_APP_ARN, 55 | awsIAMIdCRegion: oidcCallbackEventEnv.AWS_IAM_IDC_REGION, 56 | oidcRedirectUrl: oidcRedirectURL 57 | }; 58 | 59 | const queryStringParameters = event.queryStringParameters ?? {}; 60 | if (!queryStringParameters.code || !queryStringParameters.state) { 61 | return { 62 | statusCode: 400, 63 | body: JSON.stringify({ 64 | message: 'Invalid request' 65 | }) 66 | }; 67 | } 68 | 69 | logger.info(`Invoking finish session with oidc redirect url ${oidcRedirectURL}`); 70 | try { 71 | await dependencies.finishSession( 72 | sessionManagerEnv, 73 | queryStringParameters.code, 74 | queryStringParameters.state 75 | ); 76 | 77 | return { 78 | headers: { 79 | 'Content-Type': 'text/plain' 80 | }, 81 | statusCode: 200, 82 | body: 'Authentication successful. You can close this window and return to Slack.' 83 | }; 84 | } catch (error) { 85 | logger.error(`Error finishing session: ${error}`); 86 | return { 87 | headers: { 88 | 'Content-Type': 'text/plain' 89 | }, 90 | statusCode: 500, 91 | body: 'Internal server error' 92 | }; 93 | } 94 | }; 95 | 96 | const getExportedValue = async (stackName: string, exportedName: string): Promise => { 97 | const cloudFormation = new CloudFormation(); 98 | let nextToken: string | undefined; 99 | let exportedValue: string | undefined; 100 | 101 | do { 102 | const listExportsResponse = await cloudFormation 103 | .listExports({ NextToken: nextToken }) 104 | .promise(); 105 | 106 | const foundExport = listExportsResponse.Exports?.find( 107 | (exp) => exp && exp.Name === exportedName 108 | ); 109 | 110 | if (foundExport) { 111 | exportedValue = foundExport.Value; 112 | break; 113 | } 114 | 115 | nextToken = listExportsResponse.NextToken; 116 | } while (nextToken); 117 | 118 | return getOrThrowIfEmpty( 119 | exportedValue, 120 | `Exported value for ${exportedName} in stack ${stackName} is empty` 121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /tst/helpers/amazon-q/amazon-q-helpers.test.ts: -------------------------------------------------------------------------------- 1 | import amazonQValidResponse1 from '@tst/mocks/amazon-q/valid-response-1.json'; 2 | import amazonQValidResponse2 from '@tst/mocks/amazon-q/valid-response-2.json'; 3 | import { MOCK_DEPENDENCIES, MOCK_ENV, MOCK_IAM_SESSION_CREDS } from '@tst/mocks/mocks'; 4 | 5 | import { 6 | chat, 7 | getResponseAsBlocks, 8 | getTable, 9 | getTablePrefix, 10 | hasTable, 11 | parseTable 12 | } from '@helpers/amazon-q/amazon-q-helpers'; 13 | import { ChatSyncCommandOutput } from '@aws-sdk/client-qbusiness'; 14 | 15 | describe('AmazonQ helpers test', () => { 16 | test('Should get a response as block with context', async () => { 17 | const response = await chat('slackUserId', 'message', [], MOCK_DEPENDENCIES, MOCK_ENV, MOCK_IAM_SESSION_CREDS); 18 | expect(response).toEqual(amazonQValidResponse1); 19 | }); 20 | 21 | test('Test response markdown conversion', async () => { 22 | const response = getResponseAsBlocks(amazonQValidResponse1 as ChatSyncCommandOutput); 23 | expect(response).toEqual([ 24 | { 25 | text: { 26 | text: '*The Pillars of the Well Architected Framework*\n\n*Name:* Operational Excellence\n*Description:* The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures.\n*Name:* Security\n*Description:* The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies.\n*Name:* Reliability\n*Description:* The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues.\n*Name:* Performance Efficiency\n*Description:* The ability to use computing resources efficiently to meet system requirements, and to maintain that efficiency as demand changes and technologies evolve.\n*Name:* Cost Optimization\n*Description:* The ability to run systems to deliver business value at the lowest price point.\n', 27 | type: 'mrkdwn' 28 | }, 29 | type: 'section' 30 | }, 31 | { 32 | elements: [ 33 | { 34 | action_id: 'VIEW_SOURCES', 35 | style: 'primary', 36 | text: { 37 | emoji: true, 38 | text: 'View source(s)', 39 | type: 'plain_text' 40 | }, 41 | type: 'button', 42 | value: 'e5a23752-3f31-4fee-83fe-56fbd7803540' 43 | } 44 | ], 45 | type: 'actions' 46 | } 47 | ]); 48 | 49 | const response2 = getResponseAsBlocks(amazonQValidResponse2); 50 | expect(response2).toEqual([ 51 | { 52 | text: { 53 | text: 'This is a simple text\n and now with a \n*header*\n*another header*', 54 | type: 'mrkdwn' 55 | }, 56 | type: 'section' 57 | } 58 | ]); 59 | }); 60 | 61 | test('Test table markdown', async () => { 62 | const prefix = getTablePrefix(amazonQValidResponse1.systemMessage); 63 | expect(hasTable(amazonQValidResponse1.systemMessage)).toBeTruthy(); 64 | 65 | const table = getTable(amazonQValidResponse1.systemMessage); 66 | const parsedTable = parseTable(table); 67 | 68 | expect(prefix).toEqual('# The Pillars of the Well Architected Framework'); 69 | expect(table).toEqual( 70 | '|Name | Description|\n|:--|:--| \n|Operational Excellence| The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures.|\n|Security|The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies.| \n|Reliability| The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues.|\n|Performance Efficiency| The ability to use computing resources efficiently to meet system requirements, and to maintain that efficiency as demand changes and technologies evolve.|\n|Cost Optimization| The ability to run systems to deliver business value at the lowest price point.|' 71 | ); 72 | expect(parsedTable).toEqual( 73 | '*Name:* Operational Excellence\n' + 74 | '*Description:* The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures.\n' + 75 | '*Name:* Security\n' + 76 | '*Description:* The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies.\n' + 77 | '*Name:* Reliability\n' + 78 | '*Description:* The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues.\n' + 79 | '*Name:* Performance Efficiency\n' + 80 | '*Description:* The ability to use computing resources efficiently to meet system requirements, and to maintain that efficiency as demand changes and technologies evolve.\n' + 81 | '*Name:* Cost Optimization\n' + 82 | '*Description:* The ability to run systems to deliver business value at the lowest price point.\n' 83 | ); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/helpers/amazon-q/amazon-q-helpers.ts: -------------------------------------------------------------------------------- 1 | import { SlackEventsEnv } from '@functions/slack-event-handler'; 2 | import { createButton, getMarkdownBlocks, SLACK_ACTION } from '@helpers/slack/slack-helpers'; 3 | import { makeLogger } from '@src/logging'; 4 | import { isEmpty } from '@src/utils'; 5 | import { ChatDependencies } from '@src/helpers/chat'; 6 | import { Block } from '@slack/web-api'; 7 | import { ChatSyncCommandOutput, AttachmentInput } from '@aws-sdk/client-qbusiness'; 8 | import { Credentials } from 'aws-sdk'; 9 | import { ExpiredTokenException } from '@aws-sdk/client-sts'; 10 | 11 | const logger = makeLogger('amazon-q-helpers'); 12 | 13 | // Member must have length less than or equal to 7000 14 | const AMAZON_Q_MSG_LIMIT = 7000; 15 | const WARN_TRUNCATED = `| Please note that you do not have all the conversation history due to limitation`; 16 | 17 | export const chat = async ( 18 | slackUserId: string, 19 | incomingMessage: string, 20 | attachments: AttachmentInput[], 21 | dependencies: ChatDependencies, 22 | env: SlackEventsEnv, 23 | iamSessionCreds: Credentials, 24 | context?: { 25 | conversationId: string; 26 | parentMessageId: string; 27 | } 28 | ): Promise => { 29 | try { 30 | // Enforce max input message limit - may cause undesired side effects 31 | // TODO consider 'smarter' truncating of number of chat history messages, etc. rather 32 | // than simple input string truncation which may corrupt JSON formatting of message history 33 | const inputMessage = 34 | incomingMessage.length > AMAZON_Q_MSG_LIMIT 35 | ? incomingMessage.slice( 36 | incomingMessage.length + WARN_TRUNCATED.length - AMAZON_Q_MSG_LIMIT 37 | ) + WARN_TRUNCATED 38 | : incomingMessage; 39 | 40 | const response = await dependencies.callClient( 41 | slackUserId, 42 | inputMessage, 43 | attachments, 44 | env, 45 | iamSessionCreds, 46 | context 47 | ); 48 | logger.debug(`AmazonQ chatSync response: ${JSON.stringify(response)}`); 49 | return response; 50 | } catch (error) { 51 | logger.error(`Caught Exception: ${JSON.stringify(error)}`); 52 | if (error instanceof Error) { 53 | logger.debug(error.stack); 54 | if (error instanceof ExpiredTokenException) { 55 | logger.error(`Token expired: ${error.message}`); 56 | } 57 | return new Error(error.message); 58 | } else { 59 | return new Error(`${JSON.stringify(error)}`); 60 | } 61 | } 62 | }; 63 | 64 | export const getResponseAsBlocks = (response: ChatSyncCommandOutput) => { 65 | if (isEmpty(response.systemMessage)) { 66 | return []; 67 | } 68 | 69 | const content = response.systemMessage; 70 | 71 | return [ 72 | ...(!hasTable(content) 73 | ? getMarkdownBlocks(convertHN(content)) 74 | : getMarkdownBlocks( 75 | `${convertHN(getTablePrefix(content))}\n\n${parseTable(getTable(content))}` 76 | )), 77 | ...(!isEmpty(response.sourceAttributions) 78 | ? [createButton('View source(s)', response.systemMessageId ?? '')] 79 | : []) 80 | ]; 81 | }; 82 | 83 | export const getFeedbackBlocks = (response: ChatSyncCommandOutput): Block[] => [ 84 | { 85 | type: 'actions', 86 | block_id: `feedback-${response.conversationId}-${response.systemMessageId}`, 87 | elements: [ 88 | { 89 | type: 'button', 90 | text: { 91 | type: 'plain_text', 92 | emoji: true, 93 | text: ':thumbsup:' 94 | }, 95 | style: 'primary', 96 | action_id: SLACK_ACTION[SLACK_ACTION.FEEDBACK_UP], 97 | value: response.systemMessageId 98 | }, 99 | { 100 | type: 'button', 101 | text: { 102 | type: 'plain_text', 103 | emoji: true, 104 | text: ':thumbsdown:' 105 | }, 106 | style: 'danger', 107 | action_id: SLACK_ACTION[SLACK_ACTION.FEEDBACK_DOWN], 108 | value: response.systemMessageId 109 | } 110 | ] 111 | } as Block 112 | ]; 113 | 114 | export const getSignInBlocks = (authorizationURL: string): Block[] => [ 115 | { 116 | type: 'actions', 117 | block_id: `sign-in`, 118 | elements: [ 119 | { 120 | type: 'button', 121 | text: { 122 | type: 'plain_text', 123 | emoji: true, 124 | text: 'Sign in to Amazon Q' 125 | }, 126 | style: 'primary', 127 | action_id: SLACK_ACTION[SLACK_ACTION.SIGN_IN], 128 | url: authorizationURL 129 | } 130 | ] 131 | } as Block 132 | ]; 133 | /** 134 | * I am not very happy about the following lines, but it is being covered by unit testing 135 | * I did not find any libraries that would parse a Markdown table to a slack block kit 136 | */ 137 | 138 | export const getTablePrefix = (content: string) => 139 | content.substring(0, content.indexOf('|')).trim(); 140 | export const hasTable = (content: string) => 141 | content.indexOf('|') >= 0 && content.split('|').find((l) => isHeaderDelimiter(l)) !== undefined; 142 | export const isHeaderDelimiter = (line: string) => 143 | line === ':--' || line === ':-:' || line === '--:' || line === '---' || line === '-'; 144 | 145 | export const getTable = (content: string) => 146 | content.indexOf('|') >= 0 && content.split('|').length > 4 147 | ? content.substring(content.indexOf('|'), content.lastIndexOf('|') + 1) 148 | : ''; 149 | 150 | export const parseTable = (table: string) => { 151 | const getRow = (row: string) => 152 | row 153 | .split('|') 154 | .filter((e) => !isEmpty(e.trim())) 155 | .map((e) => e.trim()); 156 | 157 | const t = table.split('\n'); 158 | const header = t[0]; 159 | const columnNames = getRow(header); 160 | const content = t.slice(2, t.length); 161 | 162 | const textElements = []; 163 | for (const row of content.map((e) => getRow(e))) { 164 | for (let i = 0; i < row.length; i++) { 165 | textElements.push(`*${columnNames[i]}:* ${row[i]}\n`); 166 | } 167 | } 168 | 169 | return textElements.join(''); 170 | }; 171 | 172 | export const isHN = (line: string) => line.startsWith('#'); 173 | export const removeHN = (line: string) => 174 | line.substring(line.lastIndexOf('#') + 1, line.length).trim(); 175 | 176 | export const convertHN = (linesAsString: string) => 177 | linesAsString 178 | .split('\n') 179 | .map((l) => (isHN(l) ? `*${removeHN(l)}*` : l)) 180 | .join('\n'); 181 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { pathsToModuleNameMapper } = require('ts-jest'); 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | const { compilerOptions } = require('./tsconfig'); 5 | 6 | /* 7 | * For a detailed explanation regarding each configuration property and type check, visit: 8 | * https://jestjs.io/docs/configuration 9 | */ 10 | 11 | export default { 12 | // All imported modules in your tests should be mocked automatically 13 | // automock: false, 14 | 15 | // Stop running tests after `n` failures 16 | // bail: 0, 17 | 18 | // The directory where Jest should store its cached dependency information 19 | // cacheDirectory: "/private/var/folders/8q/mvn2434d0791ss_4m5nh0yjr0000gr/T/jest_e0", 20 | 21 | // Automatically clear mock calls, instances, contexts and results before every test 22 | clearMocks: true, 23 | 24 | // Indicates whether the coverage information should be collected while executing the test 25 | collectCoverage: true, 26 | 27 | // An array of glob patterns indicating a set of files for which coverage information should be collected 28 | // collectCoverageFrom: undefined, 29 | 30 | // The directory where Jest should output its coverage files 31 | coverageDirectory: './build/coverage', 32 | 33 | // An array of regexp pattern strings used to skip coverage collection 34 | // coveragePathIgnorePatterns: [ 35 | // "/node_modules/" 36 | // ], 37 | 38 | // Indicates which provider should be used to instrument code for coverage 39 | coverageProvider: 'v8', 40 | 41 | // A list of reporter names that Jest uses when writing coverage reports 42 | coverageReporters: ['json', 'json-summary', 'cobertura', 'text', 'clover', 'html'], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // The default configuration for fake timers 54 | // fakeTimers: { 55 | // "enableGlobally": false 56 | // }, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | // moduleFileExtensions: [ 80 | // "js", 81 | // "mjs", 82 | // "cjs", 83 | // "jsx", 84 | // "ts", 85 | // "tsx", 86 | // "json", 87 | // "node" 88 | // ], 89 | 90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths), 94 | modulePaths: [''], 95 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 96 | // modulePathIgnorePatterns: [], 97 | 98 | // Activates notifications for test results 99 | // notify: false, 100 | 101 | // An enum that specifies notification mode. Requires { notify: true } 102 | // notifyMode: "failure-change", 103 | 104 | // A preset that is used as a base for Jest's configuration 105 | preset: 'ts-jest', 106 | 107 | // Run tests from one or more projects 108 | // projects: undefined, 109 | 110 | // Use this configuration option to add custom reporters to Jest 111 | // reporters: [ 112 | // 'default', 113 | // ['@amzn/jest-reporter', { language: 'typescript' }], 114 | // ], 115 | 116 | // Automatically reset mock state before every test 117 | // resetMocks: false, 118 | 119 | // Reset the module registry before running each individual test 120 | // resetModules: false, 121 | 122 | // A path to a custom resolver 123 | // resolver: undefined, 124 | 125 | // Automatically restore mock state and implementation before every test 126 | // restoreMocks: false, 127 | 128 | // The root directory that Jest should scan for tests and modules within 129 | // rootDir: undefined, 130 | 131 | // A list of paths to directories that Jest should use to search for files in 132 | roots: ['tst'] 133 | 134 | // Allows you to use a custom runner instead of Jest's default test runner 135 | // runner: "jest-runner", 136 | 137 | // The paths to modules that run some code to configure or set up the testing environment before each test 138 | // setupFiles: [], 139 | 140 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 141 | // setupFilesAfterEnv: [], 142 | 143 | // The number of seconds after which a test is considered as slow and reported as such in the results. 144 | // slowTestThreshold: 5, 145 | 146 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 147 | // snapshotSerializers: [], 148 | 149 | // The test environment that will be used for testing 150 | // testEnvironment: "jest-environment-node", 151 | 152 | // Options that will be passed to the testEnvironment 153 | // testEnvironmentOptions: {}, 154 | 155 | // Adds a location field to test results 156 | // testLocationInResults: false, 157 | 158 | // The glob patterns Jest uses to detect test files 159 | // testMatch: [ 160 | // "**/__tests__/**/*.[jt]s?(x)", 161 | // "**/?(*.)+(spec|test).[tj]s?(x)" 162 | // ], 163 | 164 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 165 | // testPathIgnorePatterns: [ 166 | // "/node_modules/" 167 | // ], 168 | 169 | // The regexp pattern or array of patterns that Jest uses to detect test files 170 | // testRegex: [], 171 | 172 | // This option allows the use of a custom results processor 173 | // testResultsProcessor: undefined, 174 | 175 | // This option allows use of a custom test runner 176 | // testRunner: "jest-circus/runner", 177 | 178 | // A map from regular expressions to paths to transformers 179 | // transform: undefined, 180 | 181 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 182 | // transformIgnorePatterns: [ 183 | // "/node_modules/", 184 | // "\\.pnp\\.[^\\/]+$" 185 | // ], 186 | 187 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 188 | // unmockedModulePathPatterns: undefined, 189 | 190 | // Indicates whether each individual test should be reported during the run 191 | // verbose: undefined, 192 | 193 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 194 | // watchPathIgnorePatterns: [], 195 | 196 | // Whether to use watchman for file crawling 197 | // watchman: true, 198 | }; 199 | -------------------------------------------------------------------------------- /src/helpers/slack/slack-helpers.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { createHmac, timingSafeEqual } from 'crypto'; 3 | import { SecretsManager } from 'aws-sdk'; 4 | import { Block, ChatPostMessageResponse, ModalView, WebClient } from '@slack/web-api'; 5 | import { SlackEventsEnv } from '@functions/slack-event-handler'; 6 | import { SlackInteractionsEnv } from '@functions/slack-interaction-handler'; 7 | import { makeLogger } from '@src/logging'; 8 | import { isEmpty } from '@src/utils'; 9 | import { SourceAttributions } from 'aws-sdk/clients/qbusiness'; 10 | 11 | const logger = makeLogger('slack-helpers'); 12 | 13 | let secretManagerClient: SecretsManager | null = null; 14 | 15 | export const ERROR_MSG = '*_Processing error_*'; 16 | const getSecretManagerClient = (env: SlackInteractionsEnv | SlackEventsEnv) => { 17 | if (secretManagerClient === null) { 18 | secretManagerClient = new SecretsManager({ region: env.REGION }); 19 | } 20 | 21 | return secretManagerClient; 22 | }; 23 | 24 | export interface Secret { 25 | SlackClientId: string; 26 | SlackClientSecret: string; 27 | SlackBotUserOAuthToken: string; 28 | SlackBotUserRefreshToken?: string; 29 | SlackSigningSecret: string; 30 | } 31 | 32 | export const getUserInfo = async (env: SlackInteractionsEnv | SlackEventsEnv, user: string) => { 33 | const response = await ( 34 | await getSlackClient(env) 35 | ).users.info({ 36 | user 37 | }); 38 | 39 | logger.debug(`getUsersInfo: ${JSON.stringify(response)}`); 40 | 41 | return response; 42 | }; 43 | 44 | export const retrieveThreadHistory = async ( 45 | env: SlackInteractionsEnv | SlackEventsEnv, 46 | channel: string, 47 | thread_ts: string 48 | ) => { 49 | const response = await ( 50 | await getSlackClient(env) 51 | ).conversations.replies({ 52 | channel, 53 | ts: thread_ts 54 | }); 55 | 56 | logger.debug(`retrieveThreadHistory: ${JSON.stringify(response)}`); 57 | 58 | return response; 59 | }; 60 | 61 | export const retrieveAttachment = async ( 62 | env: SlackInteractionsEnv | SlackEventsEnv, 63 | url: string 64 | ) => { 65 | const secret = await getSlackSecret(env); 66 | const response = await axios.get(url, { 67 | headers: { 68 | Authorization: `Bearer ${secret.SlackBotUserOAuthToken}` 69 | }, 70 | responseType: 'arraybuffer' // Important for handling binary files 71 | }); 72 | 73 | // log just enough of the attachment content to validate file contents when troubleshooting. 74 | logger.debug( 75 | `retrieveAttachment from ${url}: ${response.data 76 | .slice(0, 300) 77 | .toString() 78 | .replace(/\r?\n/g, '')}` 79 | ); 80 | return response.data; 81 | }; 82 | 83 | export const sendSlackMessage = async ( 84 | env: SlackInteractionsEnv | SlackEventsEnv, 85 | channel: string, 86 | text: string, 87 | blocks?: Block[], 88 | thread_ts?: string 89 | ) => { 90 | const response = await ( 91 | await getSlackClient(env) 92 | ).chat.postMessage({ 93 | channel, 94 | blocks, 95 | text, 96 | thread_ts 97 | }); 98 | 99 | logger.debug(`sendSlackMessage: ${JSON.stringify(response)}`); 100 | 101 | return response; 102 | }; 103 | 104 | export const updateSlackMessage = async ( 105 | env: SlackInteractionsEnv | SlackEventsEnv, 106 | postMessageResponse: ChatPostMessageResponse, 107 | text: string | undefined, 108 | blocks?: Block[] 109 | ) => { 110 | if (isEmpty(postMessageResponse.channel) || isEmpty(postMessageResponse.ts)) { 111 | logger.error(`Can't update message due to empty channel or ts`); 112 | return; 113 | } 114 | 115 | const response = await ( 116 | await getSlackClient(env) 117 | ).chat.update({ 118 | channel: postMessageResponse.channel, 119 | ts: postMessageResponse.ts, 120 | blocks, 121 | text 122 | }); 123 | 124 | logger.debug(`updateSlackMessage: ${JSON.stringify(response)}`); 125 | }; 126 | 127 | export const openModal = async ( 128 | env: SlackInteractionsEnv | SlackEventsEnv, 129 | triggerId: string, 130 | channel: string, 131 | view: ModalView 132 | ) => { 133 | const response = await ( 134 | await getSlackClient(env) 135 | ).views.open({ 136 | trigger_id: triggerId, 137 | channel, 138 | view 139 | }); 140 | 141 | logger.debug(JSON.stringify(response)); 142 | }; 143 | export const getMarkdownBlock = (content: string, imageUrl?: string) => 144 | imageUrl !== undefined 145 | ? { 146 | type: 'section', 147 | text: { 148 | type: 'mrkdwn', 149 | text: content 150 | }, 151 | accessory: { 152 | type: 'image', 153 | image_url: imageUrl, 154 | alt_text: '' 155 | } 156 | } 157 | : { 158 | type: 'section', 159 | text: { 160 | type: 'mrkdwn', 161 | text: content 162 | } 163 | }; 164 | 165 | export const getMarkdownBlocks = (content: string, imageUrl?: string): Block[] => [ 166 | getMarkdownBlock(content, imageUrl) 167 | ]; 168 | 169 | export enum SLACK_ACTION { 170 | VIEW_SOURCES, 171 | FEEDBACK_DOWN, 172 | FEEDBACK_UP, 173 | SIGN_IN 174 | } 175 | 176 | export const createButton = (text: string, systemMessageId: string) => ({ 177 | type: 'actions', 178 | elements: [ 179 | { 180 | type: 'button', 181 | text: { 182 | type: 'plain_text', 183 | text, 184 | emoji: true 185 | }, 186 | style: 'primary', 187 | value: systemMessageId, 188 | action_id: SLACK_ACTION[SLACK_ACTION.VIEW_SOURCES] 189 | } 190 | ] 191 | }); 192 | 193 | export const createModal = (title: string, sources: SourceAttributions): ModalView => { 194 | const blocks = []; 195 | for (let i = 0; i < sources.length; i++) { 196 | const source = sources[i]; 197 | 198 | if (!isEmpty(source.title)) { 199 | blocks.push({ 200 | type: 'section', 201 | text: { 202 | type: 'mrkdwn', 203 | text: `${i + 1}) Title: *${source.title.trim()}*` 204 | } 205 | }); 206 | 207 | blocks.push({ 208 | type: 'divider' 209 | }); 210 | } 211 | 212 | if (!isEmpty(source.url)) { 213 | blocks.push({ 214 | type: 'section', 215 | text: { 216 | type: 'mrkdwn', 217 | text: `_From:_ ${source.url.trim()}` 218 | } 219 | }); 220 | 221 | blocks.push({ 222 | type: 'divider' 223 | }); 224 | } 225 | 226 | if (!isEmpty(source.snippet)) { 227 | blocks.push({ 228 | type: 'section', 229 | text: { 230 | type: 'mrkdwn', 231 | text: 232 | source.snippet.length > 3000 233 | ? source.snippet.slice(0, 3000 - (1 + 3)).trim() + '...' 234 | : source.snippet.trim() 235 | } 236 | }); 237 | 238 | blocks.push({ 239 | type: 'divider' 240 | }); 241 | } 242 | } 243 | 244 | return { 245 | type: 'modal', 246 | title: { 247 | type: 'plain_text', 248 | text: title 249 | }, 250 | blocks, 251 | close: { 252 | type: 'plain_text', 253 | text: 'Close' 254 | } 255 | }; 256 | }; 257 | 258 | const getSlackClient = async (env: SlackInteractionsEnv | SlackEventsEnv) => { 259 | const secret = await getSlackSecret(env); 260 | return new WebClient(secret.SlackBotUserOAuthToken); 261 | }; 262 | 263 | export const getSlackSecret = async ( 264 | env: SlackInteractionsEnv | SlackEventsEnv 265 | ): Promise => { 266 | logger.debug(`Getting secret value for SecretId ${env.SLACK_SECRET_NAME}`); 267 | const secret = await getSecretManagerClient(env) 268 | .getSecretValue({ 269 | SecretId: env.SLACK_SECRET_NAME 270 | }) 271 | .promise(); 272 | 273 | if (secret.SecretString === undefined) { 274 | throw new Error('Missing SecretString'); 275 | } 276 | 277 | return JSON.parse(secret.SecretString); 278 | }; 279 | 280 | export const validateSlackRequest = async ( 281 | headers: { [key: string]: string | undefined }, 282 | encodedBody: string, 283 | env: SlackInteractionsEnv | SlackEventsEnv, 284 | dependencies = { 285 | getSlackSecret 286 | } 287 | ): Promise => { 288 | const secret = await dependencies.getSlackSecret(env); 289 | 290 | const isValid = verifySlackSignature(headers, encodedBody, secret.SlackSigningSecret); 291 | if (!isValid) { 292 | logger.warn( 293 | `Invalid signature for request, signature ${headers['X-Slack-Signature'] ?? 'undefined'}` 294 | ); 295 | } 296 | return isValid; 297 | }; 298 | 299 | export const verifySlackSignature = ( 300 | headers: { [key: string]: string | undefined }, 301 | encodedBody: string, 302 | slackSigningSecret: string, 303 | date = new Date() 304 | ) => { 305 | if ( 306 | headers['X-Slack-Request-Timestamp'] === undefined || 307 | headers['X-Slack-Signature'] === undefined 308 | ) { 309 | return false; 310 | } 311 | const slackRequestTimestamp = parseInt(headers['X-Slack-Request-Timestamp']); 312 | const delta = date.getTime() / 1000 - slackRequestTimestamp; 313 | if (delta > 60 * 5) { 314 | return false; 315 | } 316 | 317 | const toSign = `v0:${slackRequestTimestamp}:${encodedBody}`; 318 | const hmacSig = `v0=${createHmac('sha256', slackSigningSecret).update(toSign).digest('hex')}`; 319 | 320 | return timingSafeEqual(Buffer.from(headers['X-Slack-Signature']), Buffer.from(hmacSig)); 321 | }; 322 | -------------------------------------------------------------------------------- /src/functions/slack-interaction-handler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyResult, Callback, Context } from 'aws-lambda'; 2 | import { 3 | createModal, 4 | getMarkdownBlocks, 5 | openModal, 6 | SLACK_ACTION, 7 | validateSlackRequest 8 | } from '@helpers/slack/slack-helpers'; 9 | import { getOrThrowIfEmpty, isEmpty } from '@src/utils'; 10 | import { makeLogger } from '@src/logging'; 11 | import { chatDependencies, getMessageMetadata } from '@helpers/chat'; 12 | import { ChatSyncCommandOutput } from '@aws-sdk/client-qbusiness'; 13 | import { Credentials } from 'aws-sdk'; 14 | import { SessionManagerEnv, getSessionCreds, startSession } from '@helpers/idc/session-helpers'; 15 | import { getSignInBlocks } from '@helpers/amazon-q/amazon-q-helpers'; 16 | 17 | const logger = makeLogger('slack-interactions-handler'); 18 | 19 | const processSlackInteractionsEnv = (env: NodeJS.ProcessEnv) => ({ 20 | REGION: getOrThrowIfEmpty(env.AWS_REGION ?? env.AWS_DEFAULT_REGION), 21 | SLACK_SECRET_NAME: getOrThrowIfEmpty(env.SLACK_SECRET_NAME), 22 | AMAZON_Q_APP_ID: getOrThrowIfEmpty(env.AMAZON_Q_APP_ID), 23 | AMAZON_Q_REGION: getOrThrowIfEmpty(env.AMAZON_Q_REGION), 24 | CONTEXT_DAYS_TO_LIVE: getOrThrowIfEmpty(env.CONTEXT_DAYS_TO_LIVE), 25 | CACHE_TABLE_NAME: getOrThrowIfEmpty(env.CACHE_TABLE_NAME), 26 | MESSAGE_METADATA_TABLE_NAME: getOrThrowIfEmpty(env.MESSAGE_METADATA_TABLE_NAME), 27 | OIDC_STATE_TABLE_NAME: getOrThrowIfEmpty(env.OIDC_STATE_TABLE_NAME), 28 | IAM_SESSION_TABLE_NAME: getOrThrowIfEmpty(env.IAM_SESSION_CREDENTIALS_TABLE_NAME), 29 | OIDC_IDP_NAME: getOrThrowIfEmpty(env.OIDC_IDP_NAME), 30 | OIDC_ISSUER_URL: getOrThrowIfEmpty(env.OIDC_ISSUER_URL), 31 | OIDC_CLIENT_ID: getOrThrowIfEmpty(env.OIDC_CLIENT_ID), 32 | OIDC_CLIENT_SECRET_NAME: getOrThrowIfEmpty(env.OIDC_CLIENT_SECRET_NAME), 33 | OIDC_REDIRECT_URL: getOrThrowIfEmpty(env.OIDC_REDIRECT_URL), 34 | KMS_KEY_ARN: getOrThrowIfEmpty(env.KEY_ARN), 35 | Q_USER_API_ROLE_ARN: getOrThrowIfEmpty(env.Q_USER_API_ROLE_ARN), 36 | GATEWAY_IDC_APP_ARN: getOrThrowIfEmpty(env.GATEWAY_IDC_APP_ARN), 37 | AWS_IAM_IDC_REGION: getOrThrowIfEmpty(env.AWS_IAM_IDC_REGION) 38 | }); 39 | 40 | export type SlackInteractionsEnv = ReturnType; 41 | 42 | export const handler = async ( 43 | event: { 44 | body: string; 45 | headers: { [key: string]: string | undefined }; 46 | }, 47 | _context: Context, 48 | _callback: Callback, 49 | dependencies = { 50 | ...chatDependencies, 51 | validateSlackRequest, 52 | openModal, 53 | getSessionCreds, 54 | startSession 55 | }, 56 | slackInteractionsEnv: SlackInteractionsEnv = processSlackInteractionsEnv(process.env) 57 | ): Promise => { 58 | console.log(`Received event ${JSON.stringify(event)}`); 59 | console.log(`SlackInteractionsEnv ${JSON.stringify(slackInteractionsEnv)}`); 60 | 61 | if (isEmpty(event.body)) { 62 | logger.warn(`Empty body`); 63 | return { statusCode: 400, body: 'Bad request' }; 64 | } 65 | 66 | // You would want to ensure that this method is always here before you start parsing the request 67 | // For extra safety it is recommended to have a Synthetic test (aka Canary) via AWS that will 68 | // Call this method with an invalid signature and verify that the status code is 403 69 | // You can define a CDK construct for it. 70 | if (!(await dependencies.validateSlackRequest(event.headers, event.body, slackInteractionsEnv))) { 71 | return { statusCode: 403, body: 'Forbidden' }; 72 | } 73 | 74 | const payloadUrl = new URLSearchParams(event.body); 75 | if (!payloadUrl.has('payload')) { 76 | return { statusCode: 200, body: 'No payload. Nothing to do' }; 77 | } 78 | 79 | const payloadParam = payloadUrl.get('payload'); 80 | if (payloadParam === null) { 81 | return { statusCode: 400, body: 'Invalid input' }; 82 | } 83 | 84 | const payload = JSON.parse(payloadParam); 85 | logger.debug(`Received event payload: ${JSON.stringify(payload)}`); 86 | 87 | if (payload.type !== 'block_actions') { 88 | return { statusCode: 200, body: 'Not a block action payload. Not implemented. Nothing to do' }; 89 | } 90 | 91 | if (payload.message === undefined || payload.channel.id === undefined) { 92 | logger.warn( 93 | `Missing required parameter for response in ${JSON.stringify(payload)}, ignoring action` 94 | ); 95 | return { 96 | statusCode: 200, 97 | body: 'Missing message and channel id for block action. Cant respond. Ignoring.' 98 | }; 99 | } 100 | 101 | if (payload.actions === undefined) { 102 | console.log(`No actions in ${JSON.stringify(payload)}, ignoring`); 103 | return { 104 | statusCode: 200, 105 | body: 'Missing actions. Cant respond. Ignoring.' 106 | }; 107 | } 108 | 109 | logger.debug(`Received block action interactions: ${JSON.stringify(payload.actions)}`); 110 | 111 | for (const action of payload.actions) { 112 | const id = action.action_id; 113 | if (id === SLACK_ACTION[SLACK_ACTION.SIGN_IN]) { 114 | // post message as signing in 115 | logger.debug(`Signing in...`); 116 | await dependencies.updateSlackMessage( 117 | slackInteractionsEnv, 118 | { channel: payload.channel.id, ts: payload.message.ts, ok: true }, 119 | `Signing in through your browser...`, 120 | getMarkdownBlocks(`_Signing in through your browser..._`) 121 | ); 122 | 123 | continue; 124 | } 125 | 126 | const messageMetadata = (await getMessageMetadata( 127 | action.value, 128 | dependencies, 129 | slackInteractionsEnv 130 | )) as ChatSyncCommandOutput; 131 | 132 | if ( 133 | id === SLACK_ACTION[SLACK_ACTION.VIEW_SOURCES] && 134 | !isEmpty(messageMetadata?.sourceAttributions) 135 | ) { 136 | const modal = createModal('Source(s)', messageMetadata.sourceAttributions); 137 | 138 | await dependencies.openModal( 139 | slackInteractionsEnv, 140 | payload.trigger_id, 141 | payload.channel.id, 142 | modal 143 | ); 144 | } else if ( 145 | id === SLACK_ACTION[SLACK_ACTION.FEEDBACK_UP] || 146 | id === SLACK_ACTION[SLACK_ACTION.FEEDBACK_DOWN] 147 | ) { 148 | logger.debug(`Received feedback ${id} for ${JSON.stringify(messageMetadata)}`); 149 | 150 | // Validate if the Slack user has a valid IAM session 151 | let iamSessionCreds: Credentials; 152 | const sessionManagerEnv: SessionManagerEnv = { 153 | oidcStateTableName: slackInteractionsEnv.OIDC_STATE_TABLE_NAME, 154 | iamSessionCredentialsTableName: slackInteractionsEnv.IAM_SESSION_TABLE_NAME, 155 | oidcIdPName: slackInteractionsEnv.OIDC_IDP_NAME, 156 | oidcClientId: slackInteractionsEnv.OIDC_CLIENT_ID, 157 | oidcClientSecretName: slackInteractionsEnv.OIDC_CLIENT_SECRET_NAME, 158 | oidcIssuerUrl: slackInteractionsEnv.OIDC_ISSUER_URL, 159 | oidcRedirectUrl: slackInteractionsEnv.OIDC_REDIRECT_URL, 160 | kmsKeyArn: slackInteractionsEnv.KMS_KEY_ARN, 161 | region: slackInteractionsEnv.AMAZON_Q_REGION, 162 | qUserAPIRoleArn: slackInteractionsEnv.Q_USER_API_ROLE_ARN, 163 | gatewayIdCAppArn: slackInteractionsEnv.GATEWAY_IDC_APP_ARN, 164 | awsIAMIdCRegion: slackInteractionsEnv.AWS_IAM_IDC_REGION 165 | }; 166 | 167 | try { 168 | iamSessionCreds = await dependencies.getSessionCreds(sessionManagerEnv, payload.user.id); 169 | } catch (error) { 170 | // call sessionManager.startSession() to start a new session 171 | logger.error(`Failed to get session: ${error}`); 172 | 173 | const authorizationURL = await dependencies.startSession( 174 | sessionManagerEnv, 175 | payload.user.id 176 | ); 177 | 178 | // post a message to channel to return a slack button for authorization url 179 | await dependencies.sendSlackMessage( 180 | slackInteractionsEnv, 181 | payload.user.id, 182 | `<@${payload.user.id}>, please sign in through the Amazon Q bot app to continue.`, 183 | getSignInBlocks(authorizationURL) 184 | ); 185 | 186 | // return 200 ok message 187 | return { 188 | statusCode: 200, 189 | body: JSON.stringify({ 190 | // TODO: add more details to the response 191 | //chat: { context, input, output, blocks } 192 | }) 193 | }; 194 | } 195 | 196 | await dependencies.submitFeedbackRequest( 197 | payload.user.id, 198 | slackInteractionsEnv, 199 | iamSessionCreds, 200 | { 201 | conversationId: messageMetadata.conversationId ?? '', 202 | messageId: messageMetadata.systemMessageId ?? '' 203 | }, 204 | id === SLACK_ACTION[SLACK_ACTION.FEEDBACK_UP] ? 'USEFUL' : 'NOT_USEFUL', 205 | id === SLACK_ACTION[SLACK_ACTION.FEEDBACK_UP] ? 'HELPFUL' : 'NOT_HELPFUL', 206 | payload.message.ts 207 | ); 208 | 209 | logger.info(`Received feedback ${id} for ${JSON.stringify(messageMetadata)}`); 210 | await dependencies.updateSlackMessage( 211 | slackInteractionsEnv, 212 | { channel: payload.channel.id, ts: payload.message.ts, ok: true }, 213 | `Thanks for your feedback`, 214 | getMarkdownBlocks(`_Thanks for your feedback_`) 215 | ); 216 | } 217 | } 218 | 219 | return { statusCode: 200, body: 'Handled block action interactions!' }; 220 | }; 221 | -------------------------------------------------------------------------------- /tst/mocks/amazon-q/valid-response-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "systemMessage": " # The Pillars of the Well Architected Framework\n\n|Name | Description|\n|:--|:--| \n|Operational Excellence| The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures.|\n|Security|The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies.| \n|Reliability| The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues.|\n|Performance Efficiency| The ability to use computing resources efficiently to meet system requirements, and to maintain that efficiency as demand changes and technologies evolve.|\n|Cost Optimization| The ability to run systems to deliver business value at the lowest price point.|", 3 | "conversationId": "80a6642c-8b3d-433e-a9cb-233b42a0d63a", 4 | "sourceAttributions": [ 5 | { 6 | "title": "AWS Well-Architected Framework", 7 | "snippet": "As you deploy these systems into live environments, we learn how well these systems perform and the consequences of those trade-offs. Based on what we have learned we have created the AWS Well-Architected Framework, which provides a consistent set of best practices for customers and partners to evaluate architectures, and provides a set of questions you can use to evaluate how well an architecture is aligned to AWS best practices. The AWS Well-Architected Framework is based on five pillars — operational excellence, security, reliability, performance efficiency, and cost optimization. Table 1. The AWS Well-Architected Framework is based on five pillars — operational excellence, security, reliability, performance efficiency, and cost optimization. Table 1. The pillars of the AWS Well-Architected Framework Name Description Operational Excellence The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures. Security The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies. Reliability The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues. The pillars of the AWS Well-Architected Framework Name Description Operational Excellence The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures. Security The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies. Reliability The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues. Performance Efficiency The ability to use computing resources efficiently to meet system requirements, and to maintain that efficiency as demand changes and technologies evolve. Cost Optimization The ability to run systems to deliver business value at the lowest price point." 8 | }, 9 | { 10 | "title": "AWS Well-Architected Framework", 11 | "snippet": "As you deploy these systems into live environments, we learn how well these systems perform and the consequences of those trade-offs. Based on what we have learned we have created the AWS Well-Architected Framework, which provides a consistent set of best practices for customers and partners to evaluate architectures, and provides a set of questions you can use to evaluate how well an architecture is aligned to AWS best practices. The AWS Well-Architected Framework is based on five pillars — operational excellence, security, reliability, performance efficiency, and cost optimization. Table 1. The AWS Well-Architected Framework is based on five pillars — operational excellence, security, reliability, performance efficiency, and cost optimization. Table 1. The pillars of the AWS Well-Architected Framework Name Description Operational Excellence The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures. Security The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies. Reliability The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues. The pillars of the AWS Well-Architected Framework Name Description Operational Excellence The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures. Security The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies. Reliability The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues. Performance Efficiency The ability to use computing resources efficiently to meet system requirements, and to maintain that efficiency as demand changes and technologies evolve. Cost Optimization The ability to run systems to deliver business value at the lowest price point." 12 | }, 13 | { 14 | "title": "AWS Well-Architected Framework", 15 | "snippet": "The AWS Well-Architected Framework is based on five pillars — operational excellence, security, reliability, performance efficiency, and cost optimization. Table 1. The pillars of the AWS Well-Architected Framework Name Description Operational Excellence The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures. Security The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies. Reliability The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues. The pillars of the AWS Well-Architected Framework Name Description Operational Excellence The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures. Security The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies. Reliability The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues. Performance Efficiency The ability to use computing resources efficiently to meet system requirements, and to maintain that efficiency as demand changes and technologies evolve. Cost Optimization The ability to run systems to deliver business value at the lowest price point. In the AWS Well-Architected Framework we use these terms • A component is the code, configuration and AWS Resources that together deliver against a requirement. A component is often the unit of technical ownership, and is decoupled from other components. • We use the term workload to identify a set of components that together deliver business value." 16 | }, 17 | { 18 | "title": "AWS Well-Architected Framework", 19 | "snippet": "The AWS Well-Architected Framework is based on five pillars — operational excellence, security, reliability, performance efficiency, and cost optimization. Table 1. The pillars of the AWS Well-Architected Framework Name Description Operational Excellence The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures. Security The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies. Reliability The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues. The pillars of the AWS Well-Architected Framework Name Description Operational Excellence The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures. Security The ability to protect information, systems, and assets while delivering business value through risk assessments and mitigation strategies. Reliability The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues. Performance Efficiency The ability to use computing resources efficiently to meet system requirements, and to maintain that efficiency as demand changes and technologies evolve. Cost Optimization The ability to run systems to deliver business value at the lowest price point. In the AWS Well-Architected Framework we use these terms • A component is the code, configuration and AWS Resources that together deliver against a requirement. A component is often the unit of technical ownership, and is decoupled from other components. • We use the term workload to identify a set of components that together deliver business value." 20 | }, 21 | { 22 | "title": "Security Overview of AWS Lambda", 23 | "snippet": "cloudtrail/latest/userguide/cloudtrail-log-file-validation-intro.html https://aws.amazon.com/xray/ https://aws.amazon.com/config/ Amazon Web Services Security Overview of AWS Lambda Page 11 Architecting and Operating Lambda Functions Now that we have discussed the foundations of the Lambda service, we move on to architecture and operations. For information about standard best practices for serverless applications, see the Serverless Application Lens whitepaper, which defines and explores the pillars of the AWS Well Architected Framework in a Serverless context. • Operational Excellence Pillar – The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures. For information about standard best practices for serverless applications, see the Serverless Application Lens whitepaper, which defines and explores the pillars of the AWS Well Architected Framework in a Serverless context. • Operational Excellence Pillar – The ability to run and monitor systems to deliver business value and to continually improve supporting processes and procedures. • Security Pillar – The ability to protect information, systems, and assets while delivering business value through risk assessment and mitigation strategies. • Reliability Pillar – The ability of a system to recover from infrastructure or service disruptions, dynamically acquire computing resources to meet demand, and mitigate disruptions such as misconfigurations or transient network issues. • Performance Efficiency Pillar – The efficient use of computing resources to meet requirements and the maintenance of that efficiency as demand changes and technologies evolve. The Serverless Application Lens whitepaper includes topics such as logging metrics and alarming, throttling and limits, assigning permissions to Lambda functions, and making sensitive data available to Lambda functions." 24 | } 25 | ], 26 | "systemMessageId": "e5a23752-3f31-4fee-83fe-56fbd7803540", 27 | "userMessageId": "616fefbc-48bc-442d-a618-497bbbde3d66" 28 | } -------------------------------------------------------------------------------- /README_DEVELOPERS.md: -------------------------------------------------------------------------------- 1 | # Developer README 2 | 3 | The main README is here: [Slack gateway for Amazon Q, your business expert (preview)](./README.md) 4 | 5 | This Developer README describes how to build the project from the source code - for developer types. You can: 6 | - [Deploy the solution](#deploy-the-solution) 7 | - [Publish the solution](#publish-the-solution) 8 | 9 | ### 1. Dependencies 10 | 11 | To deploy or to publish, you need to have the following packages installed on your computer: 12 | 13 | 1. bash shell (Linux, MacOS, Windows-WSL) 14 | 2. node and npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm 15 | 3. tsc (typescript): `npm install -g typescript` 16 | 4. esbuild: `npm i -g esbuild` 17 | 5. jq: https://jqlang.github.io/jq/download/ 18 | 6. aws (AWS CLI): https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html 19 | 7. cdk (AWS CDK): https://docs.aws.amazon.com/cdk/v2/guide/cli.html 20 | 21 | Copy the GitHub repo to your computer. Either: 22 | - use the git command: git clone https://github.com/aws-samples/amazon-q-slack-gateway.git 23 | - OR, download and expand the ZIP file from the GitHub page: https://github.com/aws-samples/amazon-q-slack-gateway/archive/refs/heads/main.zip 24 | 25 | ### 2. Prerequisites 26 | 27 | 1. You need to have an AWS account and an IAM Role/User with permissions to create and manage the necessary resources and components for this application. *(If you do not have an AWS account, please see [How do I create and activate a new Amazon Web Services account?](https://aws.amazon.com/premiumsupport/knowledge-center/create-and-activate-aws-account/))* 28 | 29 | 2. You need to have an Okta Workforce Identity Cloud account. If you haven't signed up yet, see [Signing up for Okta](https://www.okta.com/) 30 | 31 | 3. You need to configure SAML and SCIM with Okta and IAM Identity Center. If you haven't configured, see [Configuring SAML and SCIM with Okta and IAM Identity Center](https://docs.aws.amazon.com/singlesignon/latest/userguide/gs-okta.html) 32 | 33 | 4. You also need to have an existing, working Amazon Q Business application integrated with IdC. If you haven't set one up yet, see [Creating an Amazon Q application](https://docs.aws.amazon.com/amazonq/latest/business-use-dg/create-app.html) 34 | 35 | 5. You need to have users subscribed to your Amazon Q Business application, and are able to access Amazon Q Web Experience. If you haven't set one up yet, see [Subscribing users to an Amazon Q application](https://docs.aws.amazon.com/amazonq/latest/qbusiness-ug/adding-users-groups.html) 36 | 37 | 38 | ## Deploy the solution 39 | 40 | ### 1. Setup 41 | 42 | #### 1.1 Create an OIDC app integration, for the gateway, in Okta. 43 | 44 | Create the client as a ['Web app'](https://help.okta.com/en-us/content/topics/apps/apps_app_integration_wizard_oidc.htm). You will want to enable the 'Refresh Token' grant type, 'Allow everyone in your organization to access', and 'Federation Broker Mode'. Use a placeholder URL, like ```https://example.com```, for the redirect URI, as you will update this later (in step 3). 45 | 46 | Verify that administrators are given ability to configure the Interaction Code grant type for apps and authorization servers. See [Verify that the Interaction Code grant type is enabled](https://developer.okta.com/docs/guides/implement-grant-type/interactioncode/main/#verify-that-the-interaction-code-grant-type-is-enabled). Verify that "Interaction Code" is enabled for your "Authorization Server". See [Enable Interaction Code grant for your authorization server](https://developer.okta.com/docs/guides/implement-grant-type/interactioncode/main/#enable-interaction-code-grant-for-your-authorization-server) 47 | 48 | #### 1.2 Create Trusted token issuer in IAM Identity Center 49 | 50 | Create trusted token issuer to trust tokens from OIDC issuer URL using these instructions listed here - https://docs.aws.amazon.com/singlesignon/latest/userguide/using-apps-with-trusted-token-issuer.html. 51 | Or you can run the below script. 52 | 53 | For the script, you need to have the OIDC issuer URL and the AWS region in which you have your AWS IAM Identity Center instance deployed. To retrieve the OIDC issuer URL, go to Okta account console, click the left hamburger menu and open Security > API and copy the whole 'Issuer URI'. The IAM IdC region is typically the same region where your Amazon Q Business application has been created but that is not a requirement. 54 | 55 | The script will output trusted token issuer ARN which you will use in the next step. 56 | 57 | ``` 58 | export AWS_DEFAULT_REGION=<> 59 | OIDC_ISSUER_URL=<> 60 | AWS_IDC_REGION=<> 61 | bin/create-trusted-token-issuer.sh $OIDC_ISSUER_URL $AWS_IDC_REGION 62 | ``` 63 | 64 | #### 1.3 Create Customer managed application in IAM Identity Center 65 | 66 | Create customer managed IdC application by running below script. 67 | 68 | For the script, you need to have the OIDC client ID, trusted token issuer ARN, and the region in which you have your Q business application. To retrieve the OIDC client ID, go to Okta account console, click the left hamburger menu and open Applications > Applications and click on the application you created in step 1.1. Copy the 'Client ID'. For TTI_ARN, you can use the output from the previous step. 69 | 70 | The script will output the IdC application ARN which you will use in the next step. 71 | 72 | ``` 73 | export AWS_DEFAULT_REGION=<> 74 | OIDC_CLIENT_ID=<> 75 | TTI_ARN=<> 76 | AWS_IDC_REGION=<> 77 | bin/create-idc-application.sh $OIDC_CLIENT_ID $TTI_ARN $AWS_IDC_REGION 78 | ``` 79 | 80 | Before starting, you need to have an existing, working Amazon Q Business application. If you haven't set one up yet, see [Creating an Amazon Q application](https://docs.aws.amazon.com/amazonq/latest/business-use-dg/create-app.html) 81 | 82 | ### 2. Initialize and deploy the stack 83 | 84 | Navigate into the project root directory and, in a bash shell, run: 85 | 86 | 1. `./init.sh` - checks your system dependendencies for required packages (see Dependencies above), sets up your environment file, and bootstraps your cdk environment. 87 | 2. `./deploy.sh` - runs the cdk build and deploys or updates a stack in your AWS account, creates a slack app manifest file, and outputs a link to the AWS Secrets Manager secret that you will need below. 88 | 89 | ### 3. Update OIDC Client Redirect URL. 90 | 91 | Go the app client settings created in Okta (in step 1.1), and update the client redirect URL with exported value in CloudFormation stack for `OIDCCallbackEndpointExportedName`. 92 | 93 | ### 4. Configure your Slack application 94 | 95 | #### 4.1 Create your app 96 | 97 | Now you can create your new app in Slack! 98 | *NOTE: If you have deployed the Slack data source connector for Amazon Q you may already have an existing Slack app installed. Do not attempt to modify that data source connector app - create a new app instead.* 99 | 100 | 1. Create a Slack app: https://api.slack.com/apps from the generated manifest - copy / paste from the stack output: `SlackAppManifest`. 101 | 2. Go to `App Home`, scroll down to the section `Show Tabs` and enable `Message Tab` then check the box `Allow users to send Slash commands and messages from the messages tab` - This is a required step to enable your user to send messages to your app 102 | 103 | #### 4.2 Add your app in your workspace 104 | 105 | Let's now add your app into your workspace, this is required to generate the `Bot User OAuth Token` value that will be needed in the next step 106 | 107 | 1. Go to OAuth & Permissions (in api.slack.com) and click `Install to Workspace`, this will generate the OAuth token 108 | 2. In Slack, go to your workspace 109 | 2. Click on your workspace name > Tools and settings > Manage apps 110 | 3. Click on your newly created app 111 | 4. In the right pane, click on "Open in App Directory" 112 | 5. Click "Open in Slack" 113 | 114 | ### 4.3 Configure your Secrets in AWS 115 | 116 | #### 4.3.1 Configure your Slack secrets in order to (1) verify the signature of each request, (2) post on behalf of your bot 117 | 118 | > **IMPORTANT** 119 | > In this example we are not enabling Slack token rotation. Enable it for a production app by implementing 120 | > rotation via AWS Secrets Manager. 121 | > Please create an issue (or, better yet, a pull request!) in this repo if you want this feature added to a future version. 122 | 123 | 1. Login to your AWS console 124 | 2. In your AWS account go to Secret manager, using the URL shown in the stack output: `SlackSecretConsoleUrl`. 125 | 3. Choose `Retrieve secret value` 126 | 4. Choose `Edit` 127 | 5. Replace the value of `Signing Secret`\* and `Bot User OAuth Token`, you will find those values in the Slack application configuration under `Basic Information` and `OAuth & Permissions`. \**(Pro tip: Be careful you don't accidentally copy 'Client Secret' (wrong) instead of 'Signing Secret' (right)!)* 128 | 129 | 3. Update OIDC Client Redirect URL. 130 | 131 | #### 4.3.2 Configure OIDC Client Secret in order to exchange the code for token 132 | 133 | 1. Login to your AWS console 134 | 2. In your AWS account go to Secret manager, using the URL shown in the stack output: `OIDCClientSecretConsoleUrl`. 135 | 3. Choose `Retrieve secret value` 136 | 4. Choose `Edit` 137 | 5. Replace the value of `OidcClientSecret`, you will find those values in the IdP app client configuration. 138 | 139 | ### Say hello 140 | > Time to say Hi! 141 | 142 | 1. Go to Slack 143 | 2. Under Apps > Manage, add your new Amazon Q app 144 | 3. Optionally add your app to team channels 145 | 4. In the app DM channel, say *Hello*. In a team channel, ask it for help with an @mention. 146 | 5. You'll be prompted to Sign In with your Okta credentials to authenticate with Amazon Q. Click the button to sign in. 147 | ![Slack Sign In](./images/sign-in-demo.png) 148 | 6. You'll be redirected to browser to sign in with Okta. Once you sign in, you can close the browser window and return to Slack. 149 | 7. You're now authenticated and can start asking questions! 150 | 8. Enjoy. 151 | 152 | ## Publish the solution 153 | 154 | In our main README, you will see that we provided Easy Deploy Buttons to launch a stack using pre-built templates that we published already to an S3 bucket. 155 | 156 | If you want to build and publish your own template, to your own S3 bucket, so that others can easily use a similar easy button approach to deploy a stack, using *your* templates, here's how. 157 | 158 | Navigate into the project root directory and, in a bash shell, run: 159 | 160 | 1. `./publish.sh `. 161 | This: 162 | - checks your system dependendencies for required packages (see Dependencies above) 163 | - bootstraps your cdk environment if needed 164 | - creates a standalone CloudFormation template (that doesn't depend on CDK) 165 | - publishes the template and required assets to an S3 bucket in your account called `cfn_bucket_basename-region` (it creates the bucket if it doesn't already exist) 166 | - optionally add a final parameter `public` if you want to make the templates public. Note: your bucket and account must be configured not to Block Public Access using new ACLs. 167 | 168 | That's it! There's just one step. 169 | 170 | When completed, it displays the CloudFormation templates S3 URLs and 1-click URLs for launching the stack creation in CloudFormation console, e.g.: 171 | ``` 172 | OUTPUTS 173 | Template URL: https://s3.us-east-1.amazonaws.com/yourbucketbasename-us-east-1/qslack-test/AmazonQSlackGateway.json 174 | CF Launch URL: https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/create/review?templateURL=https://s3.us-east-1.amazonaws.com/yourbucketbasename-us-east-1/qslack-test/AmazonQSlackGateway.json&stackName=AMAZON-Q-SLACK-GATEWAY 175 | Done 176 | ``` 177 | 178 | Follow the deployment directions in the main [README](./README.md), but use your own CF Launch URL instead of our pre-built templates (Launch Stack buttons). 179 | 180 | ## Contributing, and reporting issues 181 | 182 | We welcome your contributions to our project. Whether it's a bug report, new feature, correction, or additional 183 | documentation, we greatly value feedback and contributions from our community. 184 | 185 | See [CONTRIBUTING](CONTRIBUTING.md) for more information. 186 | 187 | ## Security 188 | 189 | See [Security issue notifications](CONTRIBUTING.md#security-issue-notifications) for more information. 190 | 191 | ## License 192 | 193 | This library is licensed under the MIT-0 License. See the [LICENSE](./LICENSE) file. 194 | -------------------------------------------------------------------------------- /src/helpers/idc/session-helpers.ts: -------------------------------------------------------------------------------- 1 | import { Credentials, SecretsManager } from 'aws-sdk'; 2 | import { makeLogger } from '@src/logging'; 3 | import axios from 'axios'; 4 | import { deleteItem, getItem, putItem } from '@helpers/dynamodb-client'; 5 | import { CreateTokenWithIAMRequest, SSOOIDC } from '@aws-sdk/client-sso-oidc'; 6 | import { AssumeRoleRequest, STS } from '@aws-sdk/client-sts'; 7 | import jwt from 'jsonwebtoken'; 8 | import * as crypto from 'crypto'; 9 | import { decryptData, encryptData } from '@helpers/idc/encryption-helpers'; 10 | 11 | const logger = makeLogger('slack-helpers'); 12 | 13 | let secretManagerClient: SecretsManager | null = null; 14 | 15 | export type SessionManagerEnv = { 16 | oidcStateTableName: string; 17 | iamSessionCredentialsTableName: string; 18 | oidcIdPName: string; 19 | oidcIssuerUrl: string; 20 | oidcClientId: string; 21 | oidcClientSecretName: string; 22 | oidcRedirectUrl: string; 23 | kmsKeyArn: string; 24 | region: string; 25 | qUserAPIRoleArn: string; 26 | gatewayIdCAppArn: string; 27 | awsIAMIdCRegion: string; 28 | }; 29 | 30 | type Session = { 31 | accessKeyId: string; 32 | secretAccessKey: string; 33 | sessionToken: string; 34 | expiration: string; 35 | refreshToken?: string; 36 | }; 37 | 38 | export const getSessionCreds = async (env: SessionManagerEnv, slackUserId: string) => { 39 | logger.debug(`Getting session for slackUserId ${slackUserId}`); 40 | 41 | let session = await loadSession(env, slackUserId); 42 | 43 | if (!hasSessionExpired(session.expiration)) { 44 | return new Credentials(session.accessKeyId, session.secretAccessKey, session.sessionToken); 45 | } 46 | 47 | logger.debug('Session has expired'); 48 | 49 | if (session.refreshToken === undefined) { 50 | logger.debug('No refresh token found'); 51 | throw new Error('SessionExpiredException'); 52 | } 53 | 54 | logger.debug(`Refreshing session for slackUserId ${slackUserId}`); 55 | const oidcSecrets = await getOIDCClientSecret(env.oidcClientSecretName, env.region); 56 | const clientSecret = oidcSecrets.OIDCClientSecret; 57 | 58 | // get token endpoint 59 | const oidcEndpoints = await getOIDCEndpoints(env.oidcIssuerUrl); 60 | const tokenEndpoint = oidcEndpoints.tokenEndpoint; 61 | const refreshedTokens = await refreshToken( 62 | session.refreshToken, 63 | env.oidcClientId, 64 | clientSecret, 65 | tokenEndpoint 66 | ); 67 | 68 | // exchange IdP id token for IAM session 69 | session = await exchangeIdPTokenForIAMSessionCreds( 70 | slackUserId, 71 | env, 72 | refreshedTokens.id_token, 73 | refreshedTokens.refresh_token 74 | ); 75 | 76 | // save session 77 | await saveSession(env, slackUserId, session); 78 | 79 | return new Credentials(session.accessKeyId, session.secretAccessKey, session.sessionToken); 80 | }; 81 | 82 | export const startSession = async (env: SessionManagerEnv, slackUserId: string) => { 83 | const state = crypto.randomBytes(16).toString('hex'); 84 | const timestamp = new Date().toISOString(); 85 | 86 | logger.debug(`Starting session for slackUserId ${slackUserId} with state ${state}`); 87 | // compute ttl now since epoch + 5 minutes 88 | const ttl = Math.floor((Date.now() + 5 * 60 * 1000) / 1000); 89 | await putItem({ 90 | TableName: env.oidcStateTableName, 91 | Item: { state: state, slackUserId: slackUserId, timestamp: timestamp, ttl: ttl } 92 | }); 93 | 94 | const oidcEndpoints = await getOIDCEndpoints(env.oidcIssuerUrl); 95 | 96 | let scopes = 'openid email offline_access'; 97 | if (env.oidcIdPName.toLowerCase() === 'okta') { 98 | // Okta allowed scopes: https://developer.okta.com/docs/api/oauth2/ 99 | scopes = 'openid email offline_access'; 100 | } else if (env.oidcIdPName.toLowerCase() === 'cognito') { 101 | // Cognito allowed scopes: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html#cognito-user-pools-define-resource-servers-about-scopes 102 | scopes = 'openid email'; 103 | } 104 | const encodedScopes = encodeURIComponent(scopes); 105 | 106 | return `${oidcEndpoints.authorizationEndpoint}?response_type=code&client_id=${ 107 | env.oidcClientId 108 | }&redirect_uri=${encodeURIComponent(env.oidcRedirectUrl)}&state=${state}&scope=${encodedScopes}`; 109 | }; 110 | 111 | export const finishSession = async ( 112 | env: SessionManagerEnv, 113 | authorization_code: string, 114 | state: string 115 | ) => { 116 | const getItemResponse = await getItem({ 117 | TableName: env.oidcStateTableName, 118 | Key: { state: state } 119 | }); 120 | 121 | if (!getItemResponse.Item) { 122 | throw new Error('InvalidState'); 123 | } 124 | 125 | // delete state from dynamodb and get the slack user id from the state 126 | await deleteItem({ 127 | TableName: env.oidcStateTableName, 128 | Key: { state: state } 129 | }); 130 | 131 | // get slackUserId from the attributes 132 | const slackUserId = getItemResponse.Item.slackUserId; 133 | logger.debug(`Slack user id ${slackUserId}`); 134 | 135 | const oidcSecrets = await getOIDCClientSecret(env.oidcClientSecretName, env.region); 136 | const clientSecret = oidcSecrets.OIDCClientSecret; 137 | 138 | // get token endpoint 139 | const oidcEndpoints = await getOIDCEndpoints(env.oidcIssuerUrl); 140 | const tokenEndpoint = oidcEndpoints.tokenEndpoint; 141 | 142 | // exchange code for token 143 | const data = { 144 | grant_type: 'authorization_code', 145 | code: authorization_code, 146 | redirect_uri: env.oidcRedirectUrl, 147 | client_id: env.oidcClientId, 148 | client_secret: clientSecret 149 | }; 150 | 151 | const queryString = toQueryString(data); 152 | const response = await axios.post(tokenEndpoint, queryString, { 153 | headers: { 154 | 'Content-Type': 'application/x-www-form-urlencoded' 155 | } 156 | }); 157 | 158 | // call exchangeIdPTokenForIAMSessionCreds 159 | const session = await exchangeIdPTokenForIAMSessionCreds( 160 | slackUserId, 161 | env, 162 | response.data.id_token, 163 | response.data.refresh_token 164 | ); 165 | 166 | // save session 167 | await saveSession(env, slackUserId, session); 168 | }; 169 | 170 | const getSSOOIDCClient = (region: string) => { 171 | return new SSOOIDC({ region: region }); 172 | }; 173 | 174 | type OIDCSecrets = { 175 | OIDCClientSecret: string; 176 | }; 177 | const getOIDCClientSecret = async (secretName: string, region: string): Promise => { 178 | logger.debug(`Getting secret value for SecretId ${secretName}`); 179 | const secret = await getSecretManagerClient(region) 180 | .getSecretValue({ 181 | SecretId: secretName 182 | }) 183 | .promise(); 184 | 185 | if (secret.SecretString === undefined) { 186 | throw new Error(`Missing secret value for ${secretName}`); 187 | } 188 | 189 | return JSON.parse(secret.SecretString); 190 | }; 191 | 192 | const getSecretManagerClient = (region: string) => { 193 | if (secretManagerClient === null) { 194 | secretManagerClient = new SecretsManager({ region: region }); 195 | } 196 | 197 | return secretManagerClient; 198 | }; 199 | 200 | type OIDCEndpoints = { 201 | authorizationEndpoint: string; 202 | tokenEndpoint: string; 203 | }; 204 | 205 | async function getOIDCEndpoints(issuerUrl: string): Promise { 206 | const response = await axios.get(`${issuerUrl}/.well-known/openid-configuration`); 207 | 208 | return { 209 | authorizationEndpoint: response.data.authorization_endpoint, 210 | tokenEndpoint: response.data.token_endpoint 211 | }; 212 | } 213 | 214 | function toQueryString(params: Record) { 215 | const parts = []; 216 | for (const key in params) { 217 | if (Object.prototype.hasOwnProperty.call(params, key)) { 218 | const value = params[key]; 219 | parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); 220 | } 221 | } 222 | return parts.join('&'); 223 | } 224 | 225 | type RefreshTokensResponse = { 226 | id_token: string; 227 | refresh_token?: string; 228 | }; 229 | 230 | const refreshToken = async ( 231 | refreshToken: string, 232 | client_id: string, 233 | client_secret: string, 234 | token_endpoint: string 235 | ) => { 236 | const data = { 237 | grant_type: 'refresh_token', 238 | refresh_token: refreshToken, 239 | client_id: client_id, 240 | client_secret: client_secret 241 | }; 242 | 243 | const queryString = toQueryString(data); 244 | 245 | const response = await axios.post(token_endpoint, queryString, { 246 | headers: { 247 | 'Content-Type': 'application/x-www-form-urlencoded' 248 | } 249 | }); 250 | 251 | // parse the response 252 | const refreshTokensResponse = response.data as RefreshTokensResponse; 253 | if (refreshTokensResponse.refresh_token === undefined) { 254 | refreshTokensResponse.refresh_token = refreshToken; 255 | } 256 | 257 | return refreshTokensResponse; 258 | }; 259 | 260 | const exchangeIdPTokenForIAMSessionCreds = async ( 261 | slackUserId: string, 262 | env: SessionManagerEnv, 263 | idToken: string, 264 | refreshToken?: string 265 | ) => { 266 | // exchange IdP id token for IdC id token 267 | const idCAppClientId = env.gatewayIdCAppArn; 268 | 269 | const createTokenWithIAMRequest: CreateTokenWithIAMRequest = { 270 | clientId: idCAppClientId, 271 | grantType: 'urn:ietf:params:oauth:grant-type:jwt-bearer', 272 | assertion: idToken 273 | }; 274 | 275 | const idcResponse = await getSSOOIDCClient(env.awsIAMIdCRegion).createTokenWithIAM( 276 | createTokenWithIAMRequest 277 | ); 278 | 279 | const idcIdToken = idcResponse.idToken!; 280 | 281 | // decode the jwt token 282 | const decodedToken = jwt.decode(idcIdToken, { complete: true }); 283 | 284 | if (!decodedToken || typeof decodedToken !== 'object' || !decodedToken.payload) { 285 | throw new Error('Invalid token'); 286 | } 287 | 288 | // Define a type for the payload 289 | interface Payload { 290 | 'sts:identity_context': string; 291 | } 292 | 293 | // Extract 'sts:identity-context' claim using type assertion 294 | const identityContext = (decodedToken.payload as Payload)['sts:identity_context']; 295 | 296 | // call sts assume role 297 | const stsClient = new STS({ region: env.region }); 298 | const assumeRoleRequest: AssumeRoleRequest = { 299 | RoleArn: env.qUserAPIRoleArn, 300 | RoleSessionName: 'q-gateway-for-slack', 301 | DurationSeconds: 3600, 302 | ProvidedContexts: [ 303 | { 304 | ProviderArn: 'arn:aws:iam::aws:contextProvider/IdentityCenter', 305 | ContextAssertion: identityContext 306 | } 307 | ] 308 | }; 309 | 310 | const assumeRoleResponse = await stsClient.assumeRole(assumeRoleRequest); 311 | 312 | // extract access key, secret key and session token and expiry 313 | const accessKeyId = assumeRoleResponse.Credentials?.AccessKeyId; 314 | const secretAccessKey = assumeRoleResponse.Credentials?.SecretAccessKey; 315 | const sessionToken = assumeRoleResponse.Credentials?.SessionToken; 316 | const expiration = assumeRoleResponse.Credentials?.Expiration; 317 | 318 | // put credentials in a record 319 | const sessionCreds: Session = { 320 | accessKeyId: accessKeyId!, 321 | secretAccessKey: secretAccessKey!, 322 | sessionToken: sessionToken!, 323 | expiration: expiration!.toISOString(), 324 | refreshToken: refreshToken 325 | }; 326 | 327 | return sessionCreds; 328 | }; 329 | 330 | const saveSession = async (env: SessionManagerEnv, slackUserId: string, sessionCreds: Session) => { 331 | const sessionCredsString = JSON.stringify(sessionCreds); 332 | 333 | const encryptedCreds = await encryptData(sessionCredsString, env.kmsKeyArn, slackUserId); 334 | 335 | // store these in dynamodb table 336 | const timestamp = new Date().toISOString(); 337 | await putItem({ 338 | TableName: env.iamSessionCredentialsTableName, 339 | Item: { 340 | slackUserId: slackUserId, 341 | encryptedCreds: encryptedCreds, 342 | expiration: sessionCreds.expiration, 343 | timestamp: timestamp 344 | } 345 | }); 346 | }; 347 | 348 | const loadSession = async (env: SessionManagerEnv, slackUserId: string) => { 349 | const getItemResponse = await getItem({ 350 | TableName: env.iamSessionCredentialsTableName, 351 | Key: { slackUserId: slackUserId } 352 | }); 353 | 354 | if (!getItemResponse.Item) { 355 | throw new Error('NoSessionExistsException'); 356 | } 357 | 358 | const item = getItemResponse.Item; 359 | const encryptedCreds: string = item.encryptedCreds; 360 | const credsString = await decryptData(encryptedCreds, env.kmsKeyArn, slackUserId); 361 | 362 | return JSON.parse(credsString) as Session; 363 | }; 364 | 365 | const hasSessionExpired = (expiration: string) => { 366 | const expirationDate = new Date(expiration); 367 | const now = new Date(); 368 | 369 | expirationDate.setMinutes(expirationDate.getMinutes() - 15); 370 | 371 | return expirationDate <= now; 372 | }; 373 | -------------------------------------------------------------------------------- /bin/convert-cfn-template.js: -------------------------------------------------------------------------------- 1 | const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const JSZip = require('jszip'); 5 | 6 | // Read command line arguments 7 | const templateName = process.argv[2]; 8 | const destinationBucket = process.argv[3]; 9 | const destinationPrefix = process.argv[4]; 10 | const awsRegion = process.argv[5]; 11 | if (!templateName || !destinationBucket || !destinationPrefix || !awsRegion) { 12 | console.error('Error: All arguments must be provided.'); 13 | console.error( 14 | 'Usage: