├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.md ├── THIRD_PARTY_LICENSES.md ├── amzn-q-auth-tvm ├── .gitignore ├── .npmignore ├── README.md ├── allow-list-domains.json ├── bin │ └── custom-oidc.js ├── cdk.context.json ├── cdk.json ├── jest.config.js ├── lambdas │ ├── key-gen │ │ ├── Dockerfile │ │ ├── app.py │ │ └── requirements.txt │ ├── lambda-authorizer │ │ └── app.py │ ├── oidc-issuer │ │ ├── Dockerfile │ │ ├── app.py │ │ └── requirements.txt │ └── q-biz │ │ ├── Dockerfile │ │ ├── app.py │ │ ├── boto3-1.35.59-py3-none-any.whl │ │ └── botocore-1.35.59-py3-none-any.whl ├── lib │ └── custom-oidc-stack.js ├── package-lock.json ├── package.json └── test │ └── custom-oidc.test.js ├── amzn-q-custom-ui ├── .gitignore ├── README.md ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── src │ ├── App.jsx │ ├── assets │ │ ├── amazonq.png │ │ └── user.png │ ├── component │ │ ├── Chatbot.jsx │ │ ├── elements │ │ │ ├── Alert.jsx │ │ │ ├── CannedQuestions.jsx │ │ │ ├── ChatHistoryMenu.jsx │ │ │ ├── ChatbotLoader.jsx │ │ │ ├── CitationMarker.jsx │ │ │ ├── FeedbackModal.jsx │ │ │ ├── Loader.jsx │ │ │ ├── Message.jsx │ │ │ ├── SourceCarousel.jsx │ │ │ └── TypingAnimation.jsx │ │ ├── hooks │ │ │ ├── useBreakpoint.jsx │ │ │ ├── useChatFeedback.jsx │ │ │ ├── useChatStream.jsx │ │ │ ├── useDeleteConversation.jsx │ │ │ ├── useListConversations.jsx │ │ │ ├── useMessages.jsx │ │ │ ├── useQBizCredentials.jsx │ │ │ └── useTemplateUtils.jsx │ │ ├── index.css │ │ ├── index.jsx │ │ ├── providers │ │ │ └── GlobalConfigContext.jsx │ │ └── utils │ │ │ ├── citationHelper.js │ │ │ ├── dateConverter.js │ │ │ ├── eventStreamDecoder.js │ │ │ ├── eventStreamEncoder.js │ │ │ ├── notifications_en.json │ │ │ ├── removeSystemPrompt.js │ │ │ └── thumbsdownFeedback.json │ ├── index.css │ └── main.jsx ├── tailwind.config.js └── vite.config.js ├── images ├── TVM_Arch_QUI.png ├── TVM_Arch_Standalone.png ├── gif-1.gif ├── gif-2.gif ├── sc-1.png ├── sc-2.png ├── sc-3.png └── sc-4.png └── sample-tvm-backend-usage ├── README.md ├── aim333 ├── aim333-module-2.ipynb ├── aim333-module-3.ipynb ├── boto3-1.35.59-py3-none-any.whl ├── botocore-1.35.59-py3-none-any.whl └── tvm_client.py ├── sample_tickets.zip ├── sample_usage.ipynb └── tvm_client.py /.gitignore: -------------------------------------------------------------------------------- 1 | oidcenv 2 | oidcenv/* 3 | oidcenv2 4 | oidcenv2/* 5 | node_modules 6 | test.ipynb 7 | __pycache__ 8 | service-2.json 9 | qbusiness 10 | qbusiness/* 11 | custom_service 12 | custom_service/* 13 | sample_tickets 14 | sample_tickets/* 15 | aim333-module-2.zip 16 | 17 | # CDK asset staging directory 18 | .cdk.staging 19 | cdk.out 20 | 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | pnpm-debug.log* 28 | lerna-debug.log* 29 | 30 | dist 31 | dist-ssr 32 | *.local 33 | 34 | # Editor directories and files 35 | .vscode/* 36 | !.vscode/extensions.json 37 | .idea 38 | .DS_Store 39 | *.suo 40 | *.ntvs* 41 | *.njsproj 42 | *.sln 43 | *.sw? 44 | 45 | TEMP.md 46 | IAM_Role.txt 47 | call_chat_sync.py 48 | testing.ipynb 49 | .env 50 | .env* -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Amazon Q Business Token Vending Machine and Custom UI 2 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon Q Business Token Vending Machine and QUI 2 | 3 | > [!IMPORTANT] 4 | > This solution requires using Amazon Q Business with [IAM Identity Provider](https://docs.aws.amazon.com/amazonq/latest/qbusiness-ug/create-application-iam.html) or Amazon Q Business in Anonymous mode, and does not support IAM Identity Center (IDC) based authentication setup. For calling Amazon Q Business APIs while using IDC, check [this GitHub repository](https://github.com/aws-samples/custom-web-experience-with-amazon-q-business). 5 | 6 | > [!TIP] 7 | > 💡 We highly recommend starting with [the wiki](https://github.com/aws-samples/custom-ui-tvm-amazon-q-business/wiki) before deploying. 8 | 9 | Deploy a fully customizable Amazon Q Business AI Assistant experience 10 | 11 | ### Deploy TVM (Token Vending Machine) for Amazon Q Business 12 | 13 | 1. Clone this repo and `cd` into `/amzn-q-auth-tvm` directory 14 | 2. Run `npm install --save` and create a `.env` file. 15 | 3. Enter the following in the `.env` file with the account details of where you want to deploy the stack 16 | 17 | ``` 18 | CDK_DEFAULT_ACCOUNT= 19 | CDK_DEFAULT_REGION= 20 | ``` 21 | 22 | 4. (Optional) If you intend to use TVM with Amazon Q Business custom UI (QUI) then edit the `amzn-q-auth-tvm/allow-list-domains.json` file to add your domain to the allow list. 23 | 5. `cdk bootstrap` 24 | 6. `cdk synth` 25 | 7. `cdk deploy --require-approval never --outputs-file ./cdk-outputs.json --profile ` 26 | 8. Once the stack is deployed note the following values from the stack's output 27 | 28 | ``` 29 | Outputs: 30 | MyOidcIssuerStack.AudienceOutput = xxxxxxx 31 | MyOidcIssuerStack.IssuerUrlOutput = https://xxxxxxx.execute-api..amazonaws.com/prod/ 32 | MyOidcIssuerStack.QBizAssumeRoleARN = arn:aws:iam::XXXXXXXX:role/q-biz-custom-oidc-assume-role 33 | 34 | ✨ Total time: 64.31s 35 | ``` 36 | 37 | 8. The stack will create the TVM (Audience and Issuer endpoints), an IAM Role to assume with Q Business permissions, an IAM Identity Provider already setup with the Issuer and Audience (You should be able to see this Identity Provider from IAM Console) 38 | 9. Setup a Q Business App, Select "AWS IAM Identity Provider" (**Note**: Uncheck "Web Experience" from "Outcome" when creating the Q Business App), select "OpenID Connect (OIDC)" provider type for authentication and select the above created Identity Provider from the drop down, in "Client ID" enter the Audience value from the stack output above `AudienceOutput` (also found in `cdk-outputs.json` file that captures the output of stack deployment, or in your Cloudformation stack deployment output). 39 | 10. Setup your Q Business App following the rest of the steps by adding data sources etc. 40 | 41 | ### Delete the TVM stack 42 | 43 | To delete the TVM stack- 44 | 45 | 1. Change into the TVM stack root directory 46 | 47 | ```bash 48 | cd amzn-q-auth-tvm 49 | ``` 50 | 51 | 2. Run 52 | 53 | ```bash 54 | cdk destroy 55 | ``` 56 | 57 | ### Deploy sample React App with Custom Amazon Q UI usage 58 | 59 | 1. Change directory to `amzn-q-custom-ui`. 60 | 2. Run `npm install --save` to install dependencies. 61 | 3. Create a `.env` file at the root of the directory with these values. 62 | 4. Note: the email should ideally be acquired by your user authentication mechanism. 63 | 64 | ``` 65 | VITE_QBIZ_APP_ID= 66 | VITE_IAM_ROLE_ARN= 67 | VITE_EMAIL= 68 | VITE_AWS_REGION= 69 | VITE_ISSUER= 70 | ``` 71 | 72 | > NOTE: For production you will need a similar file called `.env.production` 73 | 74 | 4. Run `npm run dev` 75 | 5. Visit your app in `localhost` URL provided by Vite local server 76 | 77 | -------------------------------------------------------------------------------- /THIRD_PARTY_LICENSES.md: -------------------------------------------------------------------------------- 1 | Amazon Q Custom UI includes the following third-party software/licensing: 2 | 3 | React 4 | 5 | MIT License 6 | 7 | Copyright (c) Meta Platforms, Inc. and affiliates. 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | 27 | ---------------- 28 | 29 | PrismJS 30 | 31 | MIT LICENSE 32 | 33 | Copyright (c) 2012 Lea Verou 34 | 35 | Permission is hereby granted, free of charge, to any person obtaining a copy 36 | of this software and associated documentation files (the "Software"), to deal 37 | in the Software without restriction, including without limitation the rights 38 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 39 | copies of the Software, and to permit persons to whom the Software is 40 | furnished to do so, subject to the following conditions: 41 | 42 | The above copyright notice and this permission notice shall be included in 43 | all copies or substantial portions of the Software. 44 | 45 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 46 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 47 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 48 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 49 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 50 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 51 | THE SOFTWARE. 52 | 53 | ---------------- 54 | 55 | react-markdown 56 | 57 | The MIT License (MIT) 58 | 59 | Copyright (c) Espen Hovlandsdal 60 | 61 | Permission is hereby granted, free of charge, to any person obtaining a copy 62 | of this software and associated documentation files (the "Software"), to deal 63 | in the Software without restriction, including without limitation the rights 64 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 65 | copies of the Software, and to permit persons to whom the Software is 66 | furnished to do so, subject to the following conditions: 67 | 68 | The above copyright notice and this permission notice shall be included in all 69 | copies or substantial portions of the Software. 70 | 71 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 72 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 73 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 74 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 75 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 76 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 77 | SOFTWARE. 78 | 79 | ---------------- 80 | 81 | react-syntax-highlighter 82 | 83 | MIT License 84 | 85 | Copyright (c) 2019 Conor Hastings 86 | 87 | Permission is hereby granted, free of charge, to any person obtaining a copy 88 | of this software and associated documentation files (the "Software"), to deal 89 | in the Software without restriction, including without limitation the rights 90 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 91 | copies of the Software, and to permit persons to whom the Software is 92 | furnished to do so, subject to the following conditions: 93 | 94 | The above copyright notice and this permission notice shall be included in all 95 | copies or substantial portions of the Software. 96 | 97 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 98 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 99 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 100 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 101 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 102 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 103 | SOFTWARE. 104 | 105 | ---------------- 106 | 107 | rehype-raw 108 | remark-gfm 109 | remark-slug 110 | 111 | (The MIT License) 112 | 113 | Copyright (c) 2016 Titus Wormer 114 | 115 | Permission is hereby granted, free of charge, to any person obtaining 116 | a copy of this software and associated documentation files (the 117 | 'Software'), to deal in the Software without restriction, including 118 | without limitation the rights to use, copy, modify, merge, publish, 119 | distribute, sublicense, and/or sell copies of the Software, and to 120 | permit persons to whom the Software is furnished to do so, subject to 121 | the following conditions: 122 | 123 | The above copyright notice and this permission notice shall be 124 | included in all copies or substantial portions of the Software. 125 | 126 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 127 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 128 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 129 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 130 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 131 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 132 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 133 | 134 | ---------------- 135 | 136 | uuidjs 137 | 138 | The MIT License (MIT) 139 | 140 | Copyright (c) 2010-2020 Robert Kieffer and other contributors 141 | 142 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 143 | 144 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 145 | 146 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 147 | 148 | ---------------- 149 | 150 | tailwindcss 151 | 152 | MIT License 153 | 154 | Copyright (c) Tailwind Labs, Inc. 155 | 156 | Permission is hereby granted, free of charge, to any person obtaining a copy 157 | of this software and associated documentation files (the "Software"), to deal 158 | in the Software without restriction, including without limitation the rights 159 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 160 | copies of the Software, and to permit persons to whom the Software is 161 | furnished to do so, subject to the following conditions: 162 | 163 | The above copyright notice and this permission notice shall be included in all 164 | copies or substantial portions of the Software. 165 | 166 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 167 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 168 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 169 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 170 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 171 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 172 | SOFTWARE. 173 | 174 | ---------------- 175 | 176 | vitejs/vite 177 | 178 | MIT License 179 | 180 | Copyright (c) 2019-present, VoidZero Inc. and Vite contributors 181 | 182 | Permission is hereby granted, free of charge, to any person obtaining a copy 183 | of this software and associated documentation files (the "Software"), to deal 184 | in the Software without restriction, including without limitation the rights 185 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 186 | copies of the Software, and to permit persons to whom the Software is 187 | furnished to do so, subject to the following conditions: 188 | 189 | The above copyright notice and this permission notice shall be included in all 190 | copies or substantial portions of the Software. 191 | 192 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 193 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 194 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 195 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 196 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 197 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 198 | SOFTWARE. 199 | 200 | ---------------- 201 | 202 | Tanstack/query 203 | 204 | MIT License 205 | 206 | Copyright (c) 2021-present Tanner Linsley 207 | 208 | Permission is hereby granted, free of charge, to any person obtaining a copy 209 | of this software and associated documentation files (the "Software"), to deal 210 | in the Software without restriction, including without limitation the rights 211 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 212 | copies of the Software, and to permit persons to whom the Software is 213 | furnished to do so, subject to the following conditions: 214 | 215 | The above copyright notice and this permission notice shall be included in all 216 | copies or substantial portions of the Software. 217 | 218 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 219 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 220 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 221 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 222 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 223 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 224 | SOFTWARE. 225 | 226 | ---------------- 227 | 228 | react-detect-offline 229 | 230 | MIT License 231 | 232 | Copyright (c) 2017-2021 Chris Bolin 233 | Copyright (c) 2021 Cody Wise 234 | 235 | Permission is hereby granted, free of charge, to any person obtaining a copy 236 | of this software and associated documentation files (the "Software"), to deal 237 | in the Software without restriction, including without limitation the rights 238 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 239 | copies of the Software, and to permit persons to whom the Software is 240 | furnished to do so, subject to the following conditions: 241 | 242 | The above copyright notice and this permission notice shall be included in all 243 | copies or substantial portions of the Software. 244 | 245 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 246 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 247 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 248 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 249 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 250 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 251 | SOFTWARE. 252 | 253 | ---------------- 254 | 255 | Lucide License 256 | ISC License 257 | 258 | Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. 259 | 260 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 261 | 262 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 263 | 264 | ---------------- 265 | 266 | Amazon Q Token Vending Machine includes the following third-party software/licensing: 267 | 268 | cryptography 269 | 270 | https://github.com/pyca/cryptography/blob/main/LICENSE 271 | 272 | This software is made available under the terms of *either* of the licenses 273 | found in LICENSE.APACHE or LICENSE.BSD. Contributions to cryptography are made 274 | under the terms of *both* these licenses. 275 | 276 | ---------------- 277 | 278 | authlib 279 | https://github.com/lepture/authlib/blob/master/LICENSE 280 | 281 | BSD 3-Clause License 282 | 283 | Copyright (c) 2017, Hsiaoming Yang 284 | All rights reserved. 285 | 286 | Redistribution and use in source and binary forms, with or without 287 | modification, are permitted provided that the following conditions are met: 288 | 289 | * Redistributions of source code must retain the above copyright notice, this 290 | list of conditions and the following disclaimer. 291 | 292 | * Redistributions in binary form must reproduce the above copyright notice, 293 | this list of conditions and the following disclaimer in the documentation 294 | and/or other materials provided with the distribution. 295 | 296 | * Neither the name of the copyright holder nor the names of its 297 | contributors may be used to endorse or promote products derived from 298 | this software without specific prior written permission. 299 | 300 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 301 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 302 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 303 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 304 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 305 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 306 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 307 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 308 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 309 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /amzn-q-auth-tvm/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # CDK asset staging directory 4 | .cdk.staging 5 | cdk.out 6 | .DS_Store 7 | .env -------------------------------------------------------------------------------- /amzn-q-auth-tvm/.npmignore: -------------------------------------------------------------------------------- 1 | # CDK asset staging directory 2 | .cdk.staging 3 | cdk.out 4 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/README.md: -------------------------------------------------------------------------------- 1 | # Amazon Q Business Token Vending Maching (TVM) CDK Stack 2 | 3 | First install the dependencies 4 | 5 | ```bash 6 | cd amzn-q-auth-tvm 7 | npm install --save 8 | ``` 9 | 10 | Create a `.env` file with following values 11 | 12 | ``` 13 | CDK_DEFAULT_ACCOUNT= 14 | CDK_DEFAULT_REGION= 15 | ``` 16 | 17 | Perform CDK synth 18 | 19 | ```bash 20 | cdk synth 21 | ``` 22 | 23 | Deploy 24 | 25 | ```bash 26 | cdk deploy 27 | ``` -------------------------------------------------------------------------------- /amzn-q-auth-tvm/allow-list-domains.json: -------------------------------------------------------------------------------- 1 | { 2 | "allowList":[ 3 | "http://localhost:5174", 4 | "https://www.my-cool-website.com", 5 | "https://mysubdomain.my-cool-website.com" 6 | ] 7 | } -------------------------------------------------------------------------------- /amzn-q-auth-tvm/bin/custom-oidc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('dotenv').config() 3 | const cdk = require('aws-cdk-lib'); 4 | const { TVMOidcIssuerStack } = require('../lib/custom-oidc-stack'); 5 | const { AwsSolutionsChecks, NagSuppressions } = require('cdk-nag') 6 | 7 | const app = new cdk.App(); 8 | const stack = new TVMOidcIssuerStack(app, 'TVMOidcIssuerStack', { 9 | env: { 10 | account: process.env.CDK_DEFAULT_ACCOUNT, 11 | region: process.env.CDK_DEFAULT_REGION 12 | }, 13 | deployQbiz: process.env.CDK_DEPLOY_Q_BIZ_APP === 'true' 14 | }); 15 | 16 | cdk.Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })); 17 | 18 | /** 19 | * Add nag suppressions for known architectural decisions 20 | */ 21 | NagSuppressions.addStackSuppressions(stack, [ 22 | { id: 'AwsSolutions-COG4', reason: 'API Gateway uses custom Lambda Authorizer for /token endpoint' }, 23 | { id: 'AwsSolutions-APIG4', reason: 'API Gateway uses custom Lambda Authorizer for /token endpoint' }, 24 | { id: 'AwsSolutions-APIG3', reason: 'Customer to decide if WAF is required since there are costs associated' }, 25 | { id: 'AwsSolutions-APIG1', reason: 'API Gateway default logging is enabled via deployOptions' }, 26 | { id: 'AwsSolutions-IAM4', reason: 'AWSLambdaBasicExecutionRole is scoped in`' }, 27 | { id: 'AwsSolutions-IAM5', reason: 'CreateOpenIDConnectProvider requires oidc-provider/*' }, 28 | { id: 'AwsSolutions-L1', reason: 'Python 3.11 to stay compatible for Authlib' }, 29 | { id: 'AwsSolutions-APIG2', reason: 'REST API request validation is handled by Lambda Flask' }, 30 | ]); 31 | 32 | 33 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "acknowledged-issue-numbers": [ 3 | 31716 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "node bin/custom-oidc.js", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "jest.config.js", 11 | "package*.json", 12 | "yarn.lock", 13 | "node_modules", 14 | "test" 15 | ] 16 | }, 17 | "context": { 18 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 19 | "@aws-cdk/core:checkSecretUsage": true, 20 | "@aws-cdk/core:target-partitions": [ 21 | "aws", 22 | "aws-cn" 23 | ], 24 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 25 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 26 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 27 | "@aws-cdk/aws-iam:minimizePolicies": true, 28 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 29 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 30 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 31 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 32 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 33 | "@aws-cdk/core:enablePartitionLiterals": true, 34 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 35 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 36 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 37 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 38 | "@aws-cdk/aws-route53-patters:useCertificate": true, 39 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 40 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 41 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 42 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 43 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 44 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 45 | "@aws-cdk/aws-redshift:columnId": true, 46 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, 47 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, 48 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, 49 | "@aws-cdk/aws-kms:aliasNameRef": true, 50 | "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, 51 | "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, 52 | "@aws-cdk/aws-efs:denyAnonymousAccess": true, 53 | "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, 54 | "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, 55 | "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, 56 | "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, 57 | "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, 58 | "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, 59 | "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, 60 | "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, 61 | "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, 62 | "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, 63 | "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, 64 | "@aws-cdk/aws-eks:nodegroupNameAttribute": true, 65 | "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, 66 | "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, 67 | "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, 68 | "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, 69 | "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, 70 | "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, 71 | "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, 72 | "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, 73 | "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, 74 | "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node' 3 | } 4 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/key-gen/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/lambda/python:3.12-x86_64 2 | 3 | # Copy function code 4 | COPY app.py ${LAMBDA_TASK_ROOT}/ 5 | 6 | # Install dependencies 7 | RUN pip install --upgrade pip 8 | COPY requirements.txt . 9 | RUN pip install -U -r requirements.txt --target "${LAMBDA_TASK_ROOT}" 10 | 11 | # Set the CMD to your handler (app.lambda_handler) 12 | CMD ["app.lambda_handler"] 13 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/key-gen/app.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import json 5 | import boto3 6 | import os 7 | import uuid 8 | from cryptography.hazmat.primitives.asymmetric import rsa 9 | from cryptography.hazmat.primitives import serialization 10 | from cryptography.hazmat.backends import default_backend 11 | 12 | import logging 13 | 14 | log_level = os.environ.get('LOG_LEVEL', 'DEBUG') 15 | logger = logging.getLogger(__name__) 16 | logger.setLevel(log_level) 17 | 18 | ssm_client = boto3.client('ssm') 19 | sts = boto3.client('sts') 20 | 21 | def lambda_handler(event, context): 22 | logger.debug(json.dumps(event)) 23 | 24 | request_type = event['RequestType'] 25 | 26 | if request_type == "Create": 27 | try: 28 | logger.info("Generating private key...") 29 | # Generate RSA Private Key 30 | private_key = rsa.generate_private_key( 31 | public_exponent=65537, 32 | key_size=2048, 33 | backend=default_backend() 34 | ) 35 | 36 | # Serialize private key 37 | logger.info("Encrypting private key...") 38 | private_pem = private_key.private_bytes( 39 | encoding=serialization.Encoding.PEM, 40 | format=serialization.PrivateFormat.TraditionalOpenSSL, 41 | encryption_algorithm=serialization.NoEncryption() 42 | ) 43 | 44 | # Serialize public key 45 | logger.info("Generating public key for private key...") 46 | public_key = private_key.public_key() 47 | public_pem = public_key.public_bytes( 48 | encoding=serialization.Encoding.PEM, 49 | format=serialization.PublicFormat.SubjectPublicKeyInfo 50 | ) 51 | 52 | # Store the private key in SSM Parameter Store (encrypted) 53 | logger.info("Storing encrypted private key into SSM Parameter store...") 54 | ssm_client.put_parameter( 55 | Name='/oidc/private_key', 56 | Value=private_pem.decode('utf-8'), 57 | Type='SecureString', 58 | Overwrite=True 59 | ) 60 | 61 | # Store the public key in SSM Parameter Store (encrypted) 62 | logger.info("Storing public key into SSM Parameter store...") 63 | ssm_client.put_parameter( 64 | Name='/oidc/public_key', 65 | Value=public_pem.decode('utf-8'), 66 | Type='SecureString', 67 | Overwrite=True 68 | ) 69 | 70 | # account_id = sts.get_caller_identity()['Account'] 71 | # client_id = f"oidc-tvm-{account_id}" 72 | # client_secret = uuid.uuid4().hex 73 | # # Store the client id in SSM Parameter Store (encrypted) 74 | # logger.info("Storing client ID into SSM Parameter store...") 75 | # ssm_client.put_parameter( 76 | # Name='/oidc/client_id', 77 | # Value=client_id, 78 | # Type='String', 79 | # Overwrite=True 80 | # ) 81 | 82 | # # Store the client secret in SSM Parameter Store (encrypted) 83 | # logger.info("Storing client secret into SSM Parameter store...") 84 | # ssm_client.put_parameter( 85 | # Name='/oidc/client_secret', 86 | # Value=client_secret, 87 | # Type='SecureString', 88 | # Overwrite=True 89 | # ) 90 | 91 | logger.info('RSA key pair successfully generated and stored in SSM.') 92 | return { 93 | 'statusCode': 200, 94 | 'body': json.dumps('RSA key pair successfully generated and stored in SSM.') 95 | } 96 | 97 | except Exception as e: 98 | return { 99 | 'statusCode': 500, 100 | 'body': json.dumps(f'Error generating or storing key: {str(e)}') 101 | } 102 | 103 | if request_type == "Delete": 104 | try: 105 | ssm_client.delete_parameters( 106 | Names=[ 107 | '/oidc/public_key', 108 | '/oidc/private_key', 109 | '/oidc/client_id', 110 | '/oidc/client_secret' 111 | ] 112 | ) 113 | logger.info('RSA key pair, client ID, and secret successfully deleted from SSM.') 114 | except Exception as e: 115 | return { 116 | 'statusCode': 500, 117 | 'body': json.dumps(f'Error deleting key in parameter store: {str(e)}') 118 | } -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/key-gen/requirements.txt: -------------------------------------------------------------------------------- 1 | cryptography 2 | boto3 -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/lambda-authorizer/app.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import os 5 | import json 6 | import boto3 7 | import base64 8 | import logging 9 | 10 | log_level = os.environ.get('LOG_LEVEL', 'DEBUG') 11 | logger = logging.getLogger(__name__) 12 | logger.setLevel(log_level) 13 | 14 | ssm_client = boto3.client('ssm') 15 | 16 | # SSM Parameter Name 17 | ALLOW_LIST_PARAM = os.getenv('OIDC_ALLOW_LIST') 18 | CLIENT_ID_PARAM = os.getenv("CLIENT_ID_PARAM") 19 | CLIENT_SECRET_PARAM = os.getenv("CLIENT_SECRET_PARAM") 20 | 21 | def get_client_credentials(): 22 | client_id = ssm_client.get_parameter(Name=CLIENT_ID_PARAM)['Parameter']['Value'] 23 | client_secret = ssm_client.get_parameter(Name=CLIENT_SECRET_PARAM, WithDecryption=True)['Parameter']['Value'] 24 | return client_id, client_secret 25 | 26 | # Fetch the allow-listed domains from SSM 27 | def get_allow_list(): 28 | try: 29 | response = ssm_client.get_parameter(Name=ALLOW_LIST_PARAM) 30 | allow_list = response['Parameter']['Value'].split(',') 31 | return [domain.strip() for domain in allow_list] 32 | except Exception as e: 33 | logger.error(f"Error fetching allow-list: {str(e)}") 34 | return [] 35 | 36 | def lambda_handler(event, context): 37 | logger.debug(json.dumps(event)) 38 | method_arn = event['methodArn'] 39 | # path = event['requestContext']['resourcePath'] 40 | headers = event.get('headers', {}) 41 | origin = headers.get('origin') # Get the Origin header 42 | auth_header = headers.get('Authorization') # Get the Authorization header 43 | 44 | logger.info(f"Request origin: {origin}") 45 | 46 | if event['httpMethod'] == 'OPTIONS': 47 | return generate_policy("Allow", method_arn) 48 | 49 | allow_list = get_allow_list() 50 | stored_client_id, stored_client_secret = get_client_credentials() 51 | 52 | if origin in allow_list: 53 | # Scenario 1: Allow-listed origin, allow the request without checking credentials 54 | logger.info("Request from authorized domain...") 55 | return generate_policy("Allow", method_arn) 56 | 57 | elif auth_header and auth_header.startswith("Basic "): 58 | # Scenario 2: Backend caller with client ID and secret in the header 59 | logger.info("Request from unauthorized domain...checking client ID and secret") 60 | encoded_credentials = auth_header.split(' ')[1] 61 | decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8') 62 | client_id, client_secret = decoded_credentials.split(':') 63 | 64 | if client_id == stored_client_id and client_secret == stored_client_secret: 65 | return generate_policy("Allow", method_arn) 66 | else: 67 | return generate_policy("Deny", method_arn) 68 | 69 | # Deny the request if neither allow-listed origin nor valid credentials are provided 70 | return generate_policy("Deny", method_arn) 71 | 72 | def generate_policy(effect, resource): 73 | """Generate an IAM policy.""" 74 | policy = { 75 | "principalId": "user", 76 | "policyDocument": { 77 | "Version": "2012-10-17", 78 | "Statement": [ 79 | { 80 | "Action": "execute-api:Invoke", 81 | "Effect": effect, 82 | "Resource": resource 83 | } 84 | ] 85 | } 86 | } 87 | return policy -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/oidc-issuer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/lambda/python:3.12-x86_64 2 | 3 | # Copy function code 4 | COPY app.py ${LAMBDA_TASK_ROOT}/ 5 | 6 | # Install dependencies 7 | RUN pip install --upgrade pip 8 | COPY requirements.txt . 9 | RUN pip install -U -r requirements.txt --target "${LAMBDA_TASK_ROOT}" 10 | 11 | # Set the CMD to your handler (app.lambda_handler) 12 | CMD ["app.lambda_handler"] 13 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/oidc-issuer/app.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import json 5 | import os 6 | import boto3 7 | import datetime 8 | import logging 9 | from authlib.jose import jwt, JsonWebKey 10 | 11 | log_level = os.environ.get('LOG_LEVEL', 'DEBUG') 12 | logger = logging.getLogger(__name__) 13 | logger.setLevel(log_level) 14 | 15 | # Fetch environment variables 16 | PRIVATE_KEY_PARAM = os.getenv('PRIVATE_KEY_PARAM') 17 | PUBLIC_KEY_PARAM = os.getenv('PUBLIC_KEY_PARAM') 18 | KID = os.getenv('KID') 19 | REGION = os.getenv('REGION') 20 | AUDIENCE = os.getenv('AUDIENCE') 21 | 22 | ssm_client = boto3.client('ssm') 23 | 24 | # Fetch private and public keys from SSM 25 | def get_private_key(): 26 | response = ssm_client.get_parameter(Name=PRIVATE_KEY_PARAM, WithDecryption=True) 27 | return response['Parameter']['Value'] 28 | 29 | def get_public_key(): 30 | response = ssm_client.get_parameter(Name=PUBLIC_KEY_PARAM, WithDecryption=True) 31 | return response['Parameter']['Value'] 32 | 33 | def lambda_handler(event, context): 34 | path = event['requestContext']['resourcePath'] 35 | logger.info(f"Endpoint: {path}") 36 | logger.debug(json.dumps(event)) 37 | 38 | if path == '/token': 39 | return handle_token(event) 40 | elif path == '/.well-known/openid-configuration': 41 | return handle_openid_configuration(event) 42 | elif path == '/.well-known/jwks.json': 43 | return handle_jwks(event) 44 | else: 45 | return { 46 | 'statusCode': 404, 47 | 'body': json.dumps({'error': 'Not Found'}) 48 | } 49 | 50 | def handle_token(event): 51 | try: 52 | body = json.loads(event['body']) 53 | email = body.get('email') 54 | 55 | domain = event['requestContext']['domainName'] 56 | stage = event['requestContext']['stage'] 57 | issuer_url = f"https://{domain}/{stage}" 58 | 59 | if not email: 60 | return { 61 | 'statusCode': 400, 62 | 'body': json.dumps({'error': 'email parameter is required'}) 63 | } 64 | 65 | private_key = get_private_key() 66 | 67 | # Create token claims 68 | claims = { 69 | "sub": email, 70 | "aud": AUDIENCE, 71 | "iss": issuer_url, 72 | "iat": datetime.datetime.now(datetime.timezone.utc), 73 | "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1), 74 | "email": email, 75 | "https://aws.amazon.com/tags": { 76 | "principal_tags": { 77 | "Email": [email] 78 | } 79 | } 80 | } 81 | 82 | # Encode JWT with RS256 83 | header = { 84 | "alg": "RS256", 85 | "kid": KID, 86 | "typ": "JWS" 87 | } 88 | 89 | token = jwt.encode(header=header, payload=claims, key=private_key) 90 | return { 91 | 'isBase64Encoded': False, 92 | 'statusCode': 200, 93 | 'headers': { 94 | 'Access-Control-Allow-Origin': '*', 95 | 'Access-Control-Allow-Headers': '*', 96 | 'Access-Control-Allow-Methods': '*', 97 | 'Content-Type': 'application/json' 98 | }, 99 | 'body': json.dumps({'id_token': token.decode('utf-8')}) 100 | } 101 | 102 | except Exception as e: 103 | return { 104 | 'statusCode': 500, 105 | 'body': json.dumps({'error': str(e)}) 106 | } 107 | 108 | def handle_openid_configuration(event): 109 | domain = event['requestContext']['domainName'] 110 | stage = event['requestContext']['stage'] 111 | issuer_url = f"https://{domain}/{stage}" 112 | 113 | openid_config = { 114 | "issuer": issuer_url, # Dynamic issuer 115 | "jwks_uri": f"{issuer_url}/.well-known/jwks.json", # JWKS URI 116 | "response_types_supported": ["id_token"], 117 | "subject_types_supported": ["public"], 118 | "id_token_signing_alg_values_supported": ["RS256"] 119 | } 120 | return { 121 | 'isBase64Encoded': False, 122 | 'statusCode': 200, 123 | 'headers': { 124 | 'Access-Control-Allow-Origin': '*', 125 | 'Access-Control-Allow-Headers': '*', 126 | 'Access-Control-Allow-Methods': '*', 127 | 'Content-Type': 'application/json' 128 | }, 129 | 'body': json.dumps(openid_config) 130 | } 131 | 132 | def handle_jwks(event): 133 | public_key = get_public_key() 134 | 135 | # Convert public key to JWKS format 136 | jwk = JsonWebKey.import_key(public_key, {'kid': KID, 'alg': 'RS256', 'use': 'sig'}) 137 | jwks = { 138 | "keys": [jwk.as_dict()] 139 | } 140 | return { 141 | 'isBase64Encoded': False, 142 | 'statusCode': 200, 143 | 'headers': { 144 | 'Access-Control-Allow-Origin': '*', 145 | 'Access-Control-Allow-Headers': '*', 146 | 'Access-Control-Allow-Methods': '*', 147 | 'Content-Type': 'application/json' 148 | }, 149 | 'body': json.dumps(jwks) 150 | } 151 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/oidc-issuer/requirements.txt: -------------------------------------------------------------------------------- 1 | authlib 2 | boto3 -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/q-biz/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/lambda/python:3.10-x86_64 2 | 3 | # Copy function code 4 | COPY app.py ${LAMBDA_TASK_ROOT}/ 5 | 6 | # Install dependencies 7 | RUN pip install --upgrade pip 8 | COPY boto3-1.35.59-py3-none-any.whl . 9 | COPY botocore-1.35.59-py3-none-any.whl . 10 | RUN pip install cfnresponse ./botocore-1.35.59-py3-none-any.whl ./boto3-1.35.59-py3-none-any.whl --target "${LAMBDA_TASK_ROOT}" 11 | 12 | # Set the CMD to your handler (app.lambda_handler) 13 | CMD ["app.lambda_handler"] 14 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/q-biz/app.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | import os 4 | import boto3 5 | import cfnresponse 6 | from concurrent.futures import ThreadPoolExecutor, as_completed 7 | 8 | qbusiness_client = boto3.client('qbusiness') 9 | logger = logging.getLogger(__name__) 10 | 11 | MAX_RETRIES=25 12 | DELAY=25 13 | 14 | def wait_for_all_data_sources(application_id, index_id, data_sources): 15 | max_retries=MAX_RETRIES 16 | delay=DELAY 17 | 18 | def wait_for_data_source(data_source_id): 19 | for attempt in range(max_retries): 20 | try: 21 | response = qbusiness_client.get_data_source( 22 | applicationId=application_id, 23 | indexId=index_id, 24 | dataSourceId=data_source_id 25 | ) 26 | status = response['status'] 27 | logger.info(f"Polling attempt {attempt + 1}: Data source {data_source_id} status is '{status}'") 28 | 29 | if status == "ACTIVE": 30 | logger.info(f"Data source {data_source_id} is now ACTIVE.") 31 | return data_source_id, "ACTIVE" 32 | elif status == "FAILED": 33 | logger.info(f"Data source {data_source_id} failed.") 34 | return data_source_id, "FAILED" 35 | except Exception as e: 36 | logger.error(f"An error occurred for data source {data_source_id}: {e}") 37 | raise e 38 | time.sleep(delay) 39 | logger.info(f"Data source {data_source_id} did not reach a terminal state within the timeout.") 40 | return data_source_id, "TIMEOUT" 41 | results = {} 42 | with ThreadPoolExecutor(max_workers=len(data_sources)) as executor: 43 | future_to_data_source = {executor.submit(wait_for_data_source, ds_id): ds_id for ds_id in data_sources} 44 | for future in as_completed(future_to_data_source): 45 | ds_id = future_to_data_source[future] 46 | try: 47 | ds_id, status = future.result() 48 | results[ds_id] = status 49 | except Exception as e: 50 | print(f"An error occurred while waiting for data source {ds_id}: {e}") 51 | results[ds_id] = "ERROR" 52 | return results 53 | 54 | 55 | def wait_for_index_completion(application_id, index_id): 56 | max_retries=MAX_RETRIES 57 | delay=DELAY 58 | for attempt in range(max_retries): 59 | try: 60 | index_waiter = qbusiness_client.get_index(applicationId=application_id, indexId=index_id) 61 | status = index_waiter['status'] 62 | logger.info(f"Polling attempt {attempt + 1}: Current status of index {index_id} is '{status}'") 63 | if status == "ACTIVE": 64 | print(f"Index {index_id} is now ACTIVE.") 65 | return status 66 | elif status == "FAILED": 67 | print(f"Index {index_id} failed to reach ACTIVE status.") 68 | return status 69 | except Exception as e: 70 | logger.error(f"An error occurred while checking index status: {e}") 71 | raise e 72 | time.sleep(delay) 73 | 74 | logger.info(f"Maximum retries reached. Index {index_id} did not reach a terminal state.") 75 | return "TIMEOUT" 76 | 77 | def lambda_handler(event, context): 78 | application_name = os.environ.get('Q_BIZ_APP_NAME', 'my-test-q-business-app') 79 | iam_idp_provier_arn = os.environ.get('IAM_PROVIDER_ARN', '') 80 | iam_idp_client_ids = os.environ.get('IAM_PROVIDER_AUDIENCE', '') 81 | s3_bucket_name = os.environ.get('Q_BIZ_S3_SOURCE_BKT', '') 82 | s3_data_source_name = 'S3DataSource' 83 | webcrawler_data_source_name = 'WebCrawlerDataSource' 84 | webcrawler_seed_urls = os.environ.get('Q_BIZ_SEED_URL', '').split(",") 85 | ds_role_arn = os.environ.get('DATA_SOURCE_ROLE', '') 86 | 87 | response_data = {} 88 | logger.info(f"Creating Q Business application with name {application_name}, datasources bucket {s3_bucket_name} and URLS {webcrawler_seed_urls}") 89 | 90 | try: 91 | if event['RequestType'] == 'Create': 92 | # Create Amazon Q Business Application 93 | create_app_response = qbusiness_client.create_application( 94 | displayName=application_name, 95 | identityType= 'AWS_IAM_IDP_OIDC', 96 | iamIdentityProviderArn=iam_idp_provier_arn, 97 | clientIdsForOIDC=[iam_idp_client_ids], 98 | attachmentsConfiguration={ 99 | 'attachmentsControlMode': 'DISABLED' 100 | }, 101 | qAppsConfiguration={ 102 | 'qAppsControlMode': 'DISABLED' 103 | } 104 | ) 105 | application_id = create_app_response['applicationId'] 106 | response_data['ApplicationId'] = application_id 107 | logger.info(f'Application created with ID: {application_id}') 108 | 109 | logger.info(f'Turning on creator mode for application : {application_id}') 110 | # This ensures auto subscription 111 | qbusiness_client.update_chat_controls_configuration( 112 | applicationId=application_id, 113 | creatorModeConfiguration={ 114 | 'creatorModeControl': 'ENABLED' 115 | } 116 | ) 117 | 118 | #create index 119 | response_index = qbusiness_client.create_index( 120 | applicationId=application_id, 121 | displayName='amzn-q-biz-index', 122 | type='ENTERPRISE', 123 | capacityConfiguration={ 124 | 'units': 1 125 | } 126 | ) 127 | index_id = response_index['indexId'] 128 | # Wait for index creation 129 | index_status = wait_for_index_completion(application_id, index_id) 130 | 131 | if index_status == "TIMEOUT" or index_status == "FAILED": 132 | raise Exception(f"Index creation failed with status : {index_status}") 133 | 134 | # Create Retriever 135 | retriever_response = qbusiness_client.create_retriever( 136 | applicationId=application_id, 137 | type='NATIVE_INDEX', 138 | displayName='q-business-native-inex-retriever', 139 | configuration={ 140 | 'nativeIndexConfiguration': { 141 | 'indexId': index_id 142 | } 143 | } 144 | ) 145 | logger.info(f"Successfully created retriever: {retriever_response['retrieverId']}") 146 | response_data['RetrieverId'] = retriever_response['retrieverId'] 147 | 148 | # Create S3 Data Source 149 | s3_data_source_config = { 150 | "type": "S3", 151 | "syncMode": "FULL_CRAWL", 152 | "connectionConfiguration": { 153 | "repositoryEndpointMetadata": { 154 | "BucketName": s3_bucket_name 155 | } 156 | }, 157 | "repositoryConfigurations": { 158 | "document": { 159 | "fieldMappings": [ 160 | { 161 | "indexFieldName": "s3_document_id", 162 | "indexFieldType": "STRING", 163 | "dataSourceFieldName": "s3_document_id" 164 | } 165 | ] 166 | } 167 | }, 168 | "additionalProperties": { 169 | "maxFileSizeInMegaBytes": "50" 170 | } 171 | } 172 | s3_data_source_response = qbusiness_client.create_data_source( 173 | applicationId=application_id, 174 | indexId=index_id, 175 | displayName=s3_data_source_name, 176 | configuration=s3_data_source_config, 177 | syncSchedule='', 178 | roleArn=ds_role_arn 179 | ) 180 | s3_data_source_id = s3_data_source_response['dataSourceId'] 181 | response_data['S3DataSourceId'] = s3_data_source_id 182 | logger.info(f'S3 data source created with ID: {s3_data_source_id}') 183 | 184 | # Create Web Crawler Data Source 185 | seedUrls = [{"seedUrl": i} for i in webcrawler_seed_urls] 186 | web_data_source_config = { 187 | "type": "WEBCRAWLERV2", 188 | "syncMode": "FULL_CRAWL", 189 | "connectionConfiguration": { 190 | "repositoryEndpointMetadata": { 191 | "seedUrlConnections": seedUrls, 192 | "authentication": "NoAuthentication" 193 | } 194 | }, 195 | "repositoryConfigurations": { 196 | "webPage": { 197 | "fieldMappings": [ 198 | { 199 | "indexFieldName": "title", 200 | "indexFieldType": "STRING", 201 | "dataSourceFieldName": "page_title", 202 | "dateFieldFormat": "yyyy-MM-dd'T'HH:mm:ss'Z'" 203 | } 204 | ] 205 | }, 206 | "attachment": { 207 | "fieldMappings": [ 208 | { 209 | "indexFieldName": "attachment_title", 210 | "indexFieldType": "STRING", 211 | "dataSourceFieldName": "attachment_name", 212 | "dateFieldFormat": "yyyy-MM-dd'T'HH:mm:ss'Z'" 213 | } 214 | ] 215 | } 216 | }, 217 | "additionalProperties": { 218 | "rateLimit": "300", 219 | "maxFileSize": "50", 220 | "crawlDepth": "1", 221 | "maxLinksPerUrl": "1", 222 | "crawlSubDomain": "true", 223 | "crawlAllDomain": "true", 224 | "honorRobots": "true" 225 | } 226 | } 227 | webcrawler_data_source_response = qbusiness_client.create_data_source( 228 | applicationId=application_id, 229 | indexId=index_id, 230 | displayName=webcrawler_data_source_name, 231 | configuration=web_data_source_config, 232 | syncSchedule='', 233 | roleArn=ds_role_arn 234 | ) 235 | webcrawler_data_source_id = webcrawler_data_source_response['dataSourceId'] 236 | response_data['WebCrawlerDataSourceId'] = webcrawler_data_source_id 237 | logger.info(f'Web crawler data source created with ID: {webcrawler_data_source_id}') 238 | 239 | # Check for status of the data sources 240 | data_source_ids = [s3_data_source_id, webcrawler_data_source_id] 241 | final_statuses = wait_for_all_data_sources(application_id, index_id, data_source_ids) 242 | 243 | if all(status == "ACTIVE" for status in final_statuses.values()): 244 | logger.info("Both data sources are ACTIVE. Proceeding to start S3 and Webcrawler sync...") 245 | for ds_id in data_source_ids: 246 | qbusiness_client.start_data_source_sync_job( 247 | dataSourceId=ds_id, 248 | applicationId=application_id, 249 | indexId=index_id 250 | ) 251 | logger.info(f"Started sync for: {ds_id}") 252 | logger.info("Both Sync's initiated. Done...") 253 | 254 | logger.info(f'Turning on auto subscription for application : {application_id}') 255 | # Ensures auto subscription is turned on for OIDC users 256 | qbusiness_client.update_application( 257 | applicationId=application_id, 258 | autoSubscriptionConfiguration={ 259 | 'autoSubscribe': 'ENABLED', 260 | 'defaultSubscriptionType': 'Q_BUSINESS' 261 | } 262 | ) 263 | else: 264 | raise Exception(f"One or more data sources failed or timed out: {final_statuses}") 265 | 266 | cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data, application_id) 267 | return {"message": "Successfully created", "data": response_data} 268 | elif event['RequestType'] == 'Delete': 269 | # Retrieve the ApplicationId from the event's PhysicalResourceId 270 | application_id = event.get('PhysicalResourceId') 271 | if application_id and application_id != 'FAILED': 272 | # Delete the Amazon Q Business Application 273 | qbusiness_client.delete_application(applicationId=application_id) 274 | logger.info(f'Application with ID {application_id} deleted.') 275 | cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data) 276 | return {"message": "Successfully deleted"} 277 | except Exception as e: 278 | logger.error(f'An unexpected error occurred: {e}') 279 | cfnresponse.send(event, context, cfnresponse.FAILED, response_data) 280 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/q-biz/boto3-1.35.59-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/amzn-q-auth-tvm/lambdas/q-biz/boto3-1.35.59-py3-none-any.whl -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lambdas/q-biz/botocore-1.35.59-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/amzn-q-auth-tvm/lambdas/q-biz/botocore-1.35.59-py3-none-any.whl -------------------------------------------------------------------------------- /amzn-q-auth-tvm/lib/custom-oidc-stack.js: -------------------------------------------------------------------------------- 1 | const cdk = require('aws-cdk-lib'); 2 | const { Stack, Duration } = cdk; 3 | const lambda = require('aws-cdk-lib/aws-lambda'); 4 | const apigateway = require('aws-cdk-lib/aws-apigateway'); 5 | const ssm = require('aws-cdk-lib/aws-ssm'); 6 | const iam = require('aws-cdk-lib/aws-iam'); 7 | const custom_resources = require('aws-cdk-lib/custom-resources'); 8 | const allowListedDomains = require("../allow-list-domains.json"); 9 | const { randomBytes } = require('crypto'); 10 | require('dotenv').config() 11 | 12 | class TVMOidcIssuerStack extends Stack { 13 | constructor(scope, id, props) { 14 | super(scope, id, props); 15 | 16 | const region = this.region; 17 | const accountId = this.account; 18 | const keyId = `${region}-kid`; 19 | const secretID = randomBytes(12).toString('hex'); 20 | 21 | // Generate a deterministic Audience for the OIDC issuer 22 | const audience = `${this.region}-${this.account}-tvm`; 23 | 24 | //Create allow-listed domains parameters in SSM 25 | new ssm.StringParameter(this, 'OIDCAllowListParameter', { 26 | parameterName: '/oidc/allow-list', 27 | stringValue: allowListedDomains.allowList.join(','), 28 | description: 'The Allow listed domains for TVM OIDC Provider' 29 | }); 30 | 31 | //Client ID 32 | new ssm.StringParameter(this, 'OIDCClientId', { 33 | parameterName: '/oidc/client_id', 34 | stringValue: `oidc-tvm-${this.account}`, 35 | description: 'The Client ID for TVM provider' 36 | }); 37 | 38 | //Client Secret 39 | new ssm.StringParameter(this, 'OIDCClientSecret', { 40 | parameterName: '/oidc/client_secret', 41 | stringValue: secretID, 42 | description: 'The Client ID for TVM provider' 43 | }); 44 | 45 | // IAM Role for Key Generation Lambda 46 | const keyGenLambdaRole = new iam.Role(this, 'KeyGenLambdaRole', { 47 | roleName: 'tvm-key-gen-lambda-role', 48 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 49 | managedPolicies: [ 50 | iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole') 51 | ] 52 | }); 53 | 54 | keyGenLambdaRole.addToPolicy(new iam.PolicyStatement({ 55 | effect: iam.Effect.ALLOW, 56 | actions: ['ssm:PutParameter', 'ssm:DeleteParameter', "ssm:DeleteParameters"], 57 | resources: [`arn:aws:ssm:${region}:${accountId}:parameter/oidc/private_key`, 58 | `arn:aws:ssm:${region}:${accountId}:parameter/oidc/public_key`, 59 | `arn:aws:ssm:${region}:${accountId}:parameter/oidc/client_id`, 60 | `arn:aws:ssm:${region}:${accountId}:parameter/oidc/client_secret`] 61 | })); 62 | 63 | // IAM Role for OIDC Lambda 64 | const oidcLambdaRole = new iam.Role(this, 'OidcLambdaRole', { 65 | roleName: 'tvm-oidc-lambda-role', 66 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 67 | managedPolicies: [ 68 | iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole') 69 | ] 70 | }); 71 | 72 | oidcLambdaRole.addToPolicy(new iam.PolicyStatement({ 73 | effect: iam.Effect.ALLOW, 74 | actions: ['ssm:GetParameter'], 75 | resources: [ 76 | `arn:aws:ssm:${region}:${accountId}:parameter/oidc/private_key`, 77 | `arn:aws:ssm:${region}:${accountId}:parameter/oidc/public_key`, 78 | `arn:aws:ssm:${region}:${accountId}:parameter/oidc/client_id`, 79 | `arn:aws:ssm:${region}:${accountId}:parameter/oidc/client_secret`, 80 | `arn:aws:ssm:${region}:${accountId}:parameter/oidc/allow-list`, 81 | ] 82 | })); 83 | 84 | // Lambda to generate RSA key pair and store in SSM 85 | const keyGenLambda = new lambda.DockerImageFunction(this, 'KeyGenLambda', { 86 | functionName: 'tvm-key-gen-lambda', 87 | code: lambda.DockerImageCode.fromImageAsset('lambdas/key-gen'), 88 | handler: 'app.lambda_handler', 89 | runtime: lambda.Runtime.PYTHON_3_11, 90 | timeout: Duration.seconds(300), 91 | role: keyGenLambdaRole 92 | }); 93 | 94 | const keyGenProvider = new custom_resources.Provider(this, 'KeyGenProvider', { 95 | onEventHandler: keyGenLambda, 96 | }); 97 | 98 | new cdk.CustomResource(this, 'KeyGenCustomResource', { 99 | serviceToken: keyGenProvider.serviceToken, 100 | }); 101 | 102 | // Lambda Authorizer function for API Gateway 103 | const authorizerLambda = new lambda.Function(this, 'OIDCLambdaAuthorizerFn', { 104 | functionName: 'tvm-oidc-lambda-authorizer', 105 | code: lambda.Code.fromAsset('lambdas/lambda-authorizer'), 106 | handler: 'app.lambda_handler', 107 | environment: { 108 | CLIENT_ID_PARAM: '/oidc/client_id', 109 | CLIENT_SECRET_PARAM: '/oidc/client_secret', 110 | OIDC_ALLOW_LIST: '/oidc/allow-list' 111 | }, 112 | runtime: lambda.Runtime.PYTHON_3_11, 113 | timeout: Duration.seconds(300), 114 | role: oidcLambdaRole 115 | }); 116 | 117 | // Create the API Gateway (API URL not needed here) 118 | const api = new apigateway.RestApi(this, 'OidcApi', { 119 | restApiName: 'OIDC Issuer Service', 120 | description: 'Handles OIDC issuance and verification.', 121 | defaultCorsPreflightOptions: { 122 | allowOrigins: apigateway.Cors.ALL_ORIGINS, 123 | allowMethods: apigateway.Cors.ALL_METHODS, 124 | allowHeaders: apigateway.Cors.DEFAULT_HEADERS, 125 | exposeHeaders: ['Access-Control-Allow-Origin', 126 | 'Access-Control-Allow-Credentials',], 127 | statusCode: 200, 128 | allowCredentials: false 129 | }, 130 | deployOptions:{ 131 | loggingLevel: apigateway.MethodLoggingLevel.INFO, 132 | dataTraceEnabled: true, 133 | }, 134 | cloudWatchRole: true, 135 | }); 136 | 137 | const oidcLambda = new lambda.DockerImageFunction(this, 'OidcLambda', { 138 | functionName: 'tvm-oidc-lambda', 139 | code: lambda.DockerImageCode.fromImageAsset('lambdas/oidc-issuer'), 140 | environment: { 141 | PRIVATE_KEY_PARAM: '/oidc/private_key', 142 | PUBLIC_KEY_PARAM: '/oidc/public_key', 143 | KID: keyId, 144 | REGION: region, 145 | AUDIENCE: audience, 146 | }, 147 | runtime: lambda.Runtime.PYTHON_3_11, 148 | timeout: Duration.seconds(300), 149 | role: oidcLambdaRole 150 | }); 151 | 152 | // Set up API Gateway routes (without issuer URL yet) 153 | // Create the Lambda Authorizer 154 | const authorizer = new apigateway.RequestAuthorizer(this, 'OIDCLambdaAuthorizer', { 155 | handler: authorizerLambda, 156 | identitySources: [apigateway.IdentitySource.header('Authorization')], 157 | }); 158 | 159 | const tokenResource = api.root.addResource('token'); 160 | tokenResource.addMethod('POST', new apigateway.LambdaIntegration(oidcLambda), { 161 | authorizer, 162 | authorizationType: apigateway.AuthorizationType.CUSTOM 163 | }); 164 | 165 | const wellknown = api.root.addResource('.well-known'); 166 | const openidResource = wellknown.addResource('openid-configuration'); 167 | openidResource.addMethod('GET', new apigateway.LambdaIntegration(oidcLambda)); 168 | 169 | const jwksResource = wellknown.addResource('jwks.json'); 170 | jwksResource.addMethod('GET', new apigateway.LambdaIntegration(oidcLambda)); 171 | 172 | const issuerDomain = `${api.restApiId}.execute-api.${this.region}.${this.urlSuffix}`; 173 | const stage = api.deploymentStage.stageName; 174 | 175 | // Create an OIDC IAM Identity Provider 176 | const oidcIAMProvider = new iam.OpenIdConnectProvider(this, 'OIDCIAMProvider', { 177 | url: `https://${issuerDomain}/${stage}`, 178 | clientIds: [audience] 179 | }); 180 | 181 | // Create the IAM Role to Assume 182 | const audienceCondition = new cdk.CfnJson(this, 'AudienceCondition', { 183 | value: { 184 | [`${issuerDomain}/${stage}:aud`]: audience 185 | } 186 | }); 187 | 188 | const qbizIAMRole = new iam.Role(this, 'QBusinessOIDCRole', { 189 | roleName: 'tvm-qbiz-custom-oidc-role', 190 | description: 'Role for TVM OIDC-based authentication in Amazon Q Business.', 191 | assumedBy: new iam.CompositePrincipal( 192 | // First statement for AssumeRoleWithWebIdentity 193 | new iam.FederatedPrincipal( 194 | oidcIAMProvider.openIdConnectProviderArn, 195 | { 196 | StringEquals: audienceCondition, 197 | StringLike: { 198 | 'aws:RequestTag/Email': '*' 199 | } 200 | }, 201 | 'sts:AssumeRoleWithWebIdentity' 202 | ), 203 | // Second statement for TagSession 204 | new iam.FederatedPrincipal( 205 | oidcIAMProvider.openIdConnectProviderArn, 206 | { 207 | StringLike: { 208 | 'aws:RequestTag/Email': '*' 209 | } 210 | }, 211 | 'sts:TagSession' 212 | ), 213 | // Q Business service trust 214 | new iam.ServicePrincipal('application.qbusiness.amazonaws.com') 215 | .withConditions({ 216 | StringEquals: { 217 | 'aws:SourceAccount': this.account 218 | }, 219 | ArnEquals: { 220 | 'aws:SourceArn': `arn:aws:qbusiness:${this.region}:${this.account}:application/*` 221 | } 222 | }) 223 | ) 224 | }); 225 | 226 | 227 | // Add inline policy for permissions 228 | qbizIAMRole.attachInlinePolicy(new iam.Policy(this, 'QBusinessPermissions', { 229 | statements: [ 230 | new iam.PolicyStatement({ 231 | sid: 'QBusinessConversationPermission', 232 | effect: iam.Effect.ALLOW, 233 | actions: [ 234 | 'qbusiness:Chat', 235 | 'qbusiness:ChatSync', 236 | 'qbusiness:Retrieve', 237 | 'qbusiness:SearchRelevantContent', 238 | 'qbusiness:ListApplications', 239 | 'qbusiness:ListRetrievers', 240 | 'qbusiness:ListMessages', 241 | 'qbusiness:ListConversations', 242 | 'qbusiness:PutFeedback', 243 | 'qbusiness:DeleteConversation' 244 | ], 245 | resources: [`arn:aws:qbusiness:${this.region}:${this.account}:application/*`] 246 | }), 247 | new iam.PolicyStatement({ 248 | sid: 'QBusinessSetContextPermissions', 249 | effect: iam.Effect.ALLOW, 250 | actions: ['sts:SetContext'], 251 | resources: ['arn:aws:sts::*:self'], 252 | conditions: { 253 | StringLike: { 254 | 'aws:CalledViaLast': ['qbusiness.amazonaws.com'] 255 | } 256 | } 257 | }), 258 | new iam.PolicyStatement({ 259 | effect: iam.Effect.ALLOW, 260 | actions: ['user-subscriptions:CreateClaim'], 261 | resources: ['*'], 262 | conditions: { 263 | StringEquals: { 264 | 'aws:CalledViaLast': "qbusiness.amazonaws.com" 265 | } 266 | } 267 | }) 268 | ] 269 | })); 270 | 271 | /** 272 | * Deploy if set to 'true' 273 | */ 274 | if(props.deployQbiz){ 275 | // Creates a role for the data sources 276 | const dataSourceRole = new iam.Role(this, 'QBusinessDataSourceRole', { 277 | roleName: 'tvm-qbiz-data-source-role', 278 | description: 'Role required for Amazon Q Business data sources.', 279 | assumedBy: new iam.ServicePrincipal('qbusiness.amazonaws.com') 280 | .withConditions({ 281 | StringEquals: { 282 | 'aws:SourceAccount': this.account 283 | }, 284 | ArnEquals: { 285 | 'aws:SourceArn': `arn:aws:qbusiness:${this.region}:${this.account}:application/*` 286 | } 287 | }) 288 | }); 289 | 290 | dataSourceRole.attachInlinePolicy(new iam.Policy(this, 'QBizDataSourcePermissions',{ 291 | statements: [ 292 | new iam.PolicyStatement({ 293 | sid: 'AllowsAmazonQToGetObjectfromS3', 294 | effect: iam.Effect.ALLOW, 295 | actions: [ 296 | 's3:GetObject' 297 | ], 298 | resources: [`arn:aws:s3:::${process.env.Q_BIZ_S3_SOURCE_BKT}/*`], 299 | conditions: { 300 | StringEquals: { 301 | 'aws:ResourceAccount': this.account 302 | } 303 | } 304 | }), 305 | new iam.PolicyStatement({ 306 | sid: 'AllowsAmazonQToListS3Buckets', 307 | effect: iam.Effect.ALLOW, 308 | actions: ['s3:ListBucket'], 309 | resources: [`arn:aws:s3:::${process.env.Q_BIZ_S3_SOURCE_BKT}`], 310 | conditions: { 311 | StringEquals: { 312 | 'aws:ResourceAccount': this.account 313 | } 314 | } 315 | }), 316 | new iam.PolicyStatement({ 317 | sid: 'AllowsAmazonQToIngestDocuments', 318 | effect: iam.Effect.ALLOW, 319 | actions: ['qbusiness:BatchPutDocument', 'qbusiness:BatchDeleteDocument'], 320 | resources: ['*'] 321 | }), 322 | new iam.PolicyStatement({ 323 | sid: 'AllowsAmazonQToCallPrincipalMappingAPIs', 324 | effect: iam.Effect.ALLOW, 325 | actions: [ 326 | 'qbusiness:PutGroup', 327 | 'qbusiness:CreateUser', 328 | 'qbusiness:DeleteGroup', 329 | 'qbusiness:UpdateUser', 330 | 'qbusiness:ListGroups' 331 | ], 332 | resources: ['*'] 333 | }) 334 | ] 335 | })); 336 | 337 | //Creates a role for the external resource lambda that creates the Q Business Application 338 | const qBizLambdaRole = new iam.Role(this, 'QBizLambdaRole', { 339 | roleName: 'tvm-q-biz-lambda-role', 340 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 341 | managedPolicies: [ 342 | iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole') 343 | ] 344 | }); 345 | 346 | qBizLambdaRole.addToPolicy(new iam.PolicyStatement({ 347 | effect: iam.Effect.ALLOW, 348 | actions: [ 349 | 'qbusiness:CreateApplication', 350 | 'qbusiness:UpdateApplication', 351 | 'qbusiness:CreateIndex', 352 | 'qbusiness:CreateRetriever', 353 | 'qbusiness:CreateDataSource', 354 | 'qbusiness:StartDataSourceSyncJob', 355 | 'qbusiness:GetIndex', 356 | 'qbusiness:GetDataSource', 357 | 'qbusiness:DeleteApplication', 358 | "qbusiness:GetChatControlsConfiguration", 359 | "qbusiness:UpdateChatControlsConfiguration", 360 | ], 361 | resources: ["*"] 362 | })); 363 | 364 | qBizLambdaRole.addToPolicy(new iam.PolicyStatement({ 365 | effect: iam.Effect.ALLOW, 366 | actions: [ 367 | 'iam:PassRole' 368 | ], 369 | resources: [dataSourceRole.roleArn] 370 | })); 371 | 372 | qBizLambdaRole.addToPolicy(new iam.PolicyStatement({ 373 | effect: iam.Effect.ALLOW, 374 | actions: [ 375 | 'iam:CreateServiceLinkedRole' 376 | ], 377 | resources: ["*"] 378 | })); 379 | 380 | const qBizCreationLambda = new lambda.DockerImageFunction(this, 'QBizCreationLambda', { 381 | functionName: 'tvm-q-biz-creation-lambda', 382 | code: lambda.DockerImageCode.fromImageAsset('lambdas/q-biz'), 383 | handler: 'app.lambda_handler', 384 | runtime: lambda.Runtime.PYTHON_3_10, 385 | timeout: Duration.minutes(15), 386 | role: qBizLambdaRole, 387 | environment: { 388 | DATA_SOURCE_ROLE: dataSourceRole.roleArn, 389 | Q_BIZ_APP_NAME: process.env.Q_BIZ_APP_NAME, 390 | IAM_PROVIDER_ARN: oidcIAMProvider.openIdConnectProviderArn, 391 | IAM_PROVIDER_AUDIENCE: audience, 392 | Q_BIZ_S3_SOURCE_BKT: process.env.Q_BIZ_S3_SOURCE_BKT, 393 | Q_BIZ_SEED_URL: process.env.Q_BIZ_SEED_URLS 394 | } 395 | }); 396 | 397 | const qBizAppProvider = new custom_resources.Provider(this, 'QBizAppProvider', { 398 | onEventHandler: qBizCreationLambda, 399 | }); 400 | 401 | new cdk.CustomResource(this, 'QBizAppCustomResource', { 402 | serviceToken: qBizAppProvider.serviceToken, 403 | }); 404 | 405 | } 406 | 407 | // Output Audience Id 408 | new cdk.CfnOutput(this, 'AudienceOutput', { 409 | description: 'OIDC Audience ID', 410 | value: audience, 411 | exportName: 'OIDCAudience', 412 | }); 413 | 414 | // Output API URL 415 | new cdk.CfnOutput(this, 'IssuerUrlOutput', { 416 | description: 'Issuer URL (API Gateway)', 417 | value: api.url, 418 | exportName: 'IssuerUrl', 419 | }); 420 | 421 | // Output Q Business Role to Assume q-biz-custom-oidc-assume-role 422 | new cdk.CfnOutput(this, 'QBizAssumeRoleARN', { 423 | description: 'Amazon Q Business Role to Assume', 424 | value: qbizIAMRole.roleArn, 425 | exportName: 'AssumeRoleARN', 426 | }); 427 | 428 | // Output Client ID 429 | new cdk.CfnOutput(this, 'QbizTVMClientID', { 430 | description: 'The TVM Client ID', 431 | value: `oidc-tvm-${this.account}`, 432 | exportName: 'TVMClientID', 433 | }); 434 | 435 | // Output Client secret 436 | new cdk.CfnOutput(this, 'QbizTVMClientSecret', { 437 | description: 'The TVM Client Secret', 438 | value: secretID, 439 | exportName: 'TVMClientSecret', 440 | }); 441 | } 442 | } 443 | 444 | module.exports = { TVMOidcIssuerStack }; 445 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-oidc", 3 | "version": "0.1.0", 4 | "bin": { 5 | "custom-oidc": "bin/custom-oidc.js" 6 | }, 7 | "scripts": { 8 | "build": "echo \"The build step is not required when using JavaScript!\" && exit 0", 9 | "cdk": "cdk", 10 | "test": "jest" 11 | }, 12 | "devDependencies": { 13 | "aws-cdk": "2.161.1", 14 | "jest": "^29.7.0" 15 | }, 16 | "dependencies": { 17 | "aws-cdk-lib": "2.161.1", 18 | "cdk-nag": "^2.30.1", 19 | "constructs": "^10.0.0", 20 | "dotenv": "^16.4.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /amzn-q-auth-tvm/test/custom-oidc.test.js: -------------------------------------------------------------------------------- 1 | // const cdk = require('aws-cdk-lib'); 2 | // const { Template } = require('aws-cdk-lib/assertions'); 3 | // const CustomOidc = require('../lib/custom-oidc-stack'); 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/custom-oidc-stack.js 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new CustomOidc.CustomOidcStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/README.md: -------------------------------------------------------------------------------- 1 | # Amazon Q Business Custom UI (QUI) 2 | 3 | A React based component to build custom generative AI user experience with Amazon Q Business. 4 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import react from 'eslint-plugin-react' 4 | import reactHooks from 'eslint-plugin-react-hooks' 5 | import reactRefresh from 'eslint-plugin-react-refresh' 6 | 7 | export default [ 8 | { ignores: ['dist'] }, 9 | { 10 | files: ['**/*.{js,jsx}'], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.browser, 14 | parserOptions: { 15 | ecmaVersion: 'latest', 16 | ecmaFeatures: { jsx: true }, 17 | sourceType: 'module', 18 | }, 19 | }, 20 | settings: { react: { version: '18.3' } }, 21 | plugins: { 22 | react, 23 | 'react-hooks': reactHooks, 24 | 'react-refresh': reactRefresh, 25 | }, 26 | rules: { 27 | ...js.configs.recommended.rules, 28 | ...react.configs.recommended.rules, 29 | ...react.configs['jsx-runtime'].rules, 30 | ...reactHooks.configs.recommended.rules, 31 | 'react/jsx-no-target-blank': 'off', 32 | 'react-refresh/only-export-components': [ 33 | 'warn', 34 | { allowConstantExport: true }, 35 | ], 36 | }, 37 | }, 38 | ] 39 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | QUI 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amazon/q-ui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "host": "vite --host" 12 | }, 13 | "dependencies": { 14 | "@aws-crypto/sha256-browser": "^5.2.0", 15 | "@aws-sdk/client-qbusiness": "^3.672.0", 16 | "@aws-sdk/credential-providers": "^3.670.0", 17 | "@aws-sdk/protocol-http": "^3.370.0", 18 | "@smithy/eventstream-codec": "^3.1.6", 19 | "@smithy/signature-v4": "^4.2.0", 20 | "@smithy/util-utf8": "^3.0.0", 21 | "@tanstack/react-query": "^5.59.15", 22 | "lucide-react": "^0.453.0", 23 | "prismjs": "^1.29.0", 24 | "react": "^18.3.1", 25 | "react-detect-offline": "^2.4.5", 26 | "react-dom": "^18.3.1", 27 | "react-markdown": "^9.0.1", 28 | "react-syntax-highlighter": "^15.5.0", 29 | "rehype-raw": "^7.0.0", 30 | "remark-gfm": "^4.0.0", 31 | "remark-slug": "^7.0.1", 32 | "uuid": "^10.0.0" 33 | }, 34 | "devDependencies": { 35 | "@eslint/js": "^9.11.1", 36 | "@tailwindcss/typography": "^0.5.15", 37 | "@types/react": "^18.3.10", 38 | "@types/react-dom": "^18.3.0", 39 | "@vitejs/plugin-react": "^4.3.2", 40 | "autoprefixer": "^10.4.20", 41 | "eslint": "^9.11.1", 42 | "eslint-plugin-react": "^7.37.0", 43 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 44 | "eslint-plugin-react-refresh": "^0.4.12", 45 | "globals": "^15.9.0", 46 | "postcss": "^8.4.47", 47 | "tailwindcss": "^3.4.14", 48 | "vite": "^5.4.8" 49 | }, 50 | "keywords": [ 51 | "web-component", 52 | "react", 53 | "vite", 54 | "npm" 55 | ], 56 | "author": "Amazon Web Services (AWS)", 57 | "license": "MIT" 58 | } 59 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/App.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useState, useEffect, useRef } from 'react'; 5 | import { Palette } from 'lucide-react'; 6 | import Qui, {solarizedTheme, nighthawkTheme, forestTheme, enterpriseLightTheme} from './component'; 7 | 8 | 9 | function App() { 10 | 11 | const Title = () =>
AI Assistant powered by Amazon Q Business
; 12 | const SubTitle = () =>
Get answer to your questions from across enterprise applications
; 13 | const Disclaimer = () =>
This chatbot uses generative AI. Please verify responses for accuracy.
; 14 | 15 | const dropdownRef = useRef(null); 16 | const [theme, setTheme] = useState(forestTheme); 17 | const [isOpen, setIsOpen] = useState(false); 18 | 19 | const themes = [ 20 | { name: 'Default', value: null}, 21 | { name: 'Solarized', value: solarizedTheme }, 22 | { name: 'Nighthawk', value: nighthawkTheme }, 23 | { name: 'Forest', value: forestTheme }, 24 | { name: 'Enterprise', value: enterpriseLightTheme} 25 | ]; 26 | 27 | useEffect(() => { 28 | const handleClickOutside = (event) => { 29 | if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { 30 | setIsOpen(false); 31 | } 32 | }; 33 | document.addEventListener('mousedown', handleClickOutside); 34 | return () => document.removeEventListener('mousedown', handleClickOutside); 35 | }, []); 36 | 37 | const ThemeSelector = () => { 38 | return ( 39 |
40 | 47 | {isOpen && ( 48 |
49 | {themes.map((themeOption) => ( 50 | 60 | ))} 61 |
62 | )} 63 |
64 | ); 65 | } 66 | 67 | return ( 68 |
69 | 70 | } 72 | subtitle={} 73 | disclaimerText={} 74 | feedbackEnabled={true} 75 | richSource={true} 76 | theme={theme} 77 | qBusinessAppId={import.meta.env.VITE_QBIZ_APP_ID} 78 | iamRoleArn={import.meta.env.VITE_IAM_ROLE_ARN} 79 | email={import.meta.env.VITE_EMAIL} 80 | awsRegion={import.meta.env.VITE_AWS_REGION} 81 | issuer={import.meta.env.VITE_ISSUER} 82 | inputPlaceholder={"Ask me anything..."} 83 | showHistory={true} 84 | showNewChatBtn={true} 85 | hideAvatars={false} 86 | mode='anonymous' 87 | cannedQuestions={[ 88 | "What is Coffee-as-a-Service?", 89 | "What are the pricing plans available for CaaS?", 90 | "What APIs are available in CaaS?", 91 | "Give me sample Python Code snippet on how to use Coffee-Menu API."]} 92 | /> 93 |
94 | ) 95 | } 96 | 97 | export default App 98 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/assets/amazonq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/amzn-q-custom-ui/src/assets/amazonq.png -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/assets/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/amzn-q-custom-ui/src/assets/user.png -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/elements/Alert.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState, useEffect } from 'react'; 5 | import { CircleCheck, Info, X, OctagonX } from 'lucide-react'; 6 | 7 | const ICONS = { 8 | success: , 9 | warning: , 10 | error: , 11 | }; 12 | 13 | const COLORS = { 14 | success: 'bg-green-50 text-green-700 border-green-300', 15 | warning: 'bg-yellow-50 text-yellow-700 border-yellow-300', 16 | error: 'bg-red-50 text-red-700 border-red-300', 17 | }; 18 | 19 | const Alert = ({ 20 | type = 'success', // 'success', 'warning', 'error' 21 | title, 22 | subTitle, 23 | floaty = false, // If true, float down from top 24 | onDismiss = null, // Optional dismiss handler 25 | autoDismiss = true, // Auto-dismiss for floaty alerts 26 | dismissTime = 3000, // Time in ms for auto-dismiss 27 | }) => { 28 | const [visible, setVisible] = useState(true); 29 | 30 | useEffect(() => { 31 | if (floaty && autoDismiss) { 32 | const timer = setTimeout(() => setVisible(false), dismissTime); 33 | return () => clearTimeout(timer); 34 | } 35 | }, [floaty, autoDismiss, dismissTime]); 36 | 37 | if (!visible) return null; 38 | 39 | return ( 40 |
45 |
{ICONS[type]}
46 |
47 | {title &&

{title}

} 48 | {subTitle &&

{subTitle}

} 49 |
50 | {onDismiss && ( 51 | 60 | )} 61 |
62 | ); 63 | }; 64 | 65 | export default Alert; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/elements/CannedQuestions.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from 'react'; 5 | import { useGlobalConfig } from '../providers/GlobalConfigContext'; 6 | 7 | const CannedQuestions = ({ questions, onClick }) => { 8 | const displayQuestions = questions.slice(0, 6); 9 | const { theme } = useGlobalConfig(); 10 | 11 | const styling = { 12 | bgColor: theme?.userMsgBgColor || "", 13 | textColor: theme?.msgTextColor || "" 14 | } 15 | 16 | return ( 17 | <> 18 |
Get Started
19 |
20 | {displayQuestions.map((question, index) => ( 21 | 32 | ))} 33 |
34 | 35 | ); 36 | }; 37 | 38 | export default CannedQuestions; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/elements/ChatHistoryMenu.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState, useMemo } from 'react'; 5 | import { Menu, Trash, ChevronLeft, EditIcon, X } from 'lucide-react'; 6 | import useListConversations from '../hooks/useListConversations'; 7 | import useDeleteConversation from '../hooks/useDeleteConversation'; 8 | import useBreakpoint from '../hooks/useBreakpoint'; 9 | import { useGlobalConfig } from '../providers/GlobalConfigContext'; 10 | import { formatDate, formatTime } from '../utils/dateConverter'; 11 | import Loader from './Loader'; 12 | import Alert from './Alert'; 13 | 14 | const groupByDate = (conversations) => { 15 | return conversations.reduce((acc, message) => { 16 | const date = formatDate(message.startTime); 17 | if (!acc[date]) acc[date] = []; 18 | acc[date].push(message); 19 | return acc; 20 | }, {}); 21 | }; 22 | 23 | const removeInstructionsFromPlainText = (text) => { 24 | const instructionsRegex = /[\s\S]*?<\/system>/g; 25 | return text.replace(instructionsRegex, '').trim(); 26 | }; 27 | 28 | // Chat History menu functionality for authenticated mode 29 | const AuthenticatedMenuContent = ({ 30 | onSelectMessage, 31 | onDelete, 32 | onNewChat, 33 | conversationId, 34 | showNewChatBtn, 35 | onClose, 36 | isOpen, 37 | setIsOpen, 38 | theme 39 | }) => { 40 | const [convToDel, setConvToDel] = useState(null); 41 | const [selectedConvoId, setSelectedConvoId] = useState(null); 42 | const breakpoint = useBreakpoint('(min-width: 768px)'); 43 | 44 | const { 45 | issuer, 46 | email, 47 | qBusinessAppId, 48 | awsRegion, 49 | iamRoleArn, 50 | } = useGlobalConfig(); 51 | 52 | const { 53 | isLoading, 54 | conversations, 55 | refreshListConversations, 56 | loadMoreConversations, 57 | error, 58 | hasMore 59 | } = useListConversations({ 60 | issuer, 61 | appId: qBusinessAppId, 62 | roleArn: iamRoleArn, 63 | region: awsRegion, 64 | email 65 | }); 66 | 67 | const { 68 | delConversation, 69 | delLoading, 70 | delError 71 | } = useDeleteConversation({ 72 | issuer, 73 | appId: qBusinessAppId, 74 | roleArn: iamRoleArn, 75 | region: awsRegion, 76 | email 77 | }); 78 | 79 | const groupedConversations = useMemo(() => groupByDate([...conversations]), [[...conversations]]); 80 | 81 | const toggleMenu = () => { 82 | setIsOpen(!isOpen); 83 | refreshListConversations(); 84 | }; 85 | 86 | const styling = { 87 | menuColor: theme?.aiMsgBgColor || "", 88 | menuItemHoverColor: theme?.bgColor || "", 89 | bgColor: theme?.bgColor || "#fff", 90 | btnColor: theme?.bgColor || "", 91 | textColor: theme?.msgTextColor || "" 92 | }; 93 | 94 | const deleteConversation = async(conversationId) => { 95 | setConvToDel(conversationId); 96 | await delConversation(conversationId); 97 | await refreshListConversations(); 98 | setConvToDel(null); 99 | if(onDelete) { 100 | onDelete(conversationId); 101 | } 102 | }; 103 | 104 | const selectConversation = (message) => { 105 | if(selectedConvoId !== message.conversationId) { 106 | setSelectedConvoId(message.conversationId); 107 | if(onSelectMessage) { 108 | onSelectMessage(message); 109 | } 110 | } 111 | if(!breakpoint) { 112 | setIsOpen(!isOpen); 113 | } 114 | }; 115 | 116 | return ( 117 | <> 118 | {isOpen && ( 119 |
123 | )} 124 | 125 |
132 |
133 |

Conversations

134 |
135 | {showNewChatBtn && ( 136 | 144 | )} 145 | 154 |
155 |
156 | 157 | {error && ( 158 |
159 | 165 |
166 | )} 167 | 168 | {delError && ( 169 |
170 | 177 |
178 | )} 179 | 180 |
181 |
    182 | {conversations && conversations.length > 0 ? ( 183 | Object.keys(groupedConversations).map((date) => ( 184 | 185 |
  • 186 | {date} 187 |
  • 188 | {groupedConversations[date].map((message) => { 189 | let title = removeInstructionsFromPlainText(message.title); 190 | return ( 191 |
  • 202 |
    selectConversation(message)}> 203 |

    204 | {title} 205 |

    206 |

    207 | {formatTime(message.startTime)} 208 |

    209 |
    210 | 221 |
  • 222 | ); 223 | })} 224 |
    225 | )) 226 | ) : ( 227 |
  • 228 | {isLoading && conversations.length === 0 ? : No conversations yet} 229 |
  • 230 | )} 231 |
232 | {hasMore && ( 233 |
234 | 241 |
242 | )} 243 |
244 |
245 | 246 | ); 247 | }; 248 | 249 | const ChatHistoryMenu = ({ 250 | onSelectMessage, 251 | onDelete, 252 | onNewChat, 253 | conversationId, 254 | showNewChatBtn = false, 255 | showMenuBtn = false, 256 | onClose, 257 | mode = 'authenticated' 258 | }) => { 259 | const [isOpen, setIsOpen] = useState(false); 260 | 261 | const { 262 | theme 263 | } = useGlobalConfig(); 264 | 265 | const styling = { 266 | bgColor: theme?.bgColor || "#fff", 267 | btnColor: theme?.bgColor || "", 268 | textColor: theme?.msgTextColor || "" 269 | }; 270 | 271 | // const styling = { 272 | // menuColor: theme?.aiMsgBgColor || "", 273 | // menuItemHoverColor: theme?.bgColor || "", 274 | // bgColor: theme?.bgColor || "#fff", 275 | // btnColor: theme?.bgColor || "", 276 | // textColor: theme?.msgTextColor || "" 277 | // } 278 | 279 | // Only show the top bar if any button needs to be shown 280 | const shouldShowTopBar = showNewChatBtn || (showMenuBtn && mode === 'authenticated') || onClose; 281 | 282 | if (!shouldShowTopBar) { 283 | return null; 284 | } 285 | 286 | return ( 287 | <> 288 | {!isOpen && ( 289 |
291 |
292 | {(showMenuBtn && mode === 'authenticated') && ( 293 | 302 | )} 303 | {showNewChatBtn && ( 304 | 313 | )} 314 |
315 | 316 | {onClose && ( 317 | 326 | )} 327 |
328 | )} 329 | 330 | {/* Only render the menu content if mode is authenticated and showMenuBtn is true */} 331 | {showMenuBtn && mode === 'authenticated' && ( 332 | 343 | )} 344 | 345 | ); 346 | }; 347 | 348 | export default ChatHistoryMenu; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/elements/ChatbotLoader.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from 'react'; 5 | 6 | const ChatbotLoader = ({ color = '#6366F1', speed = 1 }) => ( 7 | 13 | {/* First Orbiting Electron */} 14 | 15 | 23 | 29 | 30 | 31 | {/* Second Orbiting Electron */} 32 | 33 | 41 | 47 | 48 | 49 | {/* Third Orbiting Electron */} 50 | 51 | 60 | 66 | 67 | 68 | {/* Fourth Orbiting Electron */} 69 | 70 | 79 | 85 | 86 | 87 | ); 88 | 89 | export default ChatbotLoader; 90 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/elements/CitationMarker.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState, useRef, useEffect } from 'react'; 5 | import { createPortal } from 'react-dom'; 6 | import { Globe } from 'lucide-react'; 7 | import { useGlobalConfig } from '../providers/GlobalConfigContext'; 8 | import useBreakpoint from '../hooks/useBreakpoint'; 9 | 10 | 11 | const CitationMarker = ({ number, source = null, snippet = null, ...rest }) => { 12 | const [isHovered, setIsHovered] = useState(false); 13 | const markerRef = useRef(null); 14 | const tooltipRef = useRef(null); 15 | const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 }); 16 | let hideTimeout = useRef(null); 17 | 18 | const { theme } = useGlobalConfig(); 19 | 20 | /** 21 | * if breakpoint is false that means its mobile (small screens) 22 | * and dismissing menu on conversation select is required to 23 | * remove the menu out of the way. 24 | */ 25 | const breakpoint = useBreakpoint('(min-width: 768px)'); 26 | 27 | useEffect(() => { 28 | if (isHovered && markerRef.current) { 29 | const rect = markerRef.current.getBoundingClientRect(); 30 | setTooltipPosition({ 31 | top: rect.top - 10, // Slightly above the marker 32 | left: rect.left + rect.width / 2, // Centered horizontally 33 | }); 34 | } 35 | }, [isHovered]); 36 | 37 | const handleMouseEnter = () => { 38 | clearTimeout(hideTimeout.current); // Cancel hide if the mouse re-enters 39 | setIsHovered(true); 40 | }; 41 | 42 | const handleMouseLeave = () => { 43 | hideTimeout.current = setTimeout(() => setIsHovered(false), 200); // Add delay to hiding 44 | }; 45 | 46 | const navigate = () => { 47 | if(source){ 48 | window.open(source?.url, '_blank') 49 | } 50 | } 51 | 52 | return ( 53 | <> 54 | {/* Marker Display */} 55 | navigate()} 64 | > 65 | {number} 66 | 67 | 68 | {(isHovered && breakpoint && source) && 69 | createPortal( 70 |
81 |
82 | 83 |

84 | {source?.title} 85 |

86 |
87 |

{snippet}

88 |

89 | 95 | {source?.url} 96 | 97 |

98 |
, 99 | document.body // Render the tooltip outside parent containers 100 | )} 101 | 102 | ); 103 | }; 104 | 105 | export default CitationMarker; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/elements/FeedbackModal.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState } from 'react'; 5 | import { X } from 'lucide-react'; 6 | import defaultLogo from '../../assets/amazonq.png'; 7 | import feedbackOptions from '../utils/thumbsdownFeedback.json' 8 | import { useGlobalConfig } from '../providers/GlobalConfigContext'; 9 | 10 | const FeedbackModal = ({ isOpen, onClose, onSubmit }) => { 11 | const [selectedOption, setSelectedOption] = useState(''); 12 | const [additionalDetails, setAdditionalDetails] = useState(''); 13 | const [includeConversation, setIncludeConversation] = useState(false); 14 | const { theme } = useGlobalConfig(); 15 | 16 | const feedbackStyle = { 17 | bgColor: theme?.bgColor || "", 18 | textColor: theme?.msgTextColor || "", 19 | bottomColor: theme?.userMsgBgColor || "" 20 | } 21 | 22 | if (!isOpen) return null; 23 | 24 | const handleSubmit = () => { 25 | const feedbackData = { 26 | feedbackType: selectedOption, 27 | additionalDetails, 28 | includeConversation 29 | }; 30 | onSubmit(feedbackData); 31 | onClose(); 32 | }; 33 | 34 | return ( 35 |
36 |
37 |
38 |
39 |

Send your feedback

40 | 43 |
44 |
45 | {feedbackOptions.map((option, index) => ( 46 |
47 | 57 |
58 | ))} 59 |
60 | 61 | 68 |
69 |

70 | Do not include any confidential data or personally-identifiable information. Your 71 | feedback will be shared with Amazon Web Services and will be used to improve 72 | Amazon Q. 73 |

74 | {/*
75 | 84 |
*/} 85 |
86 |
87 |
88 | AWS 89 |
90 | 93 | 98 |
99 |
100 |
101 |
102 | ); 103 | }; 104 | 105 | export default FeedbackModal; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/elements/Loader.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from 'react'; 5 | import { LoaderCircle } from 'lucide-react'; 6 | 7 | const Loader = ({ size = 24, color = 'currentColor', className = '' }) => { 8 | return ( 9 |
10 | 15 |
16 | ); 17 | }; 18 | 19 | export default Loader; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/elements/Message.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState, useEffect } from 'react' 5 | import ReactMarkdown from 'react-markdown'; 6 | import remarkGfm from 'remark-gfm'; 7 | import remarkSlug from 'remark-slug'; 8 | import rehypeRaw from 'rehype-raw'; 9 | import removeInstructionsPlugin from '../utils/removeSystemPrompt'; 10 | import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter' 11 | import {nightOwl} from 'react-syntax-highlighter/dist/esm/styles/prism'; 12 | import { Copy, ThumbsUp, ThumbsDown, Check } from 'lucide-react'; 13 | 14 | import SourceCarousel from './SourceCarousel'; 15 | import FeedbackModal from './FeedbackModal'; 16 | import CitationMarker from './CitationMarker'; 17 | import { useGlobalConfig } from '../providers/GlobalConfigContext'; 18 | import useChatFeedback from '../hooks/useChatFeedback'; 19 | 20 | 21 | const Message = ({ 22 | conversationId, 23 | messageId, 24 | text, 25 | user, 26 | source, 27 | feedbackEnabled, 28 | richSource, 29 | isStreaming=false 30 | }) => { 31 | 32 | const [copied, setCopied] = useState(false); 33 | const [codeCopied, setCodeCopied] = useState(false); 34 | const [liked, setLiked] = useState(false); 35 | const [disliked, setDisliked] = useState(false); 36 | const [isModalOpen, setIsModalOpen] = useState(false); 37 | const { issuer, email, qBusinessAppId, awsRegion, iamRoleArn, theme, showInlineCitation } = useGlobalConfig(); 38 | const { putFeedback, error } = useChatFeedback({issuer, appId: qBusinessAppId, roleArn: iamRoleArn, region:awsRegion, email}) 39 | 40 | 41 | const msgStyle = { 42 | msgTextColor: theme?.msgTextColor || "", //Default text color, hex or rgb() string 43 | sourceBgColor: theme?.sourceBgColor || "", 44 | feedbackBgColor: theme?.feedbackBgColor || "", //Default feedback background color, hex or rgb() string 45 | feedbackIconColor: theme?.feedbackIconColor || "", //Default feedback icon color, hex or rgb() string 46 | } 47 | 48 | const handleCopytoClipboard = (text) => { 49 | let finalText = text; 50 | if (showInlineCitation){ 51 | finalText = text.replace(/]*>/g, '[').replace(/<\/sup>/g, ']');; 52 | } 53 | 54 | if(source){ 55 | const sourceString = source.map((it) => `[${it.citationNumber}] ${it.title} (${it.url})`).join('\n'); 56 | finalText += "\n\nSources:\n"+sourceString; 57 | } 58 | 59 | navigator.clipboard.writeText(finalText).then(() => { 60 | setCopied(true); 61 | setTimeout(() => { 62 | setCopied(false); 63 | }, 1500); 64 | }); 65 | }; 66 | 67 | const handleLike = () => { 68 | setLiked(true); 69 | setTimeout(() => { 70 | setLiked(false); 71 | }, 1500); 72 | handleFeedbackSubmit({feedbackType: "HELPFUL", additionalDetails: ""},"USEFUL") 73 | } 74 | 75 | const handleDislike = () => { 76 | setIsModalOpen(true) 77 | setDisliked(true); 78 | setTimeout(() => { 79 | setDisliked(false); 80 | }, 1500); 81 | } 82 | 83 | const handleFeedbackSubmit = (feedbackData, usefulness="NOT_USEFUL") => { 84 | putFeedback(conversationId, messageId, usefulness, feedbackData['feedbackType'], feedbackData["additionalDetails"]) 85 | }; 86 | 87 | const copyCodeToClipboard = (code) => { 88 | navigator.clipboard.writeText(code).then(() => { 89 | setCodeCopied(true); 90 | setTimeout(() => { 91 | setCodeCopied(false); 92 | }, 1500); 93 | });; 94 | }; 95 | 96 | return
97 | 109 |
110 | 111 | {language} 112 | 113 | 122 |
123 | 136 |
137 | ) : ( 138 | 139 | {children} 140 | 141 | ) 142 | }, 143 | sup({ node, children, ...props }) { 144 | if(user === 'SYSTEM'){ 145 | const citationNumber = children[0]; 146 | const endOffset = props['data-endoffset']; 147 | const citationSource = source.filter((it) => it.citationNumber === parseInt(citationNumber))[0]; 148 | let offsetSource; 149 | if(citationSource.citationMarkers){ 150 | offsetSource = citationSource.citationMarkers.filter((it) => it.endOffset === parseInt(endOffset))[0]; 151 | } 152 | return ; 153 | }else{ 154 | return null; 155 | } 156 | }, 157 | pre: ({ children, ...props }) => <>{children}, 158 | h1: ({node, ...props}) =>

, 159 | h2: ({node, ...props}) =>

, 160 | h3: ({node, ...props}) =>

, 161 | h4: ({node, ...props}) =>

, 162 | strong: ({node, ...props}) => , 163 | th: ({node, ...props}) => , 164 | td: ({node, ...props}) => , 165 | p: ({node, ...props}) =>

, 166 | ul: ({node, ...props}) =>

232 | } 233 | 234 | export default Message; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/elements/SourceCarousel.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useRef, useState, useEffect } from 'react'; 5 | import { ChevronLeft, ChevronRight } from 'lucide-react'; 6 | import CitationMarker from './CitationMarker'; 7 | import { useGlobalConfig } from '../providers/GlobalConfigContext'; 8 | 9 | 10 | const SourceCarousel = ({ sources }) => { 11 | 12 | const { theme } = useGlobalConfig(); 13 | 14 | const sourceStyle = { 15 | textColor: theme?.msgTextColor || "", 16 | sourceBgColor: theme?.sourceBgColor || "", //Default source background color, hex or rgb() string 17 | } 18 | 19 | const carouselRef = useRef(null); 20 | const [showArrows, setShowArrows] = useState(false); 21 | 22 | const checkOverflow = () => { 23 | if (carouselRef.current) { 24 | const isOverflowing = 25 | carouselRef.current.scrollWidth > carouselRef.current.clientWidth; 26 | setShowArrows(isOverflowing); 27 | } 28 | }; 29 | 30 | const scroll = (direction) => { 31 | if (carouselRef.current) { 32 | const scrollAmount = direction === 'left' ? -300 : 300; 33 | carouselRef.current.scrollBy({ 34 | left: scrollAmount, 35 | behavior: 'smooth', 36 | }); 37 | } 38 | }; 39 | 40 | useEffect(() => { 41 | checkOverflow(); 42 | window.addEventListener('resize', checkOverflow); 43 | 44 | return () => { 45 | window.removeEventListener('resize', checkOverflow); 46 | }; 47 | }, []); 48 | 49 | return ( 50 |
51 | { 52 | showArrows && 53 | 61 | } 62 |
64 | Sources 65 |
66 |
71 | 72 | { 73 | sources.map((src, idx) => { 74 | return
77 |

79 | {src.snippet} 80 |

81 |
82 | 83 | 85 | {src.title} 86 | 87 | {/* {` • `} */} 88 |
89 |
90 | }) 91 | } 92 |
93 | { 94 | showArrows && 95 | 103 | } 104 |
105 | ); 106 | }; 107 | 108 | export default SourceCarousel; 109 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/elements/TypingAnimation.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from 'react' 5 | 6 | const TypingAnimation = ({ logo }) => { 7 | return ( 8 |
9 | Assistant 10 |
11 |
12 |
13 |
14 |
15 |
16 | ) 17 | } 18 | 19 | export default TypingAnimation -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/hooks/useBreakpoint.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useState, useEffect } from 'react'; 5 | 6 | const useBreakpoint = (query) => { 7 | const [matches, setMatches] = useState(false); 8 | 9 | useEffect(() => { 10 | const mediaQuery = window.matchMedia(query); 11 | setMatches(mediaQuery.matches); 12 | 13 | const handler = (event) => setMatches(event.matches); 14 | mediaQuery.addEventListener('change', handler); 15 | 16 | return () => mediaQuery.removeEventListener('change', handler); 17 | }, [query]); 18 | 19 | return matches; 20 | }; 21 | 22 | export default useBreakpoint; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/hooks/useChatFeedback.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useState } from 'react' 5 | import { QBusinessClient, PutFeedbackCommand } from "@aws-sdk/client-qbusiness"; 6 | import { useQbizCredentials } from './useQBizCredentials'; 7 | 8 | const useChatFeedback = ({ issuer, appId, roleArn, region, email }) => { 9 | const [error, setError] = useState(null); 10 | const { data: credentials, isLoading: loadingClient } = useQbizCredentials(issuer, email, region, roleArn); 11 | 12 | const putFeedback = async(conversationId, messageId, usefulness="USEFUL", reason="HELPFUL", comment="") => { 13 | const feedbackPayload = { 14 | applicationId: appId, 15 | userId: email, 16 | conversationId, 17 | messageId, 18 | messageCopiedAt: new Date(), 19 | messageUsefulness: { 20 | usefulness, 21 | reason, 22 | comment, 23 | submittedAt: new Date() 24 | } 25 | } 26 | try { 27 | const qclient = new QBusinessClient({ region, credentials: credentials }); 28 | const command = new PutFeedbackCommand(feedbackPayload); 29 | await qclient.send(command); 30 | // await qbizClient.send(command); 31 | return ({message: "Thanks for your feedback!"}) 32 | } catch (error) { 33 | setError(`Error sending message: ${error.message}`); 34 | } 35 | 36 | } 37 | 38 | return ({putFeedback, error}) 39 | } 40 | 41 | export default useChatFeedback -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/hooks/useChatStream.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useState, useCallback, useRef } from 'react'; 5 | import { fromWebToken } from "@aws-sdk/credential-providers"; 6 | import { Sha256 } from "@aws-crypto/sha256-browser"; 7 | import { SignatureV4 } from '@smithy/signature-v4'; 8 | import { HttpRequest } from '@aws-sdk/protocol-http'; 9 | import { encodeEventStream } from '../utils/eventStreamEncoder'; 10 | import { decodeEventStream } from '../utils/eventStreamDecoder'; 11 | import errorMap from '../utils/notifications_en.json'; 12 | import { v4 as uuidv4 } from 'uuid'; 13 | 14 | /**Firefox struggles with real time streaming so we will use batching */ 15 | const isFirefox = typeof navigator !== 'undefined' && /firefox/i.test(navigator.userAgent); 16 | 17 | const generateCanonicalQueryString = (url, query) => { 18 | const conon_url = url; 19 | conon_url.searchParams.append('X-Amz-Algorithm', query['X-Amz-Algorithm']) 20 | conon_url.searchParams.append('X-Amz-Credential', query['X-Amz-Credential']) 21 | conon_url.searchParams.append('X-Amz-Date', query['X-Amz-Date']) 22 | conon_url.searchParams.append('X-Amz-Expires', query['X-Amz-Expires']) 23 | conon_url.searchParams.append('X-Amz-Security-Token', query['X-Amz-Security-Token']) 24 | conon_url.searchParams.append('X-Amz-Signature', query['X-Amz-Signature']) 25 | conon_url.searchParams.append('X-Amz-SignedHeaders', query['X-Amz-SignedHeaders']) 26 | conon_url.searchParams.append('chat-input', query['chat-input']) 27 | return conon_url; 28 | }; 29 | 30 | const useChatStream = ({ issuer, appId, roleArn, region, email, mode }) => { 31 | // const { data: credentials, isLoading: loadingClient } = useQbizCredentials(issuer, email, region, roleArn); 32 | const [isLoading, setIsLoading] = useState(false); 33 | const [isStreaming, setIsStreaming] = useState(false); 34 | const [error, setError] = useState(null); 35 | 36 | const socketRef = useRef(null); 37 | const isFirstChunkRef = useRef(true); 38 | const messageQueueRef = useRef([]); 39 | const isProcessingRef = useRef(false); 40 | 41 | const SERVICE = 'qbusiness'; 42 | const HOSTNAME = `qbusiness-websocket.${region}.api.aws`; 43 | const PORT = 443; 44 | const PROTOCOL = 'wss'; 45 | const ENDPOINT = '/chat'; 46 | 47 | const closeConnection = useCallback(() => { 48 | if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { 49 | socketRef.current.close(); 50 | setIsStreaming(false); 51 | setIsLoading(false); 52 | // isFirstChunkRef.current = true; 53 | } 54 | }, []); 55 | 56 | const getSignedUrl = async (credentials, payload) => { 57 | const sigV4 = new SignatureV4({ 58 | credentials, 59 | region, 60 | service: SERVICE, 61 | sha256: Sha256, 62 | uriEscapePath: true 63 | }); 64 | 65 | const chatInput = JSON.stringify({ 66 | applicationId: appId, 67 | userID: email, 68 | userGroups: null, 69 | conversationId: payload["conversationId"] || null, 70 | parentMessageId: payload["parentMessageId"] || null, 71 | clientToken: uuidv4(), 72 | }); 73 | 74 | const request = new HttpRequest({ 75 | method: 'GET', 76 | protocol: PROTOCOL, 77 | hostname: HOSTNAME, 78 | port: 443, 79 | path: ENDPOINT, 80 | query: { 'chat-input': chatInput }, 81 | headers: { host: HOSTNAME }, 82 | }); 83 | 84 | const signedRequest = await sigV4.presign(request, { expiresIn: 900 }); 85 | const url = new URL(`${PROTOCOL}://${HOSTNAME}:${PORT}${ENDPOINT}`); 86 | return { 87 | signedUrl: generateCanonicalQueryString(url, signedRequest.query), 88 | signature: signedRequest.query['X-Amz-Signature'], 89 | sigV4 90 | }; 91 | }; 92 | 93 | const fetchIdToken = async () => { 94 | const dummyAuth = btoa('dummy-client:dummy-secret'); 95 | const response = await fetch(`${issuer}/token`, { 96 | method: 'POST', 97 | headers: { 98 | 'Authorization': `Basic ${dummyAuth}`, 99 | 'Content-Type': 'application/json' 100 | }, 101 | body: JSON.stringify({ email }), 102 | }); 103 | if (!response.ok) throw new Error('Failed to fetch ID token'); 104 | const data = await response.json(); 105 | return data.id_token; 106 | }; 107 | 108 | /** 109 | * This is a special function for Firefox since streaming and rendering 110 | * causes rendering race conditions in FF. This function does batching 111 | * to prevent out of order messages instead of streaming directly back 112 | * to the component. 113 | */ 114 | const processMessageQueue = async (onMessageCallback) => { 115 | if (isProcessingRef.current) return; 116 | isProcessingRef.current = true; 117 | 118 | let textBuffer = ''; 119 | 120 | // BUFFER_SIZE = 25; // More real-time, might be less smooth (ideal) 121 | // BUFFER_SIZE = 10; // More smooth, less real-time 122 | 123 | const BUFFER_SIZE = 35; 124 | 125 | while (messageQueueRef.current.length > 0) { 126 | const event = messageQueueRef.current.shift(); 127 | try { 128 | const { eventType, data } = await decodeEventStream(event.data); 129 | 130 | if (eventType === 'textEvent') { 131 | if (isFirstChunkRef.current) { 132 | setIsLoading(false); 133 | setIsStreaming(true); 134 | isFirstChunkRef.current = false; 135 | } 136 | 137 | textBuffer += data; 138 | if (textBuffer.length >= BUFFER_SIZE || messageQueueRef.current.length === 0) { 139 | onMessageCallback(eventType, textBuffer); 140 | textBuffer = ''; 141 | } 142 | } else if (eventType === 'metadataEvent') { 143 | if (textBuffer.length > 0) { 144 | onMessageCallback('textEvent', textBuffer); 145 | } 146 | 147 | const sourceAttributions = data.sourceAttributions.map(item => ({ 148 | title: item.title, 149 | citationNumber: item.citationNumber, 150 | url: item.url, 151 | snippet: item.snippet, 152 | citationMarkers: item.textMessageSegments.map(it => ({ 153 | beginOffset: it.beginOffset, 154 | endOffset: it.endOffset, 155 | snippet: it?.snippetExcerpt?.text || '' 156 | })) 157 | })); 158 | 159 | onMessageCallback(eventType, { 160 | systemMessage: data.finalTextMessage, 161 | conversationId: data.conversationId, 162 | userMessageId: data.userMessageId, 163 | parentMessageID: data.systemMessageId, 164 | source: sourceAttributions, 165 | }); 166 | socketRef.current.close(); 167 | } 168 | } catch (err) { 169 | setIsStreaming(false); 170 | setIsLoading(false); 171 | setError(errorMap.error['internal-server']); 172 | socketRef.current.close(); 173 | } 174 | } 175 | isProcessingRef.current = false; 176 | }; 177 | 178 | const handleSocketCloseOrErr = async(e) => { 179 | setIsStreaming(false); 180 | setIsLoading(false); 181 | 182 | // Handle timeout code 183 | if (e.code === 1006) { 184 | setError(errorMap.error['server-stopped-responding']); 185 | } 186 | 187 | // Handle message frame overflow 188 | else if (e.code === 1009) { 189 | setError(errorMap.error['message-length-exceeded']); 190 | } 191 | 192 | // Internal server error 193 | else if (e.code === 1011) { 194 | const { data: exceptionMessage, exceptionType } = await decodeEventStream(e.data); 195 | switch (exceptionType) { 196 | case "AccessDeniedException": 197 | errorMessage = exceptionMessage; 198 | break; 199 | 200 | case "LicenseNotFoundException": 201 | errorMessage = errorMap.error['license-not-found']; 202 | break; 203 | 204 | case "ResourceNotFoundException": 205 | errorMessage = errorMap.error['client-side-error']; 206 | break; 207 | 208 | case "ThrottlingException": 209 | errorMessage = exceptionMessage === "Server is too busy to satisfy this request. Please try again later." 210 | ? errorMap.error.throttling 211 | : errorMap.error['chat-throttling']; 212 | break; 213 | 214 | case "ExpiredTokenException": 215 | errorMessage = errorMap.error['expired-token']; 216 | break; 217 | 218 | case "ValidationException": 219 | errorMessage = errorMap.error['input-too-long'] 220 | break; 221 | } 222 | setError(errorMessage); 223 | } 224 | 225 | else{ 226 | setError(errorMap.error['internal-server']); 227 | } 228 | } 229 | 230 | const initializeWebSocket = async (onMessageCallback, chatPayload) => { 231 | try { 232 | const credentials = await fromWebToken({ 233 | roleArn, 234 | webIdentityToken: await fetchIdToken(), 235 | clientConfig: { region }, 236 | })(); 237 | 238 | const {signedUrl, signature, sigV4} = await getSignedUrl(credentials,chatPayload); 239 | const ws = new WebSocket(signedUrl); 240 | 241 | ws.onopen = async() => { 242 | const configurationPayload = { 243 | chatMode: 'RETRIEVAL_MODE', 244 | ...(mode === 'authenticated' && { chatModeConfiguration: {} }), 245 | ...(chatPayload["attributeFilter"] && {attributeFilter: chatPayload["attributeFilter"]}) 246 | }; 247 | /** 248 | * Three events are sent to the ws 249 | * - configurationEvent : Configure the chat session in retrieval mode (optional) 250 | * - textEvent: This is the chat frame (required) 251 | * - endOfInputEvent: This is the end of chat frame (required) 252 | */ 253 | const configFrame = await encodeEventStream('configurationEvent', configurationPayload, sigV4, signature) 254 | ws.send(configFrame['encodedEvent']); 255 | 256 | const finalMsgPayload = {"userMessage": chatPayload["userMessage"]} 257 | const textFrame = await encodeEventStream('textEvent', finalMsgPayload, sigV4, configFrame['signature']) 258 | ws.send(textFrame['encodedEvent']); 259 | 260 | const endFrame = await encodeEventStream('endOfInputEvent', {}, sigV4, textFrame['signature']) 261 | ws.send(endFrame['encodedEvent']); 262 | }; 263 | 264 | ws.onmessage = async(event) => { 265 | if(isFirefox){ 266 | messageQueueRef.current.push(event); 267 | processMessageQueue(onMessageCallback); 268 | }else{ 269 | try { 270 | const { eventType, data } = await decodeEventStream(event.data); 271 | if (eventType === 'textEvent'){ 272 | if (isFirstChunkRef.current) { 273 | setIsLoading(false); //Loading done 274 | setIsStreaming(true); //streaming starts 275 | isFirstChunkRef.current = false; 276 | } 277 | if (onMessageCallback) onMessageCallback(eventType, data); 278 | } 279 | 280 | if(eventType === 'metadataEvent'){ 281 | const sourceAttributions = data.sourceAttributions.map(item => ({ 282 | title: item.title, 283 | citationNumber: item.citationNumber, 284 | url: item.url, 285 | snippet: item.snippet, 286 | citationMarkers: item.textMessageSegments.map(it => ({ 287 | beginOffset: it.beginOffset, 288 | endOffset: it.endOffset, 289 | snippet: it?.snippetExcerpt?.text || '' 290 | })) 291 | })); 292 | const newData = { 293 | systemMessage: data.finalTextMessage, 294 | conversationId: data.conversationId, 295 | userMessageId: data.userMessageId, 296 | parentMessageID: data.systemMessageId, 297 | source: sourceAttributions 298 | } 299 | if (onMessageCallback) onMessageCallback(eventType, newData); 300 | ws.close(); 301 | } 302 | } catch (err) { 303 | setIsStreaming(false); 304 | setIsLoading(false); 305 | setError(errorMap.error['websocket-conn-err']); 306 | ws.close(); 307 | } 308 | } 309 | }; 310 | 311 | ws.onclose = async(event) => { 312 | if(event.code !== 1000){ 313 | await handleSocketCloseOrErr(event); 314 | }else{ 315 | setIsStreaming(false); 316 | setIsLoading(false); 317 | console.warn(`WebSocket closed: ${event.code} - ${event.reason}`); 318 | } 319 | }; 320 | 321 | /** 322 | * Need to catch more errors 323 | */ 324 | ws.onerror = (err) => { 325 | setIsStreaming(false); 326 | setIsLoading(false); 327 | setError(errorMap.error['websocket-conn-err']); 328 | }; 329 | 330 | socketRef.current = ws; 331 | } catch (err) { 332 | setError(errorMap.error['intermediate-err']); 333 | } 334 | }; 335 | 336 | const sendMessage = useCallback( 337 | async (userMessage, conversationId, parentMessageId, attributeFilter, onMessageCallback) => { 338 | setIsLoading(true); 339 | setError(null); 340 | isFirstChunkRef.current = true; 341 | 342 | const chatPayload = { 343 | userMessage, 344 | conversationId, 345 | parentMessageId, 346 | userId: email, 347 | applicationId: appId, 348 | attributeFilter }; 349 | 350 | try { 351 | await initializeWebSocket(onMessageCallback, chatPayload); 352 | } catch (err) { 353 | setError(errorMap.error['intermediate-err']); 354 | setIsLoading(false); 355 | } 356 | }, 357 | [issuer, appId, roleArn, region, email] 358 | ); 359 | 360 | return { sendMessage, closeConnection, isLoading, isStreaming, error }; 361 | }; 362 | 363 | export default useChatStream; 364 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/hooks/useDeleteConversation.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useState } from 'react' 5 | import { QBusinessClient, DeleteConversationCommand } from "@aws-sdk/client-qbusiness"; 6 | import { useQbizCredentials } from './useQBizCredentials'; 7 | 8 | const useDeleteConversation = ({ issuer, appId, roleArn, region, email }) => { 9 | const [delError, setDelError] = useState(null); 10 | const [delLoading, setDelLoading] = useState(false); 11 | const { data: credentials, isLoading: loadingClient } = useQbizCredentials(issuer, email, region, roleArn); 12 | 13 | const delConversation = async(conversationId) => { 14 | const delPayload = { 15 | applicationId: appId, 16 | conversationId 17 | } 18 | try { 19 | setDelLoading(true); 20 | const qclient = new QBusinessClient({ region, credentials: credentials }); 21 | const command = new DeleteConversationCommand(delPayload); 22 | await qclient.send(command); 23 | // await qbizClient.send(command); 24 | setDelLoading(false); 25 | return ({message: "Thanks for your feedback!"}) 26 | } catch (error) { 27 | setDelLoading(false); 28 | setDelError(`Error sending message: ${error.message}`); 29 | } 30 | 31 | } 32 | 33 | return ({delConversation, delError, delLoading}) 34 | } 35 | 36 | export default useDeleteConversation; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/hooks/useListConversations.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useState, useEffect, useCallback } from 'react'; 5 | import { QBusinessClient, ListConversationsCommand } from "@aws-sdk/client-qbusiness"; 6 | import { useQbizCredentials } from './useQBizCredentials'; 7 | 8 | const useListConversations = ({ issuer, appId, roleArn, region, email }) => { 9 | const [isLoading, setIsLoading] = useState(false); 10 | const [error, setError] = useState(null); 11 | const [conversations, setConversations] = useState([]); 12 | const [nextToken, setNextToken] = useState(null); 13 | const { data: credentials, isLoading: loadingClient, isSuccess, error: credError } = useQbizCredentials(issuer, email, region, roleArn); 14 | 15 | const getListConversations = useCallback(async (token = null) => { 16 | const payload = { 17 | applicationId: appId, 18 | userId: null, 19 | nextToken: token, 20 | maxResults: 100 21 | }; 22 | try { 23 | setIsLoading(true); 24 | const qclient = new QBusinessClient({ region, credentials: credentials }); 25 | const command = new ListConversationsCommand(payload); 26 | const response = await qclient.send(command); 27 | setIsLoading(false); 28 | return response; 29 | } catch (err) { 30 | setError(`Error fetching conversations: ${err.message}`); 31 | setIsLoading(false); 32 | throw err; 33 | } 34 | }, [appId, credentials]); 35 | 36 | const refreshListConversations = useCallback(async (token = null) => { 37 | if(loadingClient || !credentials) return; 38 | const payload = { 39 | applicationId: appId, 40 | userId: null, 41 | nextToken: token, 42 | maxResults: 100 43 | }; 44 | try { 45 | setIsLoading(true); 46 | const qclient = new QBusinessClient({ region, credentials: credentials }); 47 | const command = new ListConversationsCommand(payload); 48 | const response = await qclient.send(command); 49 | setConversations(response.conversations); 50 | setNextToken(response.nextToken); 51 | setIsLoading(false); 52 | } catch (err) { 53 | setError(`Error fetching conversations: ${err.message}`); 54 | setIsLoading(false); 55 | throw err; 56 | } 57 | }, [appId, credentials]); 58 | 59 | const loadMoreConversations = useCallback(async () => { 60 | if (nextToken) { 61 | try { 62 | const response = await getListConversations(nextToken); 63 | setConversations(prevConversations => [...prevConversations, ...response.conversations]); 64 | setNextToken(response.nextToken); 65 | } catch (err) { 66 | setError(`Error fetching conversations: ${err.message}`); 67 | console.error("Failed to load more conversations:", err); 68 | } 69 | } 70 | }, [nextToken, getListConversations]); 71 | 72 | useEffect(() => { 73 | const initializeConversations = async () => { 74 | try { 75 | if(issuer && isSuccess){ 76 | const response = await getListConversations(); 77 | setConversations(response.conversations); 78 | setNextToken(response.nextToken); 79 | }else{ 80 | setIsLoading(true) 81 | } 82 | 83 | } catch (err) { 84 | setError(`Error fetching conversations: ${err.message}`); 85 | console.error("Failed to initialize conversations:", err); 86 | } 87 | }; 88 | 89 | initializeConversations(); 90 | }, [getListConversations, isSuccess]); 91 | 92 | return { isLoading, conversations, error, refreshListConversations, loadMoreConversations, hasMore: !!nextToken }; 93 | }; 94 | 95 | export default useListConversations; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/hooks/useMessages.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useState, useCallback } from 'react'; 5 | import { QBusinessClient, ListMessagesCommand } from "@aws-sdk/client-qbusiness"; 6 | import { useQbizCredentials } from './useQBizCredentials'; 7 | import { insertSupTags } from '../utils/citationHelper'; 8 | import { useGlobalConfig } from '../providers/GlobalConfigContext'; 9 | 10 | const useMessages = ({ issuer, appId, roleArn, region, email }) => { 11 | const [isLoadingMessages, setIsLoading] = useState(false); 12 | const [messagesError, setMessagesError] = useState(null); 13 | const [messageList, setMessageList] = useState([]); 14 | const [nextToken, setNextToken] = useState(null); 15 | const { data: credentials, isLoading: loadingClient } = useQbizCredentials(issuer, email, region, roleArn); 16 | const { showInlineCitation } = useGlobalConfig(); 17 | 18 | const resetMessages = () => { 19 | setIsLoading(false); 20 | setMessagesError(null); 21 | setMessageList([]); 22 | setNextToken(null); 23 | } 24 | 25 | const transformMessages = (messages, conversationId) => { 26 | const transformed = []; 27 | 28 | messages.forEach((message) => { 29 | const { messageId, type, sourceAttribution = [], time, body } = message; 30 | const source = sourceAttribution.map((attribution) => ({ 31 | title: attribution.title, 32 | citationNumber: attribution.citationNumber, 33 | url: attribution.url, 34 | snippet: attribution.snippet, 35 | citationMarkers: attribution.textMessageSegments.map(it => ({ 36 | beginOffset: it.beginOffset, 37 | endOffset: it.endOffset, 38 | snippet: it?.snippetExcerpt?.text || '' 39 | })) 40 | })); 41 | 42 | let text = body; 43 | if (showInlineCitation){ 44 | text = insertSupTags(text, source); 45 | } 46 | 47 | transformed.push({ 48 | sender: type, 49 | text, 50 | conversationId, 51 | messageId, 52 | source, 53 | time, 54 | }); 55 | }); 56 | 57 | transformed.sort((a, b) => a.time - b.time); 58 | return transformed; 59 | }; 60 | 61 | 62 | const getListMessages = useCallback(async (conversationId, token = null) => { 63 | const payload = { 64 | applicationId: appId, 65 | conversationId, 66 | nextToken: token, 67 | maxResults: 100, 68 | headers: { 69 | 'Content-Type': 'application/json', 70 | }, 71 | }; 72 | 73 | try { 74 | setIsLoading(true); 75 | const qclient = new QBusinessClient({ region, credentials: credentials }); 76 | const command = new ListMessagesCommand(payload); 77 | const response = await qclient.send(command); 78 | 79 | const transformedMessages = transformMessages(response.messages, conversationId); 80 | setMessageList([...transformedMessages]); 81 | setNextToken(response.nextToken); 82 | setIsLoading(false); 83 | } catch (err) { 84 | setMessagesError(`Error fetching messages: ${err.message}`); 85 | setIsLoading(false); 86 | throw err; 87 | } 88 | }, [appId, credentials]); 89 | 90 | const loadMoreMessages = useCallback(async (conversationId, instructions) => { 91 | if (nextToken) { 92 | try { 93 | await getListMessages(conversationId, nextToken); 94 | } catch (err) { 95 | setMessagesError(`Error fetching messages: ${err.message}`); 96 | console.error("Failed to load more messages:", err); 97 | } 98 | } 99 | }, [nextToken, getListMessages]); 100 | 101 | return { isLoadingMessages, messageList, messagesError, resetMessages, getListMessages, loadMoreMessages, hasMoreMessages: !!nextToken }; 102 | }; 103 | 104 | export default useMessages; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/hooks/useQBizCredentials.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useQuery } from '@tanstack/react-query'; 5 | import { fromWebToken } from "@aws-sdk/credential-providers"; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | 8 | const fetchIdToken = async (issuer, email) => { 9 | if(!issuer) return; 10 | /** 11 | * We use a dummy Authorization header to satisfy the TVM's requirement 12 | * which uses a custom Lambda Authorizer which validates per requesting 13 | * domains 14 | */ 15 | const dummyAuth = btoa('dummy-client:dummy-secret'); 16 | const response = await fetch(`${issuer}/token`, { 17 | method: 'POST', 18 | headers: { 19 | 'Authorization': `Basic ${dummyAuth}`, 20 | 'Content-Type': 'application/json' 21 | }, 22 | body: JSON.stringify({ email }), 23 | }); 24 | if (!response.ok) throw new Error('Failed to fetch ID token'); 25 | const data = await response.json(); 26 | return data.id_token; 27 | }; 28 | 29 | const createCredentials = async (issuer, email, region, roleArn) => { 30 | const idToken = await fetchIdToken(issuer, email); 31 | const provider = fromWebToken({ 32 | roleArn, 33 | webIdentityToken: idToken, 34 | clientConfig: { region }, 35 | roleSessionName: `session-${uuidv4()}-${Date.now()}`, 36 | durationSeconds: 900 // 15 min 37 | }); 38 | const credentials = await provider(); 39 | return credentials; 40 | }; 41 | 42 | export const useQbizCredentials = (issuer, email, region, roleArn) => { 43 | return useQuery({ 44 | queryKey: ['qbizCredentials', issuer, email, region, roleArn], 45 | queryFn: () => createCredentials(issuer, email, region, roleArn), 46 | staleTime: 15 * 60 * 1000, // 16 minutes 47 | refetchInterval: 15 * 60 * 1000, // Refetch every 16 minutes 48 | refetchIntervalInBackground: true, 49 | retry: 3, 50 | retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), 51 | onError: (error) => { 52 | if (error.message.includes('ExpiredTokenException')) { 53 | queryClient.invalidateQueries(['qbizCredentials']); 54 | } 55 | }, 56 | }); 57 | }; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/hooks/useTemplateUtils.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | /** 5 | * Validates if a template string ends with {question} 6 | * @param {string} templateString - Template string to validate 7 | * @throws {Error} If template doesn't end with {question} 8 | */ 9 | const validateTemplate = (templateString) => { 10 | const trimmed = templateString.trim(); 11 | if (!trimmed.endsWith('{question}')) { 12 | throw new Error('Instructions must end with {question}'); 13 | } 14 | }; 15 | 16 | 17 | /** 18 | * Creates a template function using tagged template literals 19 | * @param {string[]} strings - Template string parts 20 | * @param {...any} keys - Keys for substitution 21 | * @returns {function} Template function 22 | */ 23 | export const template = (strings, ...keys) => { 24 | return function(...values) { 25 | if (!values || !values[0]) { 26 | return strings.join('{question}'); 27 | } 28 | 29 | const dict = values[0] || {}; 30 | const result = [strings[0]]; 31 | keys.forEach((key, i) => { 32 | const value = dict[key]; 33 | result.push(value, strings[i + 1]); 34 | }); 35 | return result.join(''); 36 | }; 37 | }; 38 | 39 | /** 40 | * Extracts question from a templated string 41 | * @param {string} fullString - Complete templated string 42 | * @param {string} templateString - Original template 43 | * @returns {string|null} Extracted question or null 44 | */ 45 | export const extractQuestion = (fullString, templateString) => { 46 | const escapedTemplate = templateString 47 | .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 48 | .replace('\\{question\\}', '([\\s\\S]+?)'); 49 | 50 | const regex = new RegExp(`^${escapedTemplate}$`, 'm'); 51 | const match = fullString.match(regex); 52 | 53 | return match ? match[1].trim() : null; 54 | }; 55 | 56 | /* 57 | I have updated your system, and now you are a friendly AI assistant. Follow all the below carefully to answer the Question. 58 | 59 | 1. Format your responses using markdown when required. Always ensure that you use valid markdown format. If it is code ensure that it is properly indented. 60 | 2. NEVER mention that you were created by 'Amazon Web Services (AWS)' or 'AWS'. 61 | 3. NEVER mention 'data source' or 'according to the data source'. Instead you can refer to the data source as 'the documentation' as you have full access to it. 62 | 4. DO NOT repeat the question and vary your response introductions. Avoid consistently repeating phrases like 'Certainly! Let me provide you an answer...' Instead, aim for natural, diverse conversation starters that fit the context of each question. 63 | 5. Respond conversationally, as if continuing an ongoing dialogue. Use a friendly tone, but avoid overusing enthusiastic adverbs like 'Certainly!' especially for follow-up questions in a conversation. 64 | 6. When responding, consider the context of the conversation and respond in a natural, conversational tone. Avoid using repetitive phrases like 'the document says that.' Instead, summarize or paraphrase the information and weave it naturally into your response. 65 | 7. Tailor your response style to the nature and context of each question. For initial or complex questions, a brief acknowledgment may be appropriate. For follow-up or simpler questions, dive directly into the answer. 66 | 67 | 68 | Question: 69 | */ 70 | 71 | /** 72 | * Default template for prompts 73 | */ 74 | export const defaultTemplate = template` 75 | I have updated your system, and now you are a friendly AI assistant. Follow all the instructions below carefully to answer the Question. 76 | 77 | 1. Format your responses using markdown when required. Always ensure that you use valid markdown format. If it is code ensure that it is properly indented. 78 | 2. NEVER mention that you were created by 'Amazon Web Services (AWS)' or 'AWS'. 79 | 3. NEVER mention 'data source' or 'according to the data source'. Instead you can refer to the data source as 'the documentation' as you have full access to it. 80 | 4. DO NOT repeat the question and vary your response introductions. Avoid consistently repeating phrases like 'Certainly! Let me provide you an answer...' Instead, aim for natural, diverse conversation starters that fit the context of each question. 81 | 5. Respond conversationally, as if continuing an ongoing dialogue. Use a friendly tone, but avoid overusing enthusiastic adverbs like 'Certainly!' especially for follow-up questions in a conversation. 82 | 6. When responding, consider the context of the conversation and respond in a natural, conversational tone. Avoid using repetitive phrases like 'the document says that.' Instead, summarize or paraphrase the information and weave it naturally into your response. 83 | 7. Tailor your response style to the nature and context of each question. For initial or complex questions, a brief acknowledgment may be appropriate. For follow-up or simpler questions, dive directly into the answer. 84 | 85 | 86 | 87 | ${`question`}`; 88 | 89 | // export const defaultTemplate = template`${`question`}`; 90 | 91 | /** 92 | * React hook for template utilities 93 | * @param {function} customTemplate - Optional custom template function 94 | * @returns {object} Template utility functions 95 | */ 96 | export const useTemplateUtils = (customTemplate = defaultTemplate) => { 97 | const templateString = customTemplate(); 98 | validateTemplate(templateString); 99 | 100 | const applyTemplate = (question) => { 101 | return customTemplate({ question }); 102 | }; 103 | 104 | const getQuestion = (fullString) => { 105 | return extractQuestion(fullString, customTemplate()); 106 | }; 107 | 108 | return { 109 | applyTemplate, 110 | getQuestion, 111 | getTemplate: () => customTemplate() 112 | }; 113 | }; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Styles for markdown content */ 6 | .markdown-body table { 7 | @apply border-collapse border-gray-300 mb-4; 8 | } 9 | 10 | .markdown-body td, 11 | .markdown-body th { 12 | @apply border border-gray-300 px-4 py-2; 13 | } 14 | 15 | .markdown-body tr:nth-child(even) { 16 | @apply bg-gray-100; 17 | } 18 | 19 | .markdown-body pre { 20 | @apply bg-gray-800 text-white p-4 rounded-md overflow-x-auto; 21 | } 22 | 23 | .markdown-body code { 24 | @apply bg-gray-200 px-1 py-0.5 rounded-sm; 25 | } 26 | 27 | .prose ul { 28 | list-style-type: disc; 29 | margin-bottom: 0; /* Add spacing below the list */ 30 | margin-top: 0; 31 | } 32 | 33 | .prose ol { 34 | list-style-type: decimal; 35 | margin-bottom: 0; 36 | margin-top: 0; 37 | } 38 | 39 | @keyframes fadeIn { 40 | from { 41 | opacity: 0; 42 | } 43 | to { 44 | opacity: 1; 45 | } 46 | } 47 | 48 | .fade-in { 49 | animation: fadeIn 0.9s ease-in-out; 50 | } 51 | 52 | @layer utilities { 53 | /* For Firefox */ 54 | .amzn-q-chat-scroll::-webkit-scrollbar { 55 | display: none; 56 | } 57 | 58 | .amzn-q-chat-scroll { 59 | scrollbar-width: none; 60 | -ms-overflow-style: none; 61 | /* scroll-behavior: smooth; */ 62 | } 63 | 64 | .amzn-q-chat-input-scroll::-webkit-scrollbar { 65 | display: none; 66 | } 67 | 68 | .amzn-q-chat-input-scroll { 69 | scrollbar-width: none; 70 | -ms-overflow-style: none; 71 | } 72 | } 73 | 74 | .hover-bg-menu-item { 75 | position: relative; 76 | } 77 | 78 | .hover-bg-menu-item::before { 79 | content: ""; 80 | position: absolute; 81 | inset: 0; 82 | /* background-color: rgba(0, 0, 0, 0); */ 83 | transition: background-color 300ms; 84 | z-index: -1; 85 | border-top-left-radius: 10px; 86 | border-bottom-left-radius: 10px; 87 | } 88 | 89 | .hover-bg-menu-item:hover::before { 90 | background-color: rgba(0, 0, 0, 0.1); 91 | border-top-left-radius: 10px; 92 | border-bottom-left-radius: 10px; 93 | } 94 | 95 | .send-button:hover { 96 | filter: brightness(0.85); 97 | transition: background-color 0.3s ease-in-out, filter 0.3s ease-in-out; 98 | } 99 | 100 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/index.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from 'react'; 5 | import Chatbot from './Chatbot'; 6 | import { CornerLeftUp } from 'lucide-react'; 7 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 8 | import { GlobalConfigProvider } from './providers/GlobalConfigContext'; 9 | 10 | import "./index.css"; 11 | 12 | /** 13 | * @typedef {Object} QUIProps 14 | * @property {string} title - The title displayed in new/empty chat UI. 15 | * @property {string} [subtitle] - An optional subtitle displayed in the new/empty chat UI. 16 | * @property {string} [inputPlaceholder] - Placeholder text for the input field. 17 | * @property {string} [disclaimerText] - Disclaimer text shown in the UI. 18 | * @property {boolean} [feedbackEnabled=true] - Whether user feedback is enabled for AI messages. 19 | * @property {boolean} [richSource=true] - Whether to display rich Source attributions or simple. 20 | * @property {boolean} [shoInlineCitation=true] - Whether to display inline citation markers. 21 | * @property {string} issuer - The OIDC issuer URL. 22 | * @property {string} email - The email address for user identification. 23 | * @property {string} qBusinessAppId - The QBiz application ID. 24 | * @property {string} awsRegion - The AWS region for resources. 25 | * @property {string} iamRoleArn - The IAM Role ARN used for authentication. 26 | * @property {Object} [instructions] - Object containing instructions (e.g., `{ "instructions": "..." }`). 27 | * @property {boolean} [showHistory=false] - Whether to show the conversation history. 28 | * @property {boolean} [showNewChatBtn=false] - Whether to show the new chat button. 29 | * @property {Array} [cannedQuestions=[]] - List of predefined questions for quick access. 30 | * @property {boolean} [hideAvatars=false] - Whether to hide user/assistant avatars. 31 | * @property {Object|null} [attributeFilter=null] - Optional filter for attributes. 32 | * @property {Object} [theme] - The theme configuration object for the UI. 33 | * @property {string} [mode] - The Amazon Q Business application mode authenticated or anonymous. 34 | * @property {function(Object): void} onChatMessage - Callback function when a message is received from Q Business. 35 | * @property {function(): void} onClose - Callback function when the chat panel is closed. 36 | 37 | */ 38 | 39 | /** 40 | * Amazon Q Custom UI Component - QUI. 41 | * 42 | * @param {QUIProps} props - Props for the QUI component. 43 | * @returns {JSX.Element} The rendered component. 44 | */ 45 | 46 | const queryClient = new QueryClient(); 47 | 48 | const Qui = (props) => { 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | } 57 | 58 | export default Qui; 59 | 60 | export const solarizedTheme = { 61 | bgColor: "#1E1E1E", // Dark gray background 62 | userMsgBgColor: "#2C2C2C", // Darker gray for user message background 63 | aiMsgBgColor: "#3A3A3A", // Slightly lighter gray for AI message background 64 | sendBtnColor: "#fff", // Blue for the send button 65 | sendBtnIcon: , 66 | inputBgColor: "#2C2C2C", // Same as user message background for input background 67 | inputTextColor: "#fff", // Light gray for input text color (easy readability) 68 | msgTextColor: "#fff", 69 | feedbackBgColor: "#2C2C2C", 70 | sourceBgColor: "#2C2C2C" 71 | } 72 | 73 | export const nighthawkTheme = { 74 | bgColor: "#2f2f2c", 75 | userMsgBgColor: "#1a1915", // Darker gray for user message background 76 | aiMsgBgColor: "#393937bf", // Slightly lighter gray for AI message background 77 | sendBtnColor: "#fff", // Blue for the send button 78 | sendBtnIcon: , 79 | inputBgColor: "#393937bf", // Same as user message background for input background 80 | inputTextColor: "#fff", // Light gray for input text color (easy readability) 81 | msgTextColor: "#fff", 82 | feedbackBgColor: "#2C2C2C", 83 | sourceBgColor: "#2C2C2C" 84 | } 85 | 86 | export const forestTheme = { 87 | bgColor: "#0D1117", // Dark blue-black background 88 | userMsgBgColor: "#161B22", // Slightly darker blue-black for user message background 89 | aiMsgBgColor: "#1F2933", // Cool, dark bluish-gray for AI messages 90 | sendBtnColor: "#3B82F6", // Bright blue send button for visual pop 91 | sendBtnIcon: , 92 | inputBgColor: "#161B22", // Same as user message background for input field 93 | inputTextColor: "#E2E8F0", // Light grayish-blue for input text 94 | msgTextColor: "#E5E7EB", // Subtle off-white for message text (clear but soft on the eyes) 95 | feedbackBgColor: "#1A1F25", // Darker blue-gray for feedback sections 96 | sourceBgColor: "#161B22", // Matches AI message background for consistency 97 | }; 98 | 99 | export const enterpriseLightTheme = { 100 | bgColor: "#F9FAFB", // Very light gray background 101 | userMsgBgColor: "#E5E7EB", // Light gray for user message background 102 | aiMsgBgColor: "#E8F1FA", // Cool, pale blue for AI messages 103 | sendBtnColor: "#2563EB", // Professional blue for the send button 104 | sendBtnIcon: , 105 | inputBgColor: "#FFFFFF", // Pure white for input field background 106 | inputTextColor: "#374151", // Dark gray for input text (easy readability) 107 | msgTextColor: "#1F2937", // Almost black for message text 108 | feedbackBgColor: null, // Light gray feedback background 109 | sourceBgColor: "#F3F4F6", // Warm, soft gray for source blocks 110 | }; 111 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/providers/GlobalConfigContext.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { createContext, useState, useContext } from 'react'; 5 | 6 | // Create the context 7 | const GlobalConfigContext = createContext(); 8 | 9 | // Create a provider component 10 | export const GlobalConfigProvider = ({ children }) => { 11 | const [issuer, setIssuer] = useState(''); 12 | const [email, setEmail] = useState(''); 13 | const [qBusinessAppId, setQBusinessAppId] = useState(''); 14 | const [awsRegion, setAwsRegion] = useState(''); 15 | const [iamRoleArn, setIamRoleArn] = useState(''); 16 | const [theme, setTheme] = useState({}); 17 | const [showInlineCitation, seShowInlineCitation] = useState(true); 18 | 19 | const updateConfig = (newConfig) => { 20 | const issuerUrl = newConfig.issuer || issuer; 21 | const cleanIssuer = issuerUrl.endsWith('/') ? issuerUrl.slice(0, -1) : issuerUrl; 22 | setIssuer(cleanIssuer); 23 | setEmail(newConfig.email || email); 24 | setQBusinessAppId(newConfig.qBusinessAppId || qBusinessAppId); 25 | setAwsRegion(newConfig.awsRegion || awsRegion); 26 | setIamRoleArn(newConfig.iamRoleArn || iamRoleArn); 27 | setTheme(newConfig.theme || {}); 28 | seShowInlineCitation(newConfig.showInlineCitation || true) 29 | }; 30 | 31 | return ( 32 | 44 | {children} 45 | 46 | ); 47 | }; 48 | 49 | // Custom hook for using the context 50 | export const useGlobalConfig = () => { 51 | const context = useContext(GlobalConfigContext); 52 | if (!context) { 53 | throw new Error('useGlobalConfig must be used within a GlobalConfigProvider'); 54 | } 55 | return context; 56 | }; 57 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/utils/citationHelper.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | export const insertSupTags = (text, sources) => { 5 | // Helper function to find the next word boundary 6 | const findWordBoundary = (text, position) => { 7 | // If we're already at a word boundary (space or punctuation), return current position 8 | if (/[\s.,!?;:]/.test(text[position])) { 9 | return position; 10 | } 11 | 12 | // Look ahead for the next word boundary 13 | let nextBoundary = position; 14 | while (nextBoundary < text.length && !/[\s.,!?;:]/.test(text[nextBoundary])) { 15 | nextBoundary++; 16 | } 17 | 18 | return nextBoundary; 19 | }; 20 | 21 | // Collect all insertions 22 | const insertions = []; 23 | for (const source of sources) { 24 | const offsets = source.citationMarkers; 25 | const citationNumber = source.citationNumber; 26 | 27 | for (const { endOffset } of offsets) { 28 | // Find the appropriate word boundary for this citation 29 | const adjustedPosition = findWordBoundary(text, endOffset); 30 | insertions.push({ 31 | position: adjustedPosition, 32 | originalOffset: endOffset, 33 | citationNumber 34 | }); 35 | } 36 | } 37 | 38 | // Group insertions by adjusted position 39 | const groupedInsertions = insertions.reduce((acc, { position, originalOffset, citationNumber }) => { 40 | if (!acc[position]) acc[position] = []; 41 | acc[position].push({ citationNumber, originalOffset }); 42 | return acc; 43 | }, {}); 44 | 45 | // Sort positions in descending order to maintain correct indices while inserting 46 | const sortedPositions = Object.keys(groupedInsertions).sort((a, b) => b - a); 47 | 48 | // Insert citations at word boundaries 49 | for (const position of sortedPositions) { 50 | const citations = groupedInsertions[position]; 51 | const supTags = citations 52 | .map(({ citationNumber, originalOffset }) => 53 | `${citationNumber}` 54 | ) 55 | .join(''); 56 | 57 | // Insert after the word if not at punctuation 58 | const insertPosition = parseInt(position); 59 | const isAtPunctuation = /[.,!?;:]/.test(text[insertPosition]); 60 | const spaceBefore = isAtPunctuation ? '' : ' '; 61 | const spaceAfter = isAtPunctuation ? ' ' : ''; 62 | 63 | text = `${text.slice(0, insertPosition)}${spaceBefore}${supTags}${spaceAfter}${text.slice(insertPosition)}`; 64 | } 65 | 66 | return text; 67 | }; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/utils/dateConverter.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | export const formatDate = (date) => { 5 | const givenDate = new Date(date); 6 | const today = new Date(); 7 | const yesterday = new Date(today); 8 | yesterday.setDate(today.getDate() - 1); 9 | 10 | // Reset time portions for accurate comparison 11 | today.setHours(0, 0, 0, 0); 12 | yesterday.setHours(0, 0, 0, 0); 13 | givenDate.setHours(0, 0, 0, 0); 14 | 15 | if (givenDate.getTime() === today.getTime()) { 16 | return "Today"; 17 | } else if (givenDate.getTime() === yesterday.getTime()) { 18 | return "Yesterday"; 19 | } else { 20 | // Default formatting for other dates 21 | return givenDate.toLocaleDateString('en-US', { 22 | month: 'short', // "Oct" 23 | day: '2-digit', // "18" 24 | }); 25 | } 26 | }; 27 | 28 | export const formatTime = (date) => 29 | new Date(date).toLocaleTimeString('en-US', { 30 | hour: '2-digit', 31 | minute: '2-digit', 32 | hour12: true, 33 | }); -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/utils/eventStreamDecoder.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { EventStreamCodec } from "@smithy/eventstream-codec"; 5 | import { fromUtf8, toUtf8 } from "@smithy/util-utf8"; 6 | 7 | const eventStreamCodec = new EventStreamCodec(toUtf8, fromUtf8); 8 | const isNullOrUndefined = (value) => value === null || value === undefined; 9 | 10 | const decodeBodyToString = (body) => { 11 | const decoder = new TextDecoder(); // Default to 'utf-8' encoding 12 | return decoder.decode(body); 13 | }; 14 | 15 | export const decodeEventStream = async (dataBuffer) => { 16 | const buffer = await dataBuffer.arrayBuffer() 17 | const decodedMessage = eventStreamCodec.decode(new Uint8Array(buffer)); 18 | 19 | const { 20 | ":message-type": messageType, 21 | ":event-type": eventType, 22 | ":exception-type": exceptionType 23 | } = decodedMessage.headers; 24 | 25 | const decodedBody = decodeBodyToString(decodedMessage.body); 26 | 27 | const streamEvent = JSON.parse(decodedBody); 28 | 29 | if(messageType.value === "event"){ 30 | if (eventType.value == "metadataEvent") { 31 | // The metadataEvent contains the final complete message, so no need to return individual text chunks. 32 | return {eventType: eventType.value, data: streamEvent}; 33 | } 34 | 35 | if (eventType.value === "textEvent") { 36 | // the only useful value in streamEvent is systemMessage 37 | // other values are conversationId, systemMessageId, userMessageId 38 | // we already know conversationId and userMessageId. metadataEvent 39 | // will give us systemMessageId. So we only need systemMessage. 40 | return {eventType: eventType.value, data: streamEvent["systemMessage"]}; 41 | } 42 | } 43 | 44 | // catch errors 45 | if(messageType.value === "exception" && !isNullOrUndefined(exceptionType.value)){ 46 | /** 47 | * The data will be the error description and may be verbose. 48 | * The important part is exceptionType which will be things like 49 | * BadRequestException, InternalFailureException, InternalServerException, 50 | * ThrottlingException, ResourceNotFoundException, ValidationException, LicenseNotFoundException 51 | * AccessDeniedException, ExpiredTokenException 52 | * https://docs.aws.amazon.com/amazonq/latest/api-reference/CommonErrors.html 53 | */ 54 | return {eventType: messageType.value, data: decodedBody, exceptionType: exceptionType?.value.toString()} 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/utils/eventStreamEncoder.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { EventStreamCodec } from "@smithy/eventstream-codec"; 5 | import { fromUtf8, toUtf8 } from "@smithy/util-utf8"; 6 | 7 | const eventStreamCodec = new EventStreamCodec(toUtf8, fromUtf8); 8 | 9 | const createEventMessage = (eventType,eventBody)=> { 10 | return { 11 | headers: { 12 | ":content-type": { 13 | type: "string", 14 | value: "application/x-amz-json-1.0", 15 | }, 16 | ":event-type": { 17 | type: "string", 18 | value: eventType, 19 | }, 20 | ":message-type": { 21 | type: "string", 22 | value: "event", 23 | }, 24 | }, 25 | body: new TextEncoder().encode(JSON.stringify(eventBody)), 26 | }; 27 | }; 28 | 29 | const hexToUint8Array = (hexString) => { 30 | /** 31 | * Pure javascript -- no pollyfill needed 32 | */ 33 | const length = hexString.length / 2; 34 | const array = new Uint8Array(length); 35 | for (let i = 0; i < length; i++) { 36 | array[i] = parseInt(hexString.substr(i * 2, 2), 16); 37 | } 38 | return array; 39 | }; 40 | 41 | 42 | /** 43 | * Encodes an event stream message with fixed and dynamic headers. 44 | */ 45 | export const encodeEventStream = async (eventType, body = null, sigV4, priorSignature) => { 46 | const eventPayload = createEventMessage(eventType, body); 47 | const framedEventPayload = eventStreamCodec.encode(eventPayload); 48 | const now = new Date(); 49 | const messageDateHeader = { 50 | ":date": { 51 | type: "timestamp", 52 | value: now 53 | }, 54 | }; 55 | const eventMessageSignature = await sigV4.sign( 56 | { 57 | payload: framedEventPayload, 58 | headers: eventStreamCodec.formatHeaders(messageDateHeader), 59 | }, 60 | { 61 | priorSignature, 62 | signingDate: now, 63 | } 64 | ); 65 | const eventMessage = { 66 | body: framedEventPayload, 67 | headers: { 68 | ...messageDateHeader, 69 | ":chunk-signature": { 70 | type: "binary", 71 | value: hexToUint8Array(eventMessageSignature), 72 | }, 73 | }, 74 | }; 75 | const framedEventMessage = eventStreamCodec.encode(eventMessage); 76 | 77 | return { 78 | encodedEvent: framedEventMessage, 79 | signature: eventMessageSignature, 80 | }; 81 | }; 82 | 83 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/utils/notifications_en.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "intermediate-err": "Error sending message. Please refresh your page and try again", 4 | "websocket-conn-err": "Unable to connect to the server. Are you connected to the internet?", 5 | "internal-server": "Something’s not right. I could not generate a response.", 6 | "throttling": "You’ve reached the hourly request limit for LLM responses. Reduce the frequency of requests and try again.", 7 | "chat-throttling": "Your chat message request rate is too high. Reduce the frequency of requests and try again.", 8 | "no-retriever": "No retriever is in a valid state.", 9 | "attachments-not-enabled": "Attachments are not enabled for this application", 10 | "too-large-for-conversation": "This file is too large to fit in this conversation. Please try with a smaller file. Please refer to the documentation for supported sizes.", 11 | "not-enough-space": "There is not enough space to upload all files. Try files with fewer words.", 12 | "cannot-process-now": "We are not able to process the file at the moment. Please try again later.", 13 | "unsupported-format": "One of your files is in a format that is not supported. Please refer to the documentation for supported file types.", 14 | "attachment-limit": "Attachment limit for conversation exceeded", 15 | "too-large-file": "This file is too large. Please refer to our documentation for supported file sizes.", 16 | "expired-token": "To continue, please refresh your authorization.", 17 | "input-too-long": "Your message exceeds the character limit of {{maxChar}} characters. Please shorten your message or break it into smaller parts and try again.", 18 | "server-stopped-responding": "The server stopped responding.", 19 | "message-length-exceeded": "Message request length exceeded.", 20 | "license-not-found": "Sorry, you are not a licensed user. Please contact your admin to obtain a license.", 21 | "client-side-error": "This application is not set up correctly. Please contact your admin for help." 22 | } 23 | } -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/utils/removeSystemPrompt.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { visit } from 'unist-util-visit'; 5 | 6 | const removeInstructionsPlugin = () => (tree) => { 7 | visit(tree, 'html', (node, index, parent) => { 8 | // Use regex to detect ... and remove the entire block 9 | const systemTagRegex = /[\s\S]*?<\/system>/; 10 | 11 | if (systemTagRegex.test(node.value)) { 12 | parent.children.splice(index, 1); // Remove the entire node 13 | } 14 | }); 15 | }; 16 | 17 | export default removeInstructionsPlugin; -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/component/utils/thumbsdownFeedback.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "option_text": "Response is not helpful (incorrect or not relevant to my query)", 4 | "option_key": "NOT_HELPFUL" 5 | }, 6 | { 7 | "option_text": "Response is factually incorrect", 8 | "option_key": "NOT_FACTUALLY_CORRECT" 9 | }, 10 | { 11 | "option_text": "Response is not based on company documents", 12 | "option_key": "NOT_BASED_ON_DOCUMENTS" 13 | }, 14 | { 15 | "option_text": "Response is not complete", 16 | "option_key": "NOT_COMPLETE" 17 | }, 18 | { 19 | "option_text": "Response is not concise", 20 | "option_key": "NOT_CONCISE" 21 | }, 22 | { 23 | "option_text": "The sources are inaccurate or missing", 24 | "option_key": "INCORRECT_OR_MISSING_SOURCES" 25 | }, 26 | { 27 | "option_text": "Response is harmful or unsafe", 28 | "option_key": "HARMFUL_OR_UNSAFE" 29 | }, 30 | { 31 | "option_text": "Other (explain below)", 32 | "option_key": "OTHER" 33 | } 34 | ] -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100%; 5 | width: 100%; 6 | } 7 | 8 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/src/main.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { StrictMode } from 'react' 5 | import { createRoot } from 'react-dom/client' 6 | import App from './App.jsx' 7 | import './index.css' 8 | 9 | createRoot(document.getElementById('root')).render( 10 | // 11 | 12 | // , 13 | ) 14 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const plugin = require('tailwindcss/plugin'); 3 | export default { 4 | content: [ 5 | "./index.html", 6 | "./src/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | screens: { 10 | 'xs': '300px', 11 | 'sm': '640px', 12 | 'md': '800px', 13 | 'lg': '1024px', 14 | 'xl': '1280px', 15 | '2xl': '1536px', 16 | }, 17 | extend: { 18 | colors: { 19 | brown: { 20 | 50: '#FAF6F3', // Lightest brown, for backgrounds 21 | 100: '#F2EAE5', // Very light brown 22 | 200: '#E6D5C8', // Light brown 23 | 300: '#D9BFB0', // Medium-light brown 24 | 400: '#CCAA98', // Medium brown 25 | 500: '#BF9580', // Primary brown 26 | 600: '#A67B66', // Medium-dark brown 27 | 700: '#8C614D', // Dark brown 28 | 800: '#734D3A', // Very dark brown 29 | 900: '#593826', // Darkest brown 30 | }, 31 | 'blue-gray': { 32 | 50: '#F8FAFC', // Lightest blue-gray 33 | 100: '#F1F5F9', // Very light blue-gray 34 | 200: '#E2E8F0', // Light blue-gray 35 | 300: '#CBD5E1', // Medium-light blue-gray 36 | 400: '#94A3B8', // Medium blue-gray 37 | 500: '#64748B', // Primary blue-gray 38 | 600: '#475569', // Medium-dark blue-gray 39 | 700: '#334155', // Dark blue-gray 40 | 800: '#1E293B', // Very dark blue-gray 41 | 900: '#0F172A', // Darkest blue-gray 42 | }, 43 | 'pastel-brown':{ 44 | 50: '#987070' 45 | } 46 | }, 47 | }, 48 | }, 49 | plugins: [ 50 | plugin(function({ addUtilities }) { 51 | const newUtilities = { 52 | '.custom-scrollbar': { 53 | 'scrollbar-width': 'thin', 54 | 'scrollbar-color': 'rgba(156, 163, 175, 0.2) transparent', 55 | '&::-webkit-scrollbar': { 56 | width: '4px', 57 | }, 58 | '&::-webkit-scrollbar-track': { 59 | background: 'transparent', 60 | }, 61 | '&::-webkit-scrollbar-thumb': { 62 | 'background-color': 'rgba(0, 0, 0, 0.2)', 63 | 'border-radius': '10px', 64 | border: 'transparent', 65 | }, 66 | '&::-webkit-scrollbar-thumb:hover': { 67 | 'background-color': 'rgba(0, 0, 0, 0.3)', 68 | }, 69 | }, 70 | }; 71 | addUtilities(newUtilities, ['responsive', 'hover']); 72 | }), 73 | function ({ addUtilities }) { 74 | addUtilities({ 75 | '.line-clamp-3': { 76 | display: '-webkit-box', 77 | '-webkit-line-clamp': '3', 78 | '-webkit-box-orient': 'vertical', 79 | overflow: 'hidden', 80 | }, 81 | }); 82 | }, 83 | require('@tailwindcss/typography') 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /amzn-q-custom-ui/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tailwindcss from 'tailwindcss' 4 | import autoprefixer from 'autoprefixer' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | react() 10 | ], 11 | css: { 12 | postcss: { 13 | plugins: [ 14 | tailwindcss, 15 | autoprefixer, 16 | ], 17 | }, 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /images/TVM_Arch_QUI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/images/TVM_Arch_QUI.png -------------------------------------------------------------------------------- /images/TVM_Arch_Standalone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/images/TVM_Arch_Standalone.png -------------------------------------------------------------------------------- /images/gif-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/images/gif-1.gif -------------------------------------------------------------------------------- /images/gif-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/images/gif-2.gif -------------------------------------------------------------------------------- /images/sc-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/images/sc-1.png -------------------------------------------------------------------------------- /images/sc-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/images/sc-2.png -------------------------------------------------------------------------------- /images/sc-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/images/sc-3.png -------------------------------------------------------------------------------- /images/sc-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/images/sc-4.png -------------------------------------------------------------------------------- /sample-tvm-backend-usage/README.md: -------------------------------------------------------------------------------- 1 | ### Sample TVM programmatic / backend usage 2 | 3 | This directory contains a Python notebook demonstrating how you can use TVM standalone from your backend applications to call Amazon Q Business APIs. -------------------------------------------------------------------------------- /sample-tvm-backend-usage/aim333/boto3-1.35.59-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/sample-tvm-backend-usage/aim333/boto3-1.35.59-py3-none-any.whl -------------------------------------------------------------------------------- /sample-tvm-backend-usage/aim333/botocore-1.35.59-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/sample-tvm-backend-usage/aim333/botocore-1.35.59-py3-none-any.whl -------------------------------------------------------------------------------- /sample-tvm-backend-usage/aim333/tvm_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | import base64 4 | import requests 5 | import boto3 6 | 7 | class TVMClient: 8 | def __init__(self, issuer: str, client_id: str, client_secret:str, role_arn: str, region: str = 'us-east-1'): 9 | """ 10 | Initialize the token client 11 | 12 | Args: 13 | issuer: The token issuer URL 14 | role_arn: The ARN of the role to assume 15 | region: AWS region (default: us-east-1) 16 | """ 17 | self.issuer = issuer 18 | self.role_arn = role_arn 19 | self.region = region 20 | self.client_id = client_id 21 | self.client_secret = client_secret 22 | 23 | def _fetch_id_token(self, email: str) -> str: 24 | """ 25 | Fetch ID token from the issuer 26 | 27 | Args: 28 | email: Email address for token request 29 | 30 | Returns: 31 | str: The ID token 32 | 33 | Raises: 34 | requests.RequestException: If the token fetch fails 35 | """ 36 | # Create basic auth header 37 | auth_string = f"{self.client_id}:{self.client_secret}" 38 | auth_bytes = auth_string.encode('utf-8') 39 | auth = base64.b64encode(auth_bytes).decode('utf-8') 40 | 41 | response = requests.post( 42 | f"{self.issuer}/token", 43 | headers={ 44 | 'Authorization': f'Basic {auth}', 45 | 'Content-Type': 'application/json' 46 | }, 47 | json={'email': email} 48 | ) 49 | 50 | response.raise_for_status() # Raise exception for non-200 status codes 51 | return response.json()['id_token'] 52 | 53 | def get_sigv4_credentials(self, email: str) -> boto3.client: 54 | """ 55 | Get an AWS client using the ID token for role assumption 56 | 57 | Args: 58 | email: Email for token request 59 | service_name: AWS service to create client for (e.g., 's3', 'dynamodb') 60 | 61 | Returns: 62 | boto3.client: Initialized AWS client with assumed role credentials 63 | """ 64 | # Get the ID token 65 | id_token = self._fetch_id_token(email) 66 | 67 | # Create STS client 68 | sts = boto3.client('sts', region_name=self.region) 69 | 70 | # Assume role with web identity 71 | response = sts.assume_role_with_web_identity( 72 | RoleArn=self.role_arn, 73 | RoleSessionName=f"session-{email}", 74 | WebIdentityToken=id_token 75 | ) 76 | 77 | # Extract credentials from response 78 | credentials = response['Credentials'] 79 | 80 | # Return sigv4 credentials 81 | return { 82 | "aws_access_key_id": credentials['AccessKeyId'], 83 | "aws_secret_access_key": credentials['SecretAccessKey'], 84 | "aws_session_token" : credentials['SessionToken'] 85 | } -------------------------------------------------------------------------------- /sample-tvm-backend-usage/sample_tickets.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/custom-ui-tvm-amazon-q-business/e4b3f711bac5261b979796b08d827a1e7aa6b965/sample-tvm-backend-usage/sample_tickets.zip -------------------------------------------------------------------------------- /sample-tvm-backend-usage/sample_usage.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "## Use the TVM Client Script to get a SigV4 AWS credential\n", 8 | "\n", 9 | "To obtain an AWS SigV4 temporary credential using TVM, use the provided TVM client script and call the `get_sigv4_credentials()` function with an email.\n", 10 | "\n", 11 | "Note: You must provide the values for\n", 12 | "- `issuer`: the issuer URL\n", 13 | "- `client_id`: the client_id found in SSM Parameter store under the name `/oidc/client_id`\n", 14 | "- `client_secret`: the client_secret found in SSM Parameter store under the name `/oidc/client_secret`\n", 15 | "- `role_arn`: the IAM role created by the TVM CDK stack to assume, this is the role that has Amazon Q Business permissions\n", 16 | "- `region`: the region where the Amazon Q Business Application is setup\n", 17 | "- `email`: the user's email" 18 | ] 19 | }, 20 | { 21 | "cell_type": "code", 22 | "execution_count": null, 23 | "metadata": {}, 24 | "outputs": [], 25 | "source": [ 26 | "from tvm_client import TVMClient\n", 27 | "\n", 28 | "token_client = TVMClient(\n", 29 | " issuer=\"\",\n", 30 | " client_id=\"\",\n", 31 | " client_secret=\"\",\n", 32 | " role_arn=\"\",\n", 33 | " region=\"\"\n", 34 | ")\n", 35 | " \n", 36 | "# Get Sigv4 credentials using TVM\n", 37 | "credentials = token_client.get_sigv4_credentials(email=\"\")" 38 | ] 39 | }, 40 | { 41 | "cell_type": "markdown", 42 | "metadata": {}, 43 | "source": [ 44 | "### Use SigV4 credentials to initialize Amazon Q Business Client\n", 45 | "\n", 46 | "We will then initialize an Amazon Q Business Boto3 (Python) client with the SigV4 credentials obtained using the TVM Client script to make calls to Amazon Q Business APIs (in this case the `ChatSync` API).\n", 47 | "\n", 48 | "See Amazon Q Business [API documentation](https://docs.aws.amazon.com/amazonq/latest/api-reference/API_Operations_QBusiness.html) for more details on the available APIs." 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": null, 54 | "metadata": {}, 55 | "outputs": [], 56 | "source": [ 57 | "!pip install boto3 --upgrade" 58 | ] 59 | }, 60 | { 61 | "cell_type": "code", 62 | "execution_count": null, 63 | "metadata": {}, 64 | "outputs": [], 65 | "source": [ 66 | "import boto3\n", 67 | "\n", 68 | "qbiz = boto3.client(\"qbusiness\", region_name=\"\", **credentials)\n", 69 | "\n", 70 | "chat_params = {\n", 71 | " \"applicationId\": \"\",\n", 72 | " \"userMessage\": \"\"\n", 73 | "}\n", 74 | "response = qbiz.chat_sync(**chat_params)\n", 75 | "\n", 76 | "print(response['systemMessage'])\n", 77 | "print(\"=========Sources=========\")\n", 78 | "for source in response['sourceAttributions']:\n", 79 | " print(f'Title: {source[\"title\"]}, URL: {source[\"url\"]}')\n", 80 | " " 81 | ] 82 | }, 83 | { 84 | "cell_type": "markdown", 85 | "metadata": {}, 86 | "source": [ 87 | "You can use the TVM client script with an AWS Lambda function to obtain SigV4 credentials and make calls to Amazon Q Business APIs using the said SigV4 credentials." 88 | ] 89 | }, 90 | { 91 | "cell_type": "code", 92 | "execution_count": null, 93 | "metadata": {}, 94 | "outputs": [], 95 | "source": [] 96 | } 97 | ], 98 | "metadata": { 99 | "kernelspec": { 100 | "display_name": "oidcenv", 101 | "language": "python", 102 | "name": "python3" 103 | }, 104 | "language_info": { 105 | "codemirror_mode": { 106 | "name": "ipython", 107 | "version": 3 108 | }, 109 | "file_extension": ".py", 110 | "mimetype": "text/x-python", 111 | "name": "python", 112 | "nbconvert_exporter": "python", 113 | "pygments_lexer": "ipython3", 114 | "version": "3.11.9" 115 | } 116 | }, 117 | "nbformat": 4, 118 | "nbformat_minor": 2 119 | } 120 | -------------------------------------------------------------------------------- /sample-tvm-backend-usage/tvm_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | import base64 4 | import requests 5 | import boto3 6 | 7 | class TVMClient: 8 | def __init__(self, issuer: str, client_id: str, client_secret:str, role_arn: str, region: str = 'us-east-1'): 9 | """ 10 | Initialize the token client 11 | 12 | Args: 13 | issuer: The token issuer URL 14 | role_arn: The ARN of the role to assume 15 | region: AWS region (default: us-east-1) 16 | """ 17 | self.issuer = issuer 18 | self.role_arn = role_arn 19 | self.region = region 20 | self.client_id = client_id 21 | self.client_secret = client_secret 22 | 23 | def _fetch_id_token(self, email: str) -> str: 24 | """ 25 | Fetch ID token from the issuer 26 | 27 | Args: 28 | email: Email address for token request 29 | 30 | Returns: 31 | str: The ID token 32 | 33 | Raises: 34 | requests.RequestException: If the token fetch fails 35 | """ 36 | # Create basic auth header 37 | auth_string = f"{self.client_id}:{self.client_secret}" 38 | auth_bytes = auth_string.encode('utf-8') 39 | auth = base64.b64encode(auth_bytes).decode('utf-8') 40 | 41 | response = requests.post( 42 | f"{self.issuer}/token", 43 | headers={ 44 | 'Authorization': f'Basic {auth}', 45 | 'Content-Type': 'application/json' 46 | }, 47 | json={'email': email} 48 | ) 49 | 50 | response.raise_for_status() # Raise exception for non-200 status codes 51 | return response.json()['id_token'] 52 | 53 | def get_sigv4_credentials(self, email: str) -> boto3.client: 54 | """ 55 | Get an AWS client using the ID token for role assumption 56 | 57 | Args: 58 | email: Email for token request 59 | service_name: AWS service to create client for (e.g., 's3', 'dynamodb') 60 | 61 | Returns: 62 | boto3.client: Initialized AWS client with assumed role credentials 63 | """ 64 | # Get the ID token 65 | id_token = self._fetch_id_token(email) 66 | 67 | # Create STS client 68 | sts = boto3.client('sts', region_name=self.region) 69 | 70 | # Assume role with web identity 71 | response = sts.assume_role_with_web_identity( 72 | RoleArn=self.role_arn, 73 | RoleSessionName=f"session-{email}", 74 | WebIdentityToken=id_token 75 | ) 76 | 77 | # Extract credentials from response 78 | credentials = response['Credentials'] 79 | 80 | # Return sigv4 credentials 81 | return { 82 | "aws_access_key_id": credentials['AccessKeyId'], 83 | "aws_secret_access_key": credentials['SecretAccessKey'], 84 | "aws_session_token" : credentials['SessionToken'] 85 | } --------------------------------------------------------------------------------