diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml new file mode 100644 index 00000000..281103f7 --- /dev/null +++ b/.github/workflows/publish-to-test-pypi.yml @@ -0,0 +1,66 @@ +name: Publish Python 🐍 distribution 📦 to PyPI + +on: + push: + paths: + - 'scripts/sbt-aws-cli/pyproject.toml' + +jobs: + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install Poetry + run: pip install poetry + + - name: Install dependencies + run: poetry install --no-dev + working-directory: scripts/sbt-aws-cli + + - name: Build the distribution + run: poetry build + working-directory: scripts/sbt-aws-cli + + - name: List files in dist/ + run: ls -la scripts/sbt-aws-cli/dist/ + + - name: Store the distribution packages + uses: actions/upload-artifact@v3 + with: + name: python-package-distributions + path: scripts/sbt-aws-cli/dist/ + + publish-to-pypi: + name: Publish Python 🐍 distribution 📦 to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/sbt-aws-cli + permissions: + id-token: write + + steps: + - name: Download all the dists + uses: actions/download-artifact@v3 + with: + name: python-package-distributions + path: scripts/sbt-aws-cli/dist/ + + - name: List files in dist/ after download + run: ls -la scripts/sbt-aws-cli/dist/ + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: scripts/sbt-aws-cli/dist/ + repository-url: https://upload.pypi.org/legacy/ + skip-existing: true diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 00000000..0ed736bd --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +python = {version='3.9', virtualenv='.venv'} diff --git a/API.md b/API.md index a210d38f..768ab2a8 100644 --- a/API.md +++ b/API.md @@ -3296,11 +3296,24 @@ const cognitoAuthProps: CognitoAuthProps = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | +| cliProps | {[ key: string ]: string} | Parameters for CLI authentication setup. | | controlPlaneCallbackURL | string | The callback URL for the control plane. | | setAPIGWScopes | boolean | Whether or not to specify scopes for validation at the API GW. | --- +##### `cliProps`Optional + +```typescript +public readonly cliProps: {[ key: string ]: string}; +``` + +- *Type:* {[ key: string ]: string} + +Parameters for CLI authentication setup. + +--- + ##### `controlPlaneCallbackURL`Optional ```typescript diff --git a/docs/public/README.md b/docs/public/README.md index 81b31dd0..d66fcb20 100644 --- a/docs/public/README.md +++ b/docs/public/README.md @@ -635,6 +635,43 @@ The application plane emits this event upon completion of offboarding. Similar t - **Built in the open with the community** - SBT strives to meet developers where they are. We encourage PRs, not forks - **Guide builders and make it approachable** - SBT will provide rich documentation and examples from which the community can derive inspiration and reference +## Command Line Interface (CLI) + +SBT includes a Command Line Interface (CLI) to simplify the management of your SaaS environment on AWS. The CLI is designed to interact with the Control Plane of SBT, allowing users to configure, manage tenants and users, and perform other administrative tasks seamlessly from the command line. + +### Key Features + +- **Tenant Management:** Create, retrieve, update, and delete tenants within your SaaS environment. The CLI provides straightforward commands to manage tenant data and lifecycle operations. +- **User Management:** Handle user accounts associated with your SaaS tenants. You can create, retrieve, update, and delete users, ensuring that your SaaS environment’s access controls are easily managed. + +### Installation + +To install the CLI, ensure that you have `pip` installed, and then install the `sbt-aws-cli` package using the following command + +```bash +pip install sbt-aws-cli +``` + +The CLI comes with a built-in `--help` mode that provides detailed information about each command and its options. This feature is particularly useful if you are unfamiliar with the available commands and arguments or need a quick reference. + +#### Using `--help` + +To access the help mode for the CLI, simply append the `--help` flag to any command. For example: + +```bash +sbt-aws-cli --help +``` +```bash +sbt-aws-cli --help +``` + +To get started, use the following command to configure the CLI with your Control Plane stack details. The arguments should be directly copied from the stack outputs. Remember you can use the `--help` flag to view the expected arguments. +```bash +sbt-aws-cli configure ``` + +Once you have configured the CLI, you have many commands available at your disposable, which you can view using `sbt-aws-cli --help` + + ## Additional documentation and resources ### Tenant management diff --git a/resources/functions/auth-device-grant-token/Modules/authorization-path.js b/resources/functions/auth-device-grant-token/Modules/authorization-path.js new file mode 100644 index 00000000..b4199b6e --- /dev/null +++ b/resources/functions/auth-device-grant-token/Modules/authorization-path.js @@ -0,0 +1,105 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + * + * 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. + * + * 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. + */ +//Reference to Commnon library +const common = require( __dirname + '/common.js'); + +const { CognitoIdentityProvider } = require("@aws-sdk/client-cognito-identity-provider"); +const { DynamoDB } = require("@aws-sdk/client-dynamodb"); + +//Reference to Crypto library for PKCE challenge +const crypto = require('crypto'); + +var cognitoidentityserviceprovider = new CognitoIdentityProvider({ + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + apiVersions: { + cognitoidentityserviceprovider: '2016-04-18', + }, +}); +var dynamodb = new DynamoDB({ + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + apiVersions: { + dynamodb: '2012-08-10', + }, +}); + +//Function that processes "Authorize" by an authenticated end user for a valid user code +// client_id: Client ID of the client application that initiated the Authorization request +// device_code: Primary key of the "Authorized" Authorization request in the DynamoDB table +// callback: Callback function to return the message +// dynamodb: Pointer to the DynamoDB SDK request handler +function processAllow(client_id, device_code, callback) { + + //Generating a code verifier and challenge for the PKCE protection of the OAuth2 flow + var code_verifier = common.randomString(32, 'aA#'); + var hash = crypto.createHash('sha256').update(code_verifier).digest(); + var code_challenge = common.base6UurlEncode(hash); + + //Generating a random state for preventing against CSRF attacks + var state = common.randomString(32, 'aA#'); + + //Updating the Authorization request with PKCE code verifier and State + var DynamoDBParams = { + ExpressionAttributeNames: { + "#AuthZ_State": "AuthZ_State", + "#AuthZ_Verif": "AuthZ_Verifier_code", + }, + ExpressionAttributeValues: { + ":authz_state": { + S: state + }, + ":authz_verif": { + S: code_verifier + } + }, + Key: { + "Device_code": { + S: device_code + } + }, + ReturnValues: "ALL_NEW", + TableName: process.env.DYNAMODB_TABLE, + UpdateExpression: "SET #AuthZ_State = :authz_state, #AuthZ_Verif = :authz_verif" + }; + dynamodb.updateItem(DynamoDBParams, function(err, data) { + if (err) { + //There was an error updating the Authorization request + console.log("Unable to set Authorization State and Verifier Code for Device Code = " + device_code); + common.returnHTMLError(400, "

Error, can't update status

", callback); + } + else { + //Update was successful so triggering a standard Authorization Code Grant flow with PKCE to Cognito using the inial Client Application's Client ID + var response = { + statusCode: 302, + headers: {"location": "https://" + process.env.CUP_DOMAIN + ".auth." + process.env.CUP_REGION + ".amazoncognito.com/oauth2/authorize?response_type=code&client_id=" + client_id + "&redirect_uri=" + encodeURIComponent("https://" + process.env.CODE_VERIFICATION_URI + "/callback") + "&state=" + state + "&scope=" + data.Attributes.Scope.S + "&code_challenge_method=S256&code_challenge=" + code_challenge + "&identity_provider=COGNITO"}, + }; + callback(null, response); + } + }); +} + +module.exports = Object.assign({ processAllow }); \ No newline at end of file diff --git a/resources/functions/auth-device-grant-token/Modules/callback-path.js b/resources/functions/auth-device-grant-token/Modules/callback-path.js new file mode 100644 index 00000000..b25f2e01 --- /dev/null +++ b/resources/functions/auth-device-grant-token/Modules/callback-path.js @@ -0,0 +1,119 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + * + * 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. + * + * 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. + */ +//Reference to Commnon library +const common = require( __dirname + '/common.js'); + +const { CognitoIdentityProvider } = require("@aws-sdk/client-cognito-identity-provider"); +const { DynamoDB } = require("@aws-sdk/client-dynamodb"); + +var cognitoidentityserviceprovider = new CognitoIdentityProvider({ + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + apiVersions: { + cognitoidentityserviceprovider: '2016-04-18', + }, +}); +var dynamodb = new DynamoDB({ + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + apiVersions: { + dynamodb: '2012-08-10', + }, +}); + +//Function that processes Cognito callback after end user Authorization Code grant flow with PKCE request +// event: Full event trapped by the Lambda function +// callback: Callback function to return the message +function processAuthZCodeCallback(event, callback) { + console.log("An Authorization Code has been sent back as Callback"); + + //Search the DynamoDb table for Authorization Request with provided State + var DynamoDBParams = { + ExpressionAttributeValues: { + ":authz_state": { + S: event.queryStringParameters.state + } + }, + KeyConditionExpression: "AuthZ_State = :authz_state", + IndexName: process.env.DYNAMODB_AUTHZ_STATE_INDEX, + TableName: process.env.DYNAMODB_TABLE + }; + dynamodb.query(DynamoDBParams, function(err, data) { + if (err) { + //There was an error retrieving the Authorization request + console.log("Authorization State can't be retrieved: " + event.queryStringParameters.state); + console.log(err, err.stack); + common.returnHTMLError(400, "

Error, can't update status

", callback); + } else { + console.log("Successful response"); + //If there is no result set + if (data.Items.length == 0) { + console.log("No AuthZ State was returned"); + common.returnHTMLError(400, "

Error, can't update status

", callback); + //If Result Set is more than 1 entry + } else if (data.Items.length > 1) { + console.log("Too much AuthZ State were returned"); + common.returnHTMLError(400, "

Error, can't update status

", callback); + } else { + console.log("AuthZ State was returned"); + // Updating the Authorization request with the Code returned through the Authorization Code grant flow with PKCE callback + DynamoDBParams = { + ExpressionAttributeNames: { + "#AuthZ_code": "AuthZ_code" + }, + ExpressionAttributeValues: { + ":value": { + S: event.queryStringParameters.code + } + }, + Key: { + "Device_code": { + S: data.Items[0].Device_code.S + } + }, + ReturnValues: "ALL_NEW", + TableName: process.env.DYNAMODB_TABLE, + UpdateExpression: "SET #AuthZ_code = :value" + }; + dynamodb.updateItem(DynamoDBParams, function(err, data) { + if (err) { + //Update was not successful + console.log("Unable to set state to Authorization Code for Device Code"); + console.log(err, err.stack); + common.returnHTMLError(400, "

Error, can't update status

", callback); + } + else { + //Update was successful + console.log("AuthZ Code updated"); + common.returnHTMLSuccess("

Thanks, Device has been Authorized. You can return to your device.

", callback); + } + }); + } + } + }); +} + +module.exports = Object.assign({ processAuthZCodeCallback }); \ No newline at end of file diff --git a/resources/functions/auth-device-grant-token/Modules/common.js b/resources/functions/auth-device-grant-token/Modules/common.js new file mode 100644 index 00000000..17d90a65 --- /dev/null +++ b/resources/functions/auth-device-grant-token/Modules/common.js @@ -0,0 +1,207 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + * + * 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. + * + * 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. + */ + +const { CognitoIdentityProvider } = require("@aws-sdk/client-cognito-identity-provider"); +const { DynamoDB } = require("@aws-sdk/client-dynamodb"); + +var cognitoidentityserviceprovider = new CognitoIdentityProvider({ + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + apiVersions: { + cognitoidentityserviceprovider: '2016-04-18', + }, +}); +var dynamodb = new DynamoDB({ + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + apiVersions: { + dynamodb: '2012-08-10', + }, +}); + +//Function a random string based of the required lenght and format +// length: length of the random string to generate +// client_id: format of the randrom string to generate +// result: string +function randomString(length, chars) { + var mask = ''; + if (chars.indexOf('a') > -1) mask += 'abcdefghijklmnopqrstuvwxyz'; + if (chars.indexOf('b') > -1) mask += 'bcdfghjklmnpqrstvwxz'; + if (chars.indexOf('A') > -1) mask += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (chars.indexOf('B') > -1) mask += 'BCDFGHJKLMNPQRSTVWXZ'; + if (chars.indexOf('#') > -1) mask += '0123456789'; + if (chars.indexOf('!') > -1) mask += '~`!@#$%^&*()_+-={}[]:";\'<>?,./|\\'; + var result = ''; + for (var i = length; i > 0; --i) result += mask[Math.floor(Math.random() * mask.length)]; + return result; +} + +//Function that generates cookie value +// result: Cookie value for the domain, valid 5 minutes, and secure +function generateCookieVal() { + //Generate cookie + var date = new Date(); + // Valid for 5 minutes + date.setTime(+ date + (3000)); // 5 \* 60 \* 100 + var cookieVal = Math.random().toString(36).substring(7); + + return "myCookie="+cookieVal+"; HttpOnly; Secure; SameSite=Strict; Domain=" + process.env.CODE_VERIFICATION_URI + "; Expires="+date.toGMTString()+";"; +} + +//Function that performs Base64 decoding for URL +// encoded: The Base64 URL encoded value +// result: The decoded value +function base64UrlDecode(encoded) { + encoded = encoded.replace('-', '+').replace('_', '/'); + while (encoded.length % 4) + encoded += '='; + return base64Decode(encoded); +} + +//Function that performs Base64 decoding +// encoded: The Base64 encoded value +// result: The decoded value +function base64Decode(encoded) { + return new Buffer.from(encoded || '', 'base64').toString('utf8'); +} + +//Function that performs Base64 encoding for URL +// unencoded: The decoded value +// result: The Base64 URL encoded value +function base6UurlEncode(unencoded) { + var encoded = base64Encode(unencoded); + return encoded.replace('+', '-').replace('/', '_').replace(/=+$/, ''); +} + +//Function that performs Base64 encoding +// unencoded: The decoded value +// result: The Base64 encoded value +function base64Encode(unencoded) { + return new Buffer.from(unencoded || '').toString('base64'); +} + +//Function that returns an error code as a JSON message +// code: Error code to return +// callback: Callback function to return the message +function returnJSONError(code, callback) { + var response = { + statusCode: code, + headers: {"content-type": "application/json", "cache-control": "no-store"} + }; + callback(null, response); +} + +//Function that returns an error code as a JSON message with a body +// code: Error code to return +// message: Body of the JSON message +// callback: Callback function to return the message +function returnJSONErrorWithMsg(code, message, callback) { + var msg = { + "error": message + }; + var response = { + statusCode: 400, + headers: {"content-type": "application/json", "cache-control": "no-store"}, + body: JSON.stringify(msg), + }; + callback(null, response); +} + +//Function that returns an error code as a HTML message with a body +// code: Error code to return +// HTMLvalue: Body of the HTML message +// callback: Callback function to return the message +function returnHTMLError(code, HTMLvalue, callback) { + var response = { + statusCode: code, + headers: {"content-type": "text/html", "cache-control": "no-store"}, + body: HTMLvalue + }; + callback(null, response); +} + +//Function that returns a specific "Device Code has expired" JSON message +// callback: Callback function to return the message +function returnExpiredDeviceCodeError(callback) { + var msg = { + "error": "expired_token" + }; + var response = { + statusCode: 400, + headers: {"content-type": "application/json", "cache-control": "no-store"}, + body: JSON.stringify(msg), + }; + callback(null, response); +} + +//Function that returns a specific "User Code has expired" HTML message +// callback: Callback function to return the message +function returnExpiredUserCodeError(callback) { + var response = { + statusCode: 400, + headers: {"content-type": "text/html", "cache-control": "no-store"}, + body: "

Sorry, code has expired

" + }; + callback(null, response); +} + +//Function that returns a specific "Slow down" JSON message when client is polling too frequently for a status +// callback: Callback function to return the message +function returnSlowDownError(callback) { + var msg = { + "error": "slow_down" + }; + var response = { + statusCode: 400, + headers: {"content-type": "application/json", "cache-control": "no-store"}, + body: JSON.stringify(msg), + }; + callback(null, response); +} + +//Function that returns a generic SUCCESS JSON message +// callback: Callback function to return the message +function returnJSONSuccess(JSONvalue, callback) { + var response = { + statusCode: 200, + headers: {"content-type": "application/json", "cache-control": "no-store"}, + body: JSON.stringify(JSONvalue), + }; + callback(null, response); +} + +//Function that returns a generic SUCCESS JSON message +// callback: Callback function to return the message +function returnHTMLSuccess(HTMLvalue, callback) { + var response = { + statusCode: 200, + headers: {"content-type": "text/html", "cache-control": "no-store"}, + body: HTMLvalue + }; + callback(null, response); +} + +module.exports = Object.assign({ cognitoidentityserviceprovider }, { dynamodb }, { randomString }, { generateCookieVal }, { base64UrlDecode }, { base64Decode }, { base6UurlEncode }, { base64Encode }, { returnJSONError }, { returnJSONErrorWithMsg }, { returnHTMLError }, { returnExpiredDeviceCodeError }, { returnExpiredUserCodeError }, { returnSlowDownError } ,{ returnJSONSuccess }, { returnHTMLSuccess }); \ No newline at end of file diff --git a/resources/functions/auth-device-grant-token/Modules/device-path.js b/resources/functions/auth-device-grant-token/Modules/device-path.js new file mode 100644 index 00000000..debe26a9 --- /dev/null +++ b/resources/functions/auth-device-grant-token/Modules/device-path.js @@ -0,0 +1,241 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + * + * 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. + * + * 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. + */ +//Reference to Commnon library +const common = require( __dirname + '/common.js'); + +const { CognitoIdentityProvider } = require("@aws-sdk/client-cognito-identity-provider"); +const { DynamoDB } = require("@aws-sdk/client-dynamodb"); + +//Reference to Authorization path library +const authzP = require( __dirname + '/authorization-path.js'); + +//Require a filesystem object to read the HTML page dedicated to end user UI +var fs = require("fs"); + +var cognitoidentityserviceprovider = new CognitoIdentityProvider({ + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + apiVersions: { + cognitoidentityserviceprovider: '2016-04-18', + }, +}); +var dynamodb = new DynamoDB({ + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + apiVersions: { + dynamodb: '2012-08-10', + }, +}); + +//Function that processes request by an authenticated end user with a user code +// event: Full event trapped by the Lambda function +// callback: Callback function to return the message +function requestUserCodeProcessing(event, callback) { + //Search for an Authorization request related to the provided user code + var DynamoDBParams = { + ExpressionAttributeValues: { + ":User_code": { + S: event.queryStringParameters.code + } + }, + KeyConditionExpression: "User_code = :User_code", + IndexName: process.env.DYNAMODB_USERCODE_INDEX, + TableName: process.env.DYNAMODB_TABLE + }; + dynamodb.query(DynamoDBParams, function(err, data) { + if (err) { + //There was an error retrieving the Authorization request + console.log("User code does not exist: " + event.queryStringParameters.code); + console.log(err, err.stack); + common.returnExpiredUserCodeError(callback); + } else { + console.log("successful response"); + //If no result is returned + if (data.Items.length == 0) { + console.log("no User code was returned"); + common.returnExpiredUserCodeError(callback); + //If too much result is returned + } else if (data.Items.length > 1) { + console.log("Too much User code returned from the request"); + common.returnExpiredUserCodeError(callback); + //If only one result is returned + } else { + var Device_code_ctx = data.Items[0].Device_code.S; + //If the Authorization request is already expired, authorized, or denied + if (data.Items[0].Status.S == "expired" || data.Items[0].Status.S == "authorized" || data.Items[0].Status.S == "denied") { + console.log("The Device code has already expired or been used"); + common.returnExpiredUserCodeError(callback); + //If the Authorization request has not the expired status but has a lifetime that is greater than the maximum one + } else if (Date.now() > parseInt(data.Items[0].Max_expiry.S)) { + console.log("User Code has expired"); + //Update the Authorization request to expire + DynamoDBParams = { + ExpressionAttributeNames: { + "#Status": "Status" + }, + ExpressionAttributeValues: { + ":status": { + S: "expired" + } + }, + Key: { + "Device_code": { + S: Device_code_ctx + } + }, + ReturnValues: "ALL_NEW", + TableName: process.env.DYNAMODB_TABLE, + UpdateExpression: "SET #Status = :status" + }; + dynamodb.updateItem(DynamoDBParams, function(err, data) { + if (err) { + //There was an error updating the Authorization request + console.log("User Code has expired but an error occurend when updating the DB"); + console.log(err, err.stack); + common.returnExpiredUserCodeError(callback); + } else { + //Update was successfull, we return an HTML message to the end-user + console.log("User Code has expired and DB has been updated"); + common.returnExpiredUserCodeError(callback); + } + }); + //If the code has not been redeemed and is still valid + } else { + console.log("User Code is valid and action is Authorize = " + event.queryStringParameters.authorize ); + //Retrieving the OIDC authenticated user attributes set by ALB + var payload = common.base64UrlDecode(event.headers["x-amzn-oidc-data"].split('.')[1]); + //If the end-user "Authorized" the Authorization request + if (event.queryStringParameters.authorize == 'true') { + //Update the Status and Subject of the Authorization request + DynamoDBParams = { + ExpressionAttributeNames: { + "#Status": "Status", + "#Subject": "Subject" + }, + ExpressionAttributeValues: { + ":status": { + S: "authorized" + }, + ":subject": { + S: JSON.parse(payload).username + } + }, + Key: { + "Device_code": { + S: Device_code_ctx + } + }, + ReturnValues: "ALL_NEW", + TableName: process.env.DYNAMODB_TABLE, + UpdateExpression: "SET #Status = :status, #Subject = :subject" + }; + dynamodb.updateItem(DynamoDBParams, function(err, data) { + if (err) { + //There was an error updating the Authorization request + console.log("Unable to set state to autorized for User Code"); + console.log(err, err.stack); + common.returnHTMLError(400, "

Error, can't update status

", callback); + } else { + //Update was successfull, follwoing up with the Authroization path + authzP.processAllow(data.Attributes.Client_id.S, data.Attributes.Device_code.S, callback, dynamodb); + } + }); + //If the end-user "Denied" the Authorization request + } else if (event.queryStringParameters.authorize == 'false') { + console.log("User Code is valid and action is Authorize = " + event.queryStringParameters.authorize ); + //Update the Status and Subject of the Authorization request + DynamoDBParams = { + ExpressionAttributeNames: { + "#Status": "Status", + "#Subject": "Subject" + }, + ExpressionAttributeValues: { + ":status": { + S: "denied" + }, + ":subject": { + S: JSON.parse(payload).username + } + }, + Key: { + "Device_code": { + S: Device_code_ctx + } + }, + ReturnValues: "ALL_NEW", + TableName: process.env.DYNAMODB_TABLE, + UpdateExpression: "SET #Status = :status, #Subject = :subject" + }; + dynamodb.updateItem(DynamoDBParams, function(err, data) { + if (err) { + //There was an error updating the Authorization request + console.log("Unable to set state to autorized for User Code"); + common.returnHTMLError(400, "

Error, can't update status

", callback); + } + else { + //Update was successfull, returning an HTML SUCCESS message + common.returnHTMLSuccess("

Thanks, Device has been unauthorized.

", callback); + } + }); + //If the operation is not supported + } else { + console.log("Unsupported Authorization option"); + common.returnHTMLError(400, "

Error, can't update status

", callback); + } + } + } + } + }); +} + +//Function that processes a request to show the Authorization UI +// event: Full event trapped by the Lambda function +// callback: Callback function to return the message +function requestUI(event, callback){ + //Retrieving the OIDC authenticated user attributes set by ALB + var payload = common.base64UrlDecode(event.headers["x-amzn-oidc-data"].split('.')[1]); + + //Reading the HTML page + fs.readFile('Resources/index.html', 'utf8', function(err, data) { + if (err) { + //There was an error reading the page, returning an HTML error + console.log("Error reading Resources/index.html"); + console.log(err, err.stack); + common.returnHTMLError(500, "", callback); + } else { + console.log("Success reading Resources/index.html " + data); + //Sucessful, returning page and setting the username correctly + var response = { + statusCode: 200, + headers: {"content-type": "text/html", "cache-control": "no-store"}, + body: data.replace("$Username", JSON.parse(payload).username) + }; + callback(null, response); + } + }); +} + +module.exports = Object.assign({ requestUserCodeProcessing }, { requestUI }); \ No newline at end of file diff --git a/resources/functions/auth-device-grant-token/Modules/token-path.js b/resources/functions/auth-device-grant-token/Modules/token-path.js new file mode 100644 index 00000000..1a7d6583 --- /dev/null +++ b/resources/functions/auth-device-grant-token/Modules/token-path.js @@ -0,0 +1,455 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + * + * 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. + * + * 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. + */ +//Reference to Commnon library +const common = require( __dirname + '/common.js'); + +const { CognitoIdentityProvider } = require("@aws-sdk/client-cognito-identity-provider"); +const { DynamoDB } = require("@aws-sdk/client-dynamodb"); + +//Reference to https library for retrieving JWT tokens from Cognito using the Athorization Code grant flow with PKCE +const https = require('https'); + +var cognitoidentityserviceprovider = new CognitoIdentityProvider({ + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + apiVersions: { + cognitoidentityserviceprovider: '2016-04-18', + }, +}); +var dynamodb = new DynamoDB({ + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + // The transformation for apiVersions is not implemented. + // Refer to UPGRADING.md on aws-sdk-js-v3 for changes needed. + // Please create/upvote feature request on aws-sdk-js-codemod for apiVersions. + apiVersions: { + dynamodb: '2012-08-10', + }, +}); + +//Function to process a POST request pm /token +// event: Full event trapped by the Lambda function +// callback: Callback function to return the message +function processPostRequest(event, callback) { + + //Preparing the request to acquire Cognito Client App configuration + var params = { + ClientId: event.queryStringParameters.client_id, + UserPoolId: process.env.CUP_ID, + }; + + //Acquiring Cognito Client App configuration + cognitoidentityserviceprovider.describeUserPoolClient(params, function(err, data) { + if (err) { + console.log("There was an error acquiring the Cognito Client App configuration " + event.queryStringParameters.client_id); + console.log(err, err.stack); + common.returnJSONError(401, callback); + } else { + console.log("Acquired Cognito Client App Configuration"); + //Configuration has been acquired + // An Authorization header has been provided, so this is a OAuth2 private client + if (event.headers.authorization && event.headers.authorization != '') { + console.log("this is a Private Client"); + //If it is a Basic Authentication value in the Authorization header + if (event.headers.authorization.startsWith("Basic ")){ + console.log("Private Client has a Basic Authorizaiton header") + var HeaderClientAppId = common.base64Decode(event.headers.authorization.replace("Basic ", "")).split(':')[0]; + var HeaderClientAppSecret = common.base64Decode(event.headers.authorization.replace("Basic ", "")).split(':')[1]; + + //Check if there is no credentials abuse + if (HeaderClientAppId == event.queryStringParameters.client_id && HeaderClientAppId != "") { + //Check if header matches the Cognito Client App Configuration + if (HeaderClientAppId == data.UserPoolClient.ClientId && HeaderClientAppSecret == data.UserPoolClient.ClientSecret && HeaderClientAppSecret != "") { + console.log("Authorization header is valid"); + if (!event.queryStringParameters.device_code && !event.queryStringParameters.grant_type) { + // If it is a POST on /token with valid client_id but no code parameter and no grant type, this is a request for codes + requestSetOfCodes(event, callback); + } else if (event.queryStringParameters.device_code && event.queryStringParameters.device_code != '' &&event.queryStringParameters.grant_type == "urn:ietf:params:oauth:grant-type:device_code") { + // If it is a POST on /token with valid client_id, a code parameter, and a grant type being "urn:ietf:params:oauth:grant-type:device_code", this is a request to get JWTs with a device code + requestJWTs(event, callback); + } else { + // If it is a POST on /token with valid client_id but missing a + // code parameter or a grant type being "urn:ietf:params:oauth:grant-type:device_code", + // this is a bad request + console.log("POST Call on /token with valid client_id but missing code or correct grant type"); + common.returnJSONError(405, callback); + } + } else { + console.log("Authorization header is unvalid"); + console.log("POST Call on /token with invalid client_id"); + common.returnJSONError(401, callback); + } + } else { + console.log("Authorization header Client Id does not match paramater Client Id"); + console.log("POST Call on /token with invalid client_id"); + common.returnJSONError(401, callback); + } + + //If something else, it is not supported + } else { + console.log("Authorization header is using an unsupported authentication scheme"); + console.log("POST Call on /token with invalid client_id"); + common.returnJSONError(401, callback); + } + // Otherwise this is a OAuth2 public client + } else { + //Check if request matches the Cognito Client App Configuration + if (HeaderClientAppId == data.UserPoolClient.ClientId && data.UserPoolClient.ClientSecret == "") { + console.log("Cognito Client App configuration is valid"); + if (!event.queryStringParameters.device_code && !event.queryStringParameters.grant_type) { + // If it is a POST on /token with valid client_id but no code parameter and no grant type, this is a request for codes + requestSetOfCodes(event, callback); + } else if (event.queryStringParameters.device_code && event.queryStringParameters.device_code != '' && event.queryStringParameters.grant_type == "urn:ietf:params:oauth:grant-type:device_code") { + // If it is a POST on /token with valid client_id, a code parameter, and a grant type being "urn:ietf:params:oauth:grant-type:device_code", this is a request to get JWTs with a device code + requestJWTs(event, callback); + } else { + // If it is a POST on /token with valid client_id but missing a + // code parameter or a grant type being "urn:ietf:params:oauth:grant-type:device_code", + // this is a bad request + console.log("POST Call on /token with valid client_id but missing code or correct grant type"); + common.returnJSONError(405, callback); + } + } else { + console.log("Cognito Client App configuration is a private client while request try to pass as a public client"); + console.log("POST Call on /token with invalid client_id"); + common.returnJSONError(401, callback); + } + } + } + }); +} + +//Function that processes request by a client applicaiton to get codes generated +// event: Full event trapped by the Lambda function +// callback: Callback function to return the message +function requestSetOfCodes(event, callback) { + // Generating the user code (a unique code to return to the end user) and device code (a unique code for future device calls) + var user_code = common.randomString(process.env.USER_CODE_LENGTH, process.env.USER_CODE_FORMAT); + var device_code = common.randomString(process.env.DEVICE_CODE_LENGTH, process.env.DEVICE_CODE_FORMAT); + var scope = 'openid'; + + // Creating a JSON structure to return codes to the device + var codes = { + device_code: device_code, + user_code: user_code, + verification_uri: "https://" + process.env.CODE_VERIFICATION_URI + "/device", + verification_uri_complete: "https://" + process.env.CODE_VERIFICATION_URI + "/device?code=" + user_code + "&authorize=true", + interval: parseInt(process.env.POLLING_INTERVAL), + expires_in: parseInt(process.env.CODE_EXPIRATION) + }; + + if (event.queryStringParameters.scope && event.queryStringParameters.scope != '' ) { + scope = event.queryStringParameters.scope; + } else { + scope = 'openid'; + } + + // Prepare the stroage of the codes in the DynamoDB table + var DynamoDBParams = { + Item: { + "Device_code": { + S: device_code + }, + "User_code": { + S: user_code + }, + "Status": { + S: "authorization_pending" + }, + "Client_id": { + S: event.queryStringParameters.client_id + }, + "Max_expiry": { + S: (Date.now() + process.env.CODE_EXPIRATION * 1000).toString() + }, + "Last_checked": { + S: (Date.now()).toString() + }, + "Scope":  { + S: scope + } + }, + ReturnConsumedCapacity: "TOTAL", + TableName: process.env.DYNAMODB_TABLE + }; + // Insert the item in DynamoDB + dynamodb.putItem(DynamoDBParams, function(err, data) { + if (err) { + //There was an error + console.log(err, err.stack); + console.log("Error inserting Codes item in the DynamoDB table"); + common.returnJSONError(500, callback); + } else { + //Successful, the Authorization request has been written to the DynamoDB table + console.log("Inserting Codes item in the DynamoDB table: " + data); + common.returnJSONSuccess(codes, callback); + } + }); +} + +//Function that processes request to retrieve JWT tokens from Cognito using the Athorization Code grant flow with PKCE if status is "Authorized" +// event: Full event trapped by the Lambda function +// callback: Callback function to return the message +function requestJWTs(event, callback) { + //Retrieving the Authorization Request based on the device code provided by the client application + var DynamoDBParams = { + Key: { + "Device_code": { + S: event.queryStringParameters.device_code + } + }, + TableName: process.env.DYNAMODB_TABLE + }; + dynamodb.getItem(DynamoDBParams, function(err, data) { + if (err) { + //There was an error + console.log("Error occured while retrieving device code"); + console.log(err, err.stack); + common.returnJSONError(500, callback); + } else { + //Sucessful + console.log("Sucessful request"); + //If Result Set has no value + if (data.Item.length == 0) { + console.log("No item matching device code exists"); + common.returnExpiredDeviceCodeError(callback); + //If Result Set has more than one value + } else if (data.Item.length > 1) { + console.log("More than one device code has been returned"); + common.returnExpiredDeviceCodeError(callback); + //If Result Set has only one value but it is with an "Expired" status + } else if (data.Item.Status.S == "expired") { + console.log("The Device code has already expired"); + common.returnExpiredDeviceCodeError(callback); + //If Result Set has only one value, is not explicitely expired, but has not been requested initally by the same client application + } else if (data.Item.Client_id.S != event.queryStringParameters.client_id) { + console.log("The Client id does not match the initial requestor client id"); + common.returnExpiredDeviceCodeError(callback); + //If Result Set has only one value, is not explicitely expired, has been requested initally by the same client application, but has a lifetime older than the maximum lifetime + } else if (Date.now() > parseInt(data.Item.Max_expiry.S)) { + //Update status of the Authorization request to "Expired" + console.log("The Device code has expired"); + DynamoDBParams = { + ExpressionAttributeNames: { + "#Status": "Status" + }, + ExpressionAttributeValues: { + ":status": { + S: "expired" + } + }, + Key: { + "Device_code": { + S: event.queryStringParameters.device_code + } + }, + ReturnValues: "ALL_NEW", + TableName: process.env.DYNAMODB_TABLE, + UpdateExpression: "SET #Status = :status" + }; + dynamodb.updateItem(DynamoDBParams, function(err, data) { + if (err) { + //There was an error, so return an JSON error message the Code has expired + console.log("The Device code has expired, but an error occured when updating the DB"); + console.log(err, err.stack); + common.returnExpiredDeviceCodeError(callback); + } else { + //Return an JSON error message the Code has expired + common.returnExpiredDeviceCodeError(callback); + } + }); + //If Result Set has only one value, is not expired, has been requested initally by the same client application, but application client request a status too quickly + } else if (Date.now() <= (parseInt(data.Item.Last_checked.S) + parseInt(process.env.POLLING_INTERVAL) * 1000) ) { + //Update last checked timestamp of the Authorization request to Now + DynamoDBParams = { + ExpressionAttributeNames: { + "#LC": "Last_checked" + }, + ExpressionAttributeValues: { + ":lc": { + S: (Date.now()).toString() + } + }, + Key: { + "Device_code": { + S: event.queryStringParameters.device_code + } + }, + ReturnValues: "ALL_NEW", + TableName: process.env.DYNAMODB_TABLE, + UpdateExpression: "SET #LC = :lc" + }; + dynamodb.updateItem(DynamoDBParams, function(err, data) { + if (err) { + //There was an error, so return an JSON error message the client application has to slow down + console.log("Client makes too much API calls, but an error occured while updated the last check timestamp in the DB"); + console.log(err, err.stack); + common.returnSlowDownError(callback); + } else { + //Return an JSON error message the client application has to slow down + console.log("Client makes too much API calls"); + common.returnSlowDownError(callback); + } + }); + //If all is good + } else { + //Must check the status + //But first update last checked timestamp of the Authorization request to Now + DynamoDBParams = { + ExpressionAttributeNames: { + "#LC": "Last_checked" + }, + ExpressionAttributeValues: { + ":lc": { + S: (Date.now()).toString() + } + }, + Key: { + "Device_code": { + S: event.queryStringParameters.device_code + } + }, + ReturnValues: "ALL_NEW", + TableName: process.env.DYNAMODB_TABLE, + UpdateExpression: "SET #LC = :lc" + }; + dynamodb.updateItem(DynamoDBParams, function(err, data) { + if (err) { + //There was an error, so return an JSON error message the client application has to slow down + console.log("Client is on time for checking, but an error occured while updated the last check timestamp in the DB"); + console.log(err, err.stack); + common.returnSlowDownError(callback); + } + else { + //Sucessfull + console.log("Client is on time for checking, we got a status"); + //If the Status is authorization_pending or denied, return the status to the Client application + if (data.Attributes.Status.S == 'authorization_pending' || data.Attributes.Status.S == 'denied') { + console.log("Client is on time for checking, we got a status: " + data.Attributes.Status.S); + common.returnJSONErrorWithMsg(400, data.Attributes.Status.S, callback); + //If the Status is authorized + } else if (data.Attributes.Status.S == 'authorized') { + console.log("Client is on time for checking, we got a status: " + data.Attributes.Status.S); + console.log("Token Set is empty"); + //Prepare the retrieving of JWT tokens from Cognito using the Athorization Code grant flow with PKCE + var options = { + hostname: process.env.CUP_DOMAIN + ".auth." + process.env.CUP_REGION + ".amazoncognito.com", + port: 443, + path: '/oauth2/token', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + } + }; + //If client application is private, has a Client Secret, and had provided it in the initial request, add it as an Authorization header to this request + if (event.headers.authorization != undefined) { + console.log("Setting Authorization header"); + // Client knows authentication is required for Private Client, has been issued a Client secret, and therefore present an authentication header + // Otherwise Client knows authentication is not necessary for Public Client or has made an error + options.headers.authorization = event.headers.authorization; + } + + console.log("Launching Request for Tokens"); + //Request JWT tokens + const req = https.request(options, res => { + console.log('statusCode:', res.statusCode); + + //Reading request's response data + res.on('data', (d) => { + //Prepare JWT Tokens blob + if (d.error) { + console.log("Cognito User Pool returned an error"); + common.returnExpiredDeviceCodeError(callback); + } else { + var result = JSON.parse(d.toString()); + var response = {} + + var rts = process.env.RESULT_TOKEN_SET.split('+'); + for (token_type in rts) { + if (rts[token_type] == 'ID') response.id_token = result.id_token; + if (rts[token_type] == 'ACCESS') response.access_token = result.access_token; + if (rts[token_type] == 'REFRESH') response.refresh_token = result.refresh_token; + } + + response.expires_in = result.expires_in; + + //Update the status of the Authorization request to "Denied" to prevent replay + DynamoDBParams = { + ExpressionAttributeNames: { + "#Status": "Status" + }, + ExpressionAttributeValues: { + ":status": { + S: "expired" + } + }, + Key: { + "Device_code": { + S: event.queryStringParameters.device_code + } + }, + ReturnValues: "ALL_NEW", + TableName: process.env.DYNAMODB_TABLE, + UpdateExpression: "SET #Status = :status" + }; + dynamodb.updateItem(DynamoDBParams, function(err, data) { + if (err) { + //There was an error, return expired message as Authroization code has been used + console.log("We got the tokens but we got an error updating the DB"); + console.log(err, err.stack); + common.returnExpiredDeviceCodeError(callback); + } else { + //Return the JWT tokens + common.returnJSONSuccess(response, callback); + } + }); + } + }); + }); + + //There was an error retrieving JWT Tokens + req.on('error', (e) => { + console.log("Got an error"); + console.log(e); + common.returnExpiredDeviceCodeError(callback); + }); + + //Writing Body of the request + req.write('grant_type=authorization_code&client_id=' + data.Attributes.Client_id.S + '&scope=' + data.Attributes.Scope.S + '&redirect_uri=' + encodeURIComponent("https://" + process.env.CODE_VERIFICATION_URI + '/callback') + '&code=' + data.Attributes.AuthZ_code.S + '&code_verifier=' + data.Attributes.AuthZ_Verifier_code.S); + + //When request is finalized + req.end((e) => { + console.log("Finished"); + }); + //If Status is not suppoted + } else { + common.returnExpiredDeviceCodeError(callback); + } + } + }); + } + } + }); +} + +module.exports = Object.assign({ processPostRequest }, { requestSetOfCodes }, { requestJWTs }); \ No newline at end of file diff --git a/resources/functions/auth-device-grant-token/Resources/index.html b/resources/functions/auth-device-grant-token/Resources/index.html new file mode 100644 index 00000000..37520ce9 --- /dev/null +++ b/resources/functions/auth-device-grant-token/Resources/index.html @@ -0,0 +1,68 @@ + + +
+ + +
+ +
+ +
+

Hello $Username,

Please provide the code of the Device that wants to be linked to your account and choose an action:

+
+
+

+

+ + + +
+

+
+
+ \ No newline at end of file diff --git a/resources/functions/auth-device-grant-token/index.js b/resources/functions/auth-device-grant-token/index.js new file mode 100644 index 00000000..a211219c --- /dev/null +++ b/resources/functions/auth-device-grant-token/index.js @@ -0,0 +1,106 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + * + * 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. + * + * 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. + */ + +console.log("Loading function"); +const common = require( __dirname + '/Modules/common.js'); +const tokenP = require( __dirname + '/Modules/token-path.js'); +const deviceP = require( __dirname + '/Modules/device-path.js'); +const callbackP = require( __dirname + '/Modules/callback-path.js'); +const authzP = require( __dirname + '/Modules/authorization-path.js'); + +exports.handler = (event, context, callback) => { + console.log("Received event:", JSON.stringify(event, null, 2)); + + //Initialize default settings if needed + if (!process.env.CODE_EXPIRATION || process.env.CODE_EXPIRATION == '') process.env.CODE_EXPIRATION = "1800"; + if (!process.env.DYNAMODB_TABLE || process.env.DYNAMODB_TABLE == '') process.env.DYNAMODB_TABLE = "DeviceGrant"; + if (!process.env.DYNAMODB_AUTHZ_STATE_INDEX || process.env.DYNAMODB_AUTHZ_STATE_INDEX == '') process.env.DYNAMODB_AUTHZ_STATE_INDEX = "AuthZ_state-index"; + if (!process.env.DYNAMODB_USERCODE_INDEX || process.env.DYNAMODB_USERCODE_INDEX == '') process.env.DYNAMODB_USERCODE_INDEX = "User_code-index"; + if (!process.env.POLLING_INTERVAL || process.env.POLLING_INTERVAL == '') process.env.POLLING_INTERVAL = "5"; + if (!process.env.DEVICE_CODE_FORMAT || process.env.DEVICE_CODE_FORMAT == '') process.env.DEVICE_CODE_FORMAT = "#aA"; + if (!process.env.DEVICE_CODE_LENGTH || process.env.DEVICE_CODE_LENGTH == '') process.env.DEVICE_CODE_LENGTH = "64"; + if (!process.env.USER_CODE_FORMAT || process.env.USER_CODE_FORMAT == '') process.env.USER_CODE_FORMAT = "#B"; + if (!process.env.USER_CODE_LENGTH || process.env.USER_CODE_LENGTH == '') process.env.USER_CODE_LENGTH = "8"; + if (!process.env.RESULT_TOKEN_SET || process.env.RESULT_TOKEN_SET == '') process.env.RESULT_TOKEN_SET = "ACCESS+REFRESH"; + + switch(event.path) { + //Call the Token endpoint either for getting codes or using a device code to get a JWTs + case '/token': + // If it is a POST on /token with client_id provided + if(event.httpMethod == 'POST' && event.queryStringParameters.client_id && event.queryStringParameters.client_id != '') { + tokenP.processPostRequest(event, callback); + } else { // If it is something else than a POST on /token with client_id provided + console.log("Unsupported Call on /token"); + common.returnJSONError(405, callback); + } + break; + + case "/device": + if(event.httpMethod == 'GET') { + // This is a POST Call on /device whit represent the end user wanting + // to delegate access to a device by providing the User code + if (event.headers['x-amzn-oidc-accesstoken'] && event.headers['x-amzn-oidc-accesstoken'] != '' && event.headers["x-amzn-oidc-data"] && event.headers["x-amzn-oidc-data"] != '') { + // If the request contains Authorize and Code as Query Parameters + if (event.queryStringParameters.authorize && event.queryStringParameters.authorize != '' && event.queryStringParameters.code && event.queryStringParameters.code != '' ) { + // If Code Query Parameter is NULL + if ( event.queryStringParameters.code == '' ) { + console.log("End user submitted an empty user code"); + common.returnExpiredUserCodeError(callback); + } else { + // If Code Query Parameter is not NULL + deviceP.requestUserCodeProcessing(event, callback); + } + } else { + // If the request does not contain Authorize and Code as Query Parameters + // The end user has been authenticated at the ALB and the Access token flows to the /device endpoint + deviceP.requestUI(event, callback); + } + } else { + // Request went through ALB but miss the necessary Access Token + console.log("Call passed to /device without x-amzn-oidc-accesstoken "); + common.returnJSONError(405, callback); + } + } else { + // If it is something else than a GET on /device + console.log("Unsupported Call on /device"); + common.returnJSONError(405, callback); + } + break; + + case "/callback": + if(event.httpMethod == 'GET') { + if (event.queryStringParameters.code && event.queryStringParameters.code != '' && event.queryStringParameters.state && event.queryStringParameters.state != '' ) { + callbackP.processAuthZCodeCallback(event, callback); + } else { + // ÉMissing necessary Query Parameter + console.log("Unsupported Call on /callback"); + common.returnJSONError(405, callback); + } + } else { + // If it is something else than a GET on /callback + console.log("Unsupported Call on /callback"); + common.returnJSONError(405, callback); + } + break; + + default: + // If it is an unsupported call to this API + console.log("Unsupported Call"); + common.returnJSONError(405, callback); + } +}; \ No newline at end of file diff --git a/resources/functions/auth-token-cleaning/index.js b/resources/functions/auth-token-cleaning/index.js new file mode 100644 index 00000000..373f4655 --- /dev/null +++ b/resources/functions/auth-token-cleaning/index.js @@ -0,0 +1,178 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + * + * 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. + * + * 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. + */ +'use strict'; + +var AWS = require("aws-sdk"); + +exports.handler = (event, context, callback) => { + console.log('LogScheduledEvent'); + //Receive CloudWatch scheduled event + console.log('Received event:', JSON.stringify(event, null, 2)); + + //Bootstrap DynamoDB Client + var dynamodb = new AWS.DynamoDB(); + + //Search for all entries that are not yet expired cause entries are automatically expired if necessary when polled + var params = { + ExpressionAttributeNames: { + "#Status": "Status" + }, + ExpressionAttributeValues: { + ":expired": { + S: "expired" + } + }, + FilterExpression: "#Status <> :expired", + TableName: process.env.DYNAMODB_TABLE + }; + dynamodb.scan(params, function (err, data) { + if (err) { + //There was an error when collecting the data + console.log("Error while scanning DB for 'non-expired"); + console.log(err, err.stack); // an error occurred + } else { + console.log("Processing result set"); + //If the Result Set is not empty + if (data.Items.length > 0) { + var item; + //For any Result in the Result set + for (item in data.Items) { + //If the Result Lifetime is older than the DYNAMODB_MAX_VISIBILITY + if (Date.now() > (new Date(parseInt(data.Items[item].Max_expiry.S)) + process.env.DYNAMODB_MAX_VISIBILITY)) { + //Delete the codes from the DB + params = { + Key: { + "Device_code": { + S: data.Items[item].Device_code.S + } + }, + TableName: process.env.DYNAMODB_TABLE, + }; + dynamodb.deleteItem(params, function (err, data) { + if (err) { + //An error occured during the deletion + console.log("Error occured when deleting item from the DB"); + console.log(err, err.stack); // an error occurred + } else { + //Result is deleted + console.log("Item deleted from DB"); + } + }); + //If the Result Lifetime is older maximum lifetime + } else if (Date.now() > (new Date(parseInt(data.Items[item].Max_expiry.S)))) { + //Expire the codes in the DB + params = { + ExpressionAttributeNames: { + "#Status": "Status" + }, + ExpressionAttributeValues: { + ":status": { + S: "expired" + } + }, + Key: { + "Device_code": { + S: data.Items[item].Device_code.S + } + }, + ReturnValues: "ALL_NEW", + TableName: process.env.DYNAMODB_TABLE, + UpdateExpression: "SET #Status = :status" + }; + dynamodb.updateItem(params, function (err, data) { + if (err) { + //An error happened while updating teh status of the codes + console.log("Error occured when updating the DB to expired"); + console.log(err, err.stack); + } else { + //Codes have been expired + console.log("DB has been updated"); + } + }); + } else { + //Codes are still valid + console.log("Codes are still valid"); + } + } + } else { + //There were no result set + console.log("No non-expired result set"); + } + } + }); + //Search for all entries that are not expired + params = { + ExpressionAttributeNames: { + "#Status": "Status" + }, + ExpressionAttributeValues: { + ":expired": { + S: "expired" + } + }, + FilterExpression: "#Status = :expired", + TableName: "DeviceGrant" + }; + dynamodb.scan(params, function (err, data) { + if (err) { + //There was an error when collecting the data + console.log("Error while scanning DB for 'expired'"); + console.log(err, err.stack); // an error occurred + } else { + console.log("Processing result set"); + //If the Result Set is not empty + if (data.Items.length > 0) { + var item; + for (item in data.Items) { + //If the Result Lifetime is older than the DYNAMODB_MAX_VISIBILITY + var limit = new Date(parseInt(data.Items[item].Max_expiry.S)); + limit = limit + process.env.DYNAMODB_MAX_VISIBILITY; + if (Date.now() > limit) { + //Delete the codes from the DB + params = { + Key: { + "Device_code": { + S: data.Items[item].Device_code.S + } + }, + TableName: process.env.DYNAMODB_TABLE, + }; + dynamodb.deleteItem(params, function (err, data) { + if (err) { + //An error occured during the deletion + console.log("Error occured when deleting item from the DB"); + console.log(err, err.stack); // an error occurred + } else { + //Result is deleted + console.log("Item deleted from DB"); + } + }); + } else { + //Codes are still valid + console.log("Codes are still valid"); + } + } + } else { + //There were no result set + console.log("No expired result set"); + } + } + }); + + callback(null, 'Finished'); +}; \ No newline at end of file diff --git a/scripts/sbt-aws-cli/README.md b/scripts/sbt-aws-cli/README.md new file mode 100644 index 00000000..b7c11071 --- /dev/null +++ b/scripts/sbt-aws-cli/README.md @@ -0,0 +1,3 @@ +# SBT-AWS-CLI + +CLI tool for managing SaaS Builder Toolkit (SBT) for AWS \ No newline at end of file diff --git a/scripts/sbt-aws-cli/poetry.lock b/scripts/sbt-aws-cli/poetry.lock new file mode 100644 index 00000000..d8b8961c --- /dev/null +++ b/scripts/sbt-aws-cli/poetry.lock @@ -0,0 +1,315 @@ +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2024.12.14" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, + {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "13.9.4" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "typer" +version = "0.12.5" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +files = [ + {file = "typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b"}, + {file = "typer-0.12.5.tar.gz", hash = "sha256:f592f089bedcc8ec1b974125d64851029c3b1af145f04aca64d69410f0c9b722"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +content-hash = "6d8824eb1093eb61d1861c5551c36f094130394302ea7b2aaf8167b012f8349e" diff --git a/scripts/sbt-aws-cli/pyproject.toml b/scripts/sbt-aws-cli/pyproject.toml new file mode 100644 index 00000000..105657ef --- /dev/null +++ b/scripts/sbt-aws-cli/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "sbt-aws-cli" +version = "0.0.1" +description = "" +authors = ["sbt-aws "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.9" +typer = "^0.12.5" +requests = "^2.32.3" + +[tool.poetry.scripts] +sbt-aws-cli = "sbt_aws_cli.main:app" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/scripts/sbt-aws-cli/sbt_aws_cli/__init__.py b/scripts/sbt-aws-cli/sbt_aws_cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/sbt-aws-cli/sbt_aws_cli/main.py b/scripts/sbt-aws-cli/sbt_aws_cli/main.py new file mode 100644 index 00000000..f593941f --- /dev/null +++ b/scripts/sbt-aws-cli/sbt_aws_cli/main.py @@ -0,0 +1,417 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import json +import base64 +import time +import requests +import typer +import webbrowser + +app = typer.Typer(help="CLI tool for managing SaaS Builder Toolkit (SBT) for AWS") + +# Constants +CONFIG_FILE = os.path.expanduser("~/.sbt-aws-config") + +# Helper functions +def get_token(client_id, client_secret, fqdn): + auth_header = 'Basic ' + base64.b64encode(f'{client_id}:{client_secret}'.encode()).decode() + try: + response = requests.post( + f'https://{fqdn}/token?client_id={client_id}', + headers={'Authorization': auth_header} + ) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as error: + print('Error getting token:', error.response.json() if error.response else str(error)) + +def check_status(client_id, client_secret, fqdn, device_code): + auth_header = 'Basic ' + base64.b64encode(f'{client_id}:{client_secret}'.encode()).decode() + try: + response = requests.post( + f'https://{fqdn}/token?client_id={client_id}&device_code={device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code', + headers={'Authorization': auth_header} + ) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as error: + if error.response.status_code == 400: + return error.response.json() + else: + return {'error': f'HTTP Error {error.response.status_code}: {error.response.text}'} + except requests.exceptions.RequestException as error: + return {'error': str(error)} + +def generate_credentials(client_id, client_secret, fqdn, debug: bool): + if debug: + print("Generating credentials...") + + if debug: + print(f"CLIENT_ID: {client_id}") + print(f"FQDN: {fqdn}") + + token_data = get_token(client_id, client_secret, fqdn) + if token_data: + device_code = token_data.get('device_code') + verification_uri_complete = token_data.get('verification_uri_complete') + interval = token_data.get('interval', 5) + + if verification_uri_complete: + print('You should have been directed to the browser for verification. If not, please go to:', verification_uri_complete) + webbrowser.open(verification_uri_complete) + else: + print('No verification URL provided by the authorization server.') + return + + max_attempts = 60 # 5 minutes + attempts = 0 + + while attempts < max_attempts: + time.sleep(interval) + status = check_status(client_id, client_secret, fqdn, device_code) + + if 'error' in status: + if status['error'] == 'authorization_pending': + print('Authorization pending, checking again in a few seconds...') + elif status['error'] == 'slow_down': + print('Polling too frequently, slowing down...') + interval += 5 + elif status['error'] == 'expired_token': + print('Device code expired. Please try again.') + return + elif status['error'].startswith('HTTP Error'): + print('Retrying...') + else: + print(f'Unexpected error: {status["error"]}') + return + elif 'access_token' in status: + print('Authorization successful') + os.environ['ACCESS_TOKEN'] = status['access_token'] + os.environ['REFRESH_TOKEN'] = status['refresh_token'] + return + + attempts += 1 + + print('Authorization timed out. Please try again.') + +@app.command() +def configure( + control_plane_stack: str = typer.Argument(..., help="Name of the Control Plane CloudFormation stack"), + client_id: str = typer.Argument(..., help="Device Cognito Client ID for authentication"), + fqdn: str = typer.Argument(..., help="Fully Qualified Domain Name for the control plane"), + control_plane_api_endpoint: str = typer.Argument(..., help="API endpoint for the control plane"), + cognito_domain: str = typer.Argument(..., help="Cognito domain for authentication"), + debug: bool = typer.Option(False, help="Enable debug mode") +): + """ + Configure the SBT CLI with Control Plane stack information and generate credentials. + """ + client_secret = typer.prompt("Device Cognito Client Secret for authentication", hide_input=True) + + if debug: + print("Configuring with:") + print(f"CONTROL_PLANE_STACK_NAME: {control_plane_stack}") + print(f"CLIENT_ID: {client_id}") + print(f"CLIENT_SECRET: {client_secret}") + print(f"FQDN: {fqdn}") + print(f"CONTROL_PLANE_API_ENDPOINT: {control_plane_api_endpoint}") + print(f"cognito_domain: {cognito_domain}") + + generate_credentials(client_id, client_secret, fqdn, debug) + + config_data = { + "CONTROL_PLANE_STACK_NAME": control_plane_stack, + "CONTROL_PLANE_API_ENDPOINT": control_plane_api_endpoint, + "COGNITO_DOMAIN": cognito_domain, + "CLIENT_ID": client_id, + "CLIENT_SECRET": client_secret, + "FQDN": fqdn, + "ACCESS_TOKEN": os.getenv('ACCESS_TOKEN'), + "REFRESH_TOKEN": os.getenv('REFRESH_TOKEN') + } + + with open(CONFIG_FILE, 'w') as config_file: + json.dump(config_data, config_file) + + if debug: + print(f"Configuration saved to {CONFIG_FILE}") + +def read_config(): + with open(CONFIG_FILE, 'r') as config_file: + return json.load(config_file) + +@app.command() +def refresh_tokens(debug: bool = typer.Option(False, help="Enable debug mode")): + """ + Refresh the access and refresh tokens for the current session. + """ + config = read_config() + if debug: + print("Refreshing tokens...") + + client_id = config["CLIENT_ID"] + client_secret = config["CLIENT_SECRET"] + cognito_domain = config["COGNITO_DOMAIN"] + + auth_header = 'Basic ' + base64.b64encode(f'{client_id}:{client_secret}'.encode()).decode() + try: + response = requests.post( + f'https://{cognito_domain}/token', + data={ + 'grant_type': 'refresh_token', + 'client_id': client_id, + 'refresh_token': config['REFRESH_TOKEN'] + }, + headers={'Authorization': auth_header, 'Content-Type': 'application/x-www-form-urlencoded'} + ) + response.raise_for_status() + refreshed_token_data = response.json() + + if refreshed_token_data: + config["ACCESS_TOKEN"] = refreshed_token_data['access_token'] + with open(CONFIG_FILE, 'w') as config_file: + json.dump(config, config_file) + + if debug: + print(f"Tokens refreshed and saved to {CONFIG_FILE}") + + except requests.exceptions.RequestException as error: + print('Error refreshing token:', error.response.json() if error.response else str(error)) + +@app.command() +def create_tenant( + tenant_name: str = typer.Argument(..., help="Tenant name"), + tenant_email: str = typer.Argument(..., help="Tenant email"), + tenant_tier: str = typer.Argument(..., help="Tenant tier (e.g., basic, advanced, premium"), + debug: bool = typer.Option(False, help="Enable debug mode") +): + """ + Create a new tenant with user-provided name, email, and tier. + """ + config = read_config() + + if debug: + print("Creating tenant with:") + print(f"TENANT_NAME: {tenant_name}") + print(f"TENANT_EMAIL: {tenant_email}") + print(f"TENANT_TIER: {tenant_tier}") + + data = { + "tenantName": tenant_name, + "email": tenant_email, + "tier": tenant_tier, + } + + response = requests.post( + f"{config['CONTROL_PLANE_API_ENDPOINT']}tenants", + headers={"Authorization": f"Bearer {config['ACCESS_TOKEN']}", "Content-Type": "application/json"}, + data=json.dumps(data) + ) + + print(json.dumps(response.json())) + +@app.command() +def get_tenant( + tenant_id: str = typer.Argument(..., help="ID of the tenant to retrieve"), + debug: bool = typer.Option(False, help="Enable debug mode") +): + """ + Retrieve information for a specific tenant by ID. + """ + config = read_config() + if debug: + print(f"Getting tenant with ID: {tenant_id}") + + response = requests.get( + f"{config['CONTROL_PLANE_API_ENDPOINT']}tenants/{tenant_id}", + headers={"Authorization": f"Bearer {config['ACCESS_TOKEN']}"} + ) + + print(json.dumps(response.json())) + +@app.command() +def get_all_tenants( + limit: int = typer.Argument(10, help="Maximum number of tenants to retrieve"), + next_token: str = typer.Argument("", help="Token for pagination"), + debug: bool = typer.Option(False, help="Enable debug mode") +): + """ + Retrieve a list of all tenants + """ + config = read_config() + if debug: + print("Getting all tenants") + + limit = int(limit) + + response = requests.get( + f"{config['CONTROL_PLANE_API_ENDPOINT']}tenants", + headers={"Authorization": f"Bearer {config['ACCESS_TOKEN']}"}, + params={"limit": limit, "next_token": next_token} + ) + + print(json.dumps(response.json())) + +@app.command() +def delete_tenant( + tenant_id: str = typer.Argument(..., help="ID of the tenant to delete"), + debug: bool = typer.Option(False, help="Enable debug mode") +): + """ + Delete a specific tenant by ID. + """ + config = read_config() + if debug: + print(f"Deleting tenant with ID: {tenant_id}") + + response = requests.delete( + f"{config['CONTROL_PLANE_API_ENDPOINT']}tenants/{tenant_id}", + headers={"Authorization": f"Bearer {config['ACCESS_TOKEN']}"} + ) + + print(json.dumps(response.json())) + +@app.command() +def create_user( + user_name: str = typer.Argument(..., help="Name of the user"), + user_email: str = typer.Argument(..., help="Email of the user"), + user_role: str = typer.Argument(..., help="Role of the user (e.g basicUser)"), + debug: bool = typer.Option(False, help="Enable debug mode") +): + """ + Create a new user. + """ + config = read_config() + + if debug: + print("Creating user with:") + print(f"USER_NAME: {user_name}") + print(f"USER_EMAIL: {user_email}") + print(f"USER_ROLE: {user_role}") + + data = { + "userName": user_name, + "email": user_email, + "userRole": user_role + } + + response = requests.post( + f"{config['CONTROL_PLANE_API_ENDPOINT']}users", + headers={"Authorization": f"Bearer {config['ACCESS_TOKEN']}", "Content-Type": "application/json"}, + data=json.dumps(data) + ) + + print(json.dumps(response.json())) + +@app.command() +def get_all_users( + limit: int = typer.Argument(10, help="Maximum number of users to retrieve"), + next_token: str = typer.Argument("", help="Token for pagination"), + debug: bool = typer.Option(False, help="Enable debug mode for") +): + """ + Retrieve a list of all users + """ + config = read_config() + if debug: + print("Getting all users") + + params = {"limit": limit} + if next_token: + params["next_token"] = next_token + + response = requests.get( + f"{config['CONTROL_PLANE_API_ENDPOINT']}users", + headers={"Authorization": f"Bearer {config['ACCESS_TOKEN']}"}, + params=params + ) + + print(json.dumps(response.json())) + +@app.command() +def get_user( + user_id: str = typer.Argument(..., help="ID of the user to retrieve"), + debug: bool = typer.Option(False, help="Enable debug mode") +): + """ + Retrieve information for a specific user by ID. + """ + config = read_config() + if debug: + print(f"Getting user with ID: {user_id}") + + response = requests.get( + f"{config['CONTROL_PLANE_API_ENDPOINT']}users/{user_id}", + headers={"Authorization": f"Bearer {config['ACCESS_TOKEN']}"} + ) + + print(json.dumps(response.json())) + +@app.command() +def update_user( + user_id: str = typer.Argument(..., help="ID of the user to update"), + user_role: str = typer.Argument(None, help="New role for the user"), + user_email: str = typer.Argument(None, help="New email for the user"), + debug: bool = typer.Option(False, help="Enable debug mode") +): + """ + Update a user's role and/or email. + """ + config = read_config() + data = {k: v for k, v in {"userRole": user_role, "email": user_email}.items() if v is not None} + + if debug: + print(f"Updating user with ID: {user_id} with DATA: {data}") + + response = requests.put( + f"{config['CONTROL_PLANE_API_ENDPOINT']}users/{user_id}", + headers={"Authorization": f"Bearer {config['ACCESS_TOKEN']}", "Content-Type": "application/json"}, + data=json.dumps(data) + ) + + print(json.dumps(response.json())) + +@app.command() +def delete_user( + user_id: str = typer.Argument(..., help="ID of the user to delete"), + debug: bool = typer.Option(False, help="Enable debug mode") +): + """ + Delete a specific user by ID. + """ + config = read_config() + if debug: + print(f"Deleting user with ID: {user_id}") + + response = requests.delete( + f"{config['CONTROL_PLANE_API_ENDPOINT']}users/{user_id}", + headers={"Authorization": f"Bearer {config['ACCESS_TOKEN']}"} + ) + + print(json.dumps(response.json())) + +@app.command() +def update_tenant( + tenant_id: str = typer.Argument(..., help="ID of the tenant to update"), + key: str = typer.Argument(..., help="Key of the attribute to update"), + value: str = typer.Argument(..., help="New value for the attribute"), + debug: bool = typer.Option(False, help="Enable debug mode") +): + """ + Update a specific attribute of a tenant. + """ + config = read_config() + data = {key: value} + + if debug: + print(f"Updating tenant with ID: {tenant_id} with DATA: {data}") + + response = requests.put( + f"{config['CONTROL_PLANE_API_ENDPOINT']}tenants/{tenant_id}", + headers={"Authorization": f"Bearer {config['ACCESS_TOKEN']}", "Content-Type": "application/json"}, + data=json.dumps(data) + ) + + print(json.dumps(response.json())) diff --git a/scripts/sbt-aws-cli/tests/__init__.py b/scripts/sbt-aws-cli/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/test-sbt-aws-py.sh b/scripts/test-sbt-aws-py.sh new file mode 100644 index 00000000..9e77cb06 --- /dev/null +++ b/scripts/test-sbt-aws-py.sh @@ -0,0 +1,260 @@ +#!/bin/bash +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +CONTROL_PLANE_STACK_NAME="$1" +BASE_EMAIL="$2" + +if [ -z "$CONTROL_PLANE_STACK_NAME" ] || [ -z "$BASE_EMAIL" ]; then + echo "Usage: $0 " + exit 1 +fi + +# Colors for logging +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +# Variable to track overall test status +TEST_PASSED=true + +# Function to log test status +log_test() { + local status=$1 + local message=$2 + + if [ "$status" = "pass" ]; then + echo -e "${GREEN}[PASS] $message${NC}" + else + echo -e "${RED}[FAIL] $message${NC}" + TEST_PASSED=false + fi +} + +# Function to wait for CloudFormation stack creation +wait_for_stack_creation() { + local stack_name_pattern=$1 + local max_attempts=60 + local attempt=0 + + while true; do + stack_status=$(aws cloudformation describe-stacks --query 'Stacks[?contains(StackName, `'"$stack_name_pattern"'`)].StackStatus' --output text) + if [ "$stack_status" = "CREATE_COMPLETE" ]; then + break + elif [ "$stack_status" = "CREATE_FAILED" ]; then + log_test "fail" "CloudFormation stack creation failed" + return 1 + fi + + attempt=$((attempt + 1)) + if [ "$attempt" -gt "$max_attempts" ]; then + log_test "fail" "Timeout waiting for CloudFormation stack creation" + return 1 + fi + + sleep 10 + done +} + +check_dynamodb_table_entry() { + local table_name=$1 + local tenant_id=$2 + local expected_status=$3 + + entry=$(aws dynamodb get-item --table-name "$table_name" --key '{"tenantId":{"S":"'$tenant_id'"}}' --query 'Item.tenantStatus.S' --output text) + if [ "$entry" = "$expected_status" ]; then + return 0 + else + return 1 + fi +} + +# Generate random name and email for tenant +generate_tenant_data() { + RANDOM_SUFFIX=$(openssl rand -hex 4) + TENANT_NAME="tenant${RANDOM_SUFFIX}" + TENANT_EMAIL="${BASE_EMAIL%@*}+${TENANT_NAME}@${BASE_EMAIL#*@}" +} + +# Generate random name and email for user +generate_user_data() { + RANDOM_SUFFIX=$(openssl rand -hex 4) + USER_NAME="user${RANDOM_SUFFIX}" + USER_EMAIL="${BASE_EMAIL%@*}+${USER_NAME}@${BASE_EMAIL#*@}" +} + +# Test create-tenant +echo "Testing create-tenant..." +generate_tenant_data +tenant_id=$(sbt-aws-cli create-tenant "$TENANT_NAME" "$TENANT_EMAIL" "basic" | jq -r '.data.tenantId') +# check to make sure tenant_id is NOT empty and is NOT null +if [ -n "$tenant_id" ] && [ "$tenant_id" != "null" ]; then + log_test "pass" "Tenant created successfully with ID: $tenant_id" + + # Check DynamoDB table entry + table_name=$(aws dynamodb list-tables --query "TableNames[?contains(@, 'TenantDetails') && contains(@, '$CONTROL_PLANE_STACK_NAME')]" | jq -r '.[0]') + check_dynamodb_table_entry "$table_name" "$tenant_id" "In progress" + if [ $? -eq 0 ]; then + log_test "pass" "Tenant status set to 'In progress' in DynamoDB table" + else + log_test "fail" "Failed to set tenant status to 'In progress' in DynamoDB table" + fi + +# # Wait for CloudFormation stack creation + stack_name="$tenant_id" + wait_for_stack_creation "$stack_name" + + # Wait for tenant status to change to 'created' + max_attempts=60 + attempt=0 + while true; do + check_dynamodb_table_entry "$table_name" "$tenant_id" "created" + if [ $? -eq 0 ]; then + log_test "pass" "Tenant status changed to 'created' in DynamoDB table" + break + fi + + attempt=$((attempt + 1)) + if [ "$attempt" -gt "$max_attempts" ]; then + log_test "fail" "Timeout waiting for tenant status to change to 'created'" + break + fi + + sleep 5 + done +else + log_test "fail" "Failed to create tenant" + exit 1 +fi + +# Test get-all-tenants +echo "Testing get-all-tenants..." +tenants=$(sbt-aws-cli get-all-tenants 30) +if echo "$tenants" | grep -q "$tenant_id"; then + log_test "pass" "Tenant found in get-all-tenants" +else + log_test "fail" "Tenant not found in get-all-tenants" +fi + +# Test get-tenant +echo "Testing get-tenant..." +tenant_details=$(sbt-aws-cli get-tenant "$tenant_id") +if [ -n "$tenant_details" ]; then + log_test "pass" "Tenant details retrieved successfully" +else + log_test "fail" "Failed to retrieve tenant details" +fi + +# Test delete-tenant +echo "Testing delete-tenant..." +sbt-aws-cli delete-tenant "$tenant_id" >/dev/null +if [ $? -eq 0 ]; then + log_test "pass" "Tenant deletion initiated successfully" + + # Check DynamoDB table entry + check_dynamodb_table_entry "$table_name" "$tenant_id" "Deleting" + if [ $? -eq 0 ]; then + log_test "pass" "Tenant status set to 'Deleting' in DynamoDB table" + else + log_test "fail" "Failed to set tenant status to 'Deleting' in DynamoDB table" + fi + + # Wait for tenant status to change to 'deleted' + max_attempts=60 + attempt=0 + while true; do + check_dynamodb_table_entry "$table_name" "$tenant_id" "deleted" + if [ $? -eq 0 ]; then + log_test "pass" "Tenant status changed to 'deleted' in DynamoDB table" + break + fi + + attempt=$((attempt + 1)) + if [ "$attempt" -gt "$max_attempts" ]; then + log_test "fail" "Timeout waiting for tenant status to change to 'deleted'" + break + fi + + sleep 5 + done +else + log_test "fail" "Failed to delete tenant" +fi + +# Test deleting a non-existent tenant +echo "Testing delete-tenant for non-existent tenant..." +fake_tenant_id=$(openssl rand -hex 10) +delete_output=$(sbt-aws-cli delete-tenant "$fake_tenant_id" 2>&1) +delete_response=$(echo "$delete_output" | jq -r '.') + +if [ "$(echo "$delete_response" | jq -r '.statusCode')" = "404" ] && [ "$(echo "$delete_response" | jq -r '.message')" = "Tenant $fake_tenant_id not found." ]; then + log_test "pass" "Received expected error when deleting non-existent tenant" +else + log_test "fail" "Unexpected output when deleting non-existent tenant" +fi + +# Test create-user +echo "Testing create-user..." +generate_user_data +user_id=$(sbt-aws-cli create-user "$USER_NAME" "$USER_EMAIL" "basicUser" | jq -r '.data.userName') +if [ -n "$user_id" ] && [ "$user_id" != "null" ]; then + log_test "pass" "User created successfully with ID: $user_id" +else + log_test "fail" "Failed to create user" +fi + +# Test get-all-users +echo "Testing get-all-users..." +users=$(sbt-aws-cli get-all-users 30) +if echo "$users" | grep -q "$user_id"; then + log_test "pass" "User found in get-all-users" +else + log_test "fail" "User not found in get-all-users" +fi + +# Test get-users +echo "Testing get-user..." +user_details=$(sbt-aws-cli get-user "$user_id") +if [ -n "$user_details" ]; then + log_test "pass" "User details retrieved successfully" +else + log_test "fail" "Failed to retrieve user details" +fi + +# Test update-user +new_user_role="advancedUser" +new_user_email="newemail@example.com" +sbt-aws-cli update-user "$user_id" "$new_user_role" "$new_user_email" >/dev/null +if [ $? -eq 0 ]; then + log_test "pass" "User update initiated successfully" + + # Get the updated user details + updated_user_details=$(sbt-aws-cli get-user "$user_id") + updated_user_role=$(echo "$updated_user_details" | jq -r '.data' | jq -r '.userRole') + updated_user_email=$(echo "$updated_user_details" | jq -r '.data' | jq -r '.email') + + # Verify the updated user details + if [ "$updated_user_role" = "$new_user_role" ] && [ "$updated_user_email" = "$new_user_email" ]; then + log_test "pass" "User details updated successfully" + else + log_test "fail" "Failed to update user details" + fi +else + log_test "fail" "Failed to update user" +fi + +# Test delete-user +echo "Testing delete-user..." +sbt-aws-cli delete-user "$user_id" >/dev/null +if [ $? -eq 0 ]; then + log_test "pass" "User deletion initiated successfully" +else + log_test "fail" "Failed to delete user" +fi + +# Set the exit code based on the overall test status +if [ "$TEST_PASSED" = true ]; then + exit 0 +else + exit 1 +fi diff --git a/src/control-plane/auth/cli-auth.ts b/src/control-plane/auth/cli-auth.ts new file mode 100644 index 00000000..ea86b3c5 --- /dev/null +++ b/src/control-plane/auth/cli-auth.ts @@ -0,0 +1,497 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as path from 'path'; +import * as cdk from 'aws-cdk-lib'; +import { Duration } from 'aws-cdk-lib'; +import { Certificate, CertificateValidation } from 'aws-cdk-lib/aws-certificatemanager'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as elasticloadbalancingv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; +import * as elbv2Targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets'; +import * as events from 'aws-cdk-lib/aws-events'; +import * as targets from 'aws-cdk-lib/aws-events-targets'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as route53 from 'aws-cdk-lib/aws-route53'; +import * as route53Targets from 'aws-cdk-lib/aws-route53-targets'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; +import { addTemplateTag } from '../../utils'; + +/** + * Parameters for CLI authentication setup + */ +interface cognitoAuthCLIProps { + /** + * The ID of the hosted zone in Route 53 where the domain is registered. + */ + readonly hostedZoneId: string; + + /** + */ + readonly zoneName: string; + + readonly userPool: cognito.UserPool; + + readonly jwtAudience: string[]; + + readonly cognitoDomain: string; +} + +export class cognitoAuthCLI extends Construct { + constructor(scope: Construct, id: string, props: cognitoAuthCLIProps) { + super(scope, id); + addTemplateTag(this, 'CognitoAuthCLI'); + const { hostedZoneId, zoneName, userPool, jwtAudience, cognitoDomain } = props; + + const fqdn = 'sbt.auth.' + zoneName; + + const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'hostedZone', { + hostedZoneId: hostedZoneId, + zoneName: zoneName, + }); + + const albCertificate = new Certificate(scope, 'AlbCertificate', { + domainName: fqdn, + validation: CertificateValidation.fromDns(hostedZone), + }); + + // Device Authorization Resources + const cognitoUserPoolDomain = new cognito.UserPoolDomain(scope, 'CognitoUserPoolDomain', { + cognitoDomain: { + domainPrefix: cognitoDomain, + }, + userPool: userPool, + }); + + const deviceCognitoClient = new cognito.UserPoolClient(scope, 'DeviceCognitoClient', { + userPool: userPool, + authFlows: { + adminUserPassword: true, + custom: true, + userSrp: true, + userPassword: false, + }, + oAuth: { + flows: { + authorizationCodeGrant: true, + }, + scopes: [cognito.OAuthScope.OPENID], + callbackUrls: [`https://${fqdn}/callback`], + }, + generateSecret: true, + supportedIdentityProviders: [cognito.UserPoolClientIdentityProvider.COGNITO], + }); + + const deviceGrantDynamoDbTable = new dynamodb.Table(scope, 'DeviceGrantDynamoDBTable', { + partitionKey: { name: 'Device_code', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PROVISIONED, + readCapacity: 20, + writeCapacity: 20, + pointInTimeRecovery: true, + }); + + deviceGrantDynamoDbTable.addGlobalSecondaryIndex({ + indexName: 'AuthZ_state-index', + partitionKey: { name: 'AuthZ_State', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + readCapacity: 20, + writeCapacity: 20, + }); + + deviceGrantDynamoDbTable.addGlobalSecondaryIndex({ + indexName: 'User_code-index', + partitionKey: { name: 'User_code', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + readCapacity: 20, + writeCapacity: 20, + }); + + const deviceGrantVpc = new ec2.Vpc(scope, 'DeviceGrantVPC', { + cidr: '10.192.0.0/16', + maxAzs: 2, + subnetConfiguration: [ + { + cidrMask: 24, + name: 'Public', + subnetType: ec2.SubnetType.PUBLIC, + }, + ], + enableDnsSupport: true, + enableDnsHostnames: true, + }); + + new ec2.FlowLog(scope, 'FlowLog', { + resourceType: ec2.FlowLogResourceType.fromVpc(deviceGrantVpc), + destination: ec2.FlowLogDestination.toCloudWatchLogs( + new logs.LogGroup(scope, 'FlowLogGroup') + ), + }); + + const grantDeviceAlbCognitoClient = new cognito.UserPoolClient( + scope, + 'GrantDeviceALBCognitoClient', + { + userPool: userPool, + authFlows: { + adminUserPassword: true, + custom: true, + userSrp: true, + userPassword: false, + }, + oAuth: { + flows: { + authorizationCodeGrant: true, + }, + scopes: [cognito.OAuthScope.OPENID], + callbackUrls: [`https://${fqdn}/oauth2/idpresponse`], + }, + generateSecret: true, + supportedIdentityProviders: [cognito.UserPoolClientIdentityProvider.COGNITO], + } + ); + + const retrieveCognitoSecretsPolicy = new iam.ManagedPolicy( + scope, + 'RetrieveCognitoSecretsPolicy', + { + statements: [ + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['cognito-idp:ListUserPoolClients', 'cognito-idp:DescribeUserPoolClient'], + resources: [ + `arn:aws:cognito-idp:${cdk.Stack.of(scope).region}:${cdk.Stack.of(scope).account}:userpool/${userPool.userPoolId}`, + ], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], + resources: ['*'], + }), + ], + } + ); + + const retrieveCognitoSecretsIamRole = new iam.Role(scope, 'RetrieveCognitoSecretsIAMRole', { + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), + managedPolicies: [retrieveCognitoSecretsPolicy], + }); + + NagSuppressions.addResourceSuppressions( + retrieveCognitoSecretsPolicy, + [ + { + id: 'AwsSolutions-IAM5', + reason: 'Lambda function needs permission to create and write logs to CloudWatch Logs.', + appliesTo: ['Resource::*'], + }, + ], + true + ); + + const deviceGrantAlbsg = new ec2.SecurityGroup(scope, 'DeviceGrantALBSG', { + vpc: deviceGrantVpc, + description: 'SG for Device grant ALB', + allowAllOutbound: false, + }); + deviceGrantAlbsg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Allow HTTPS traffic'); + deviceGrantAlbsg.addEgressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(443), + 'Allow HTTPS outbound traffic' + ); + + NagSuppressions.addResourceSuppressions(deviceGrantAlbsg, [ + { + id: 'AwsSolutions-EC23', + reason: 'ALB needs to be accessible from the internet for the device grant flow', + }, + ]); + + const deviceGrantCleaningIamRole = new iam.Role(scope, 'DeviceGrantCleaningIAMRole', { + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), + managedPolicies: [ + new iam.ManagedPolicy(scope, 'CleaningTablePolicy', { + statements: [ + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'dynamodb:PutItem', + 'dynamodb:DeleteItem', + 'dynamodb:GetItem', + 'dynamodb:Scan', + 'dynamodb:Query', + 'dynamodb:UpdateItem', + ], + resources: [deviceGrantDynamoDbTable.tableArn], + }), + ], + }), + ], + }); + + const deviceGrantTokenIamRole = new iam.Role(scope, 'DeviceGrantTokenIAMRole', { + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), + managedPolicies: [ + new iam.ManagedPolicy(scope, 'DeviceGrantTokenTablePolicy', { + statements: [ + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'dynamodb:PutItem', + 'dynamodb:DeleteItem', + 'dynamodb:GetItem', + 'dynamodb:Scan', + 'dynamodb:Query', + 'dynamodb:UpdateItem', + ], + resources: [ + deviceGrantDynamoDbTable.tableArn, + `${deviceGrantDynamoDbTable.tableArn}/index/User_code-index`, + `${deviceGrantDynamoDbTable.tableArn}/index/AuthZ_state-index`, + ], + }), + ], + }), + new iam.ManagedPolicy(scope, 'DevicegrantTokenRetrieveSecretsPolicy', { + statements: [ + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['cognito-idp:ListUserPoolClients', 'cognito-idp:DescribeUserPoolClient'], + resources: [ + `arn:aws:cognito-idp:${cdk.Stack.of(scope).region}:${cdk.Stack.of(scope).account}:userpool/${userPool.userPoolId!}`, + ], + }), + ], + }), + ], + }); + + const retrieveCognitoSecretsLambda = new lambda.Function( + scope, + 'RetrieveCognitoSecretsLambda', + { + runtime: lambda.Runtime.PYTHON_3_12, + handler: 'index.lambda_handler', + code: lambda.Code.fromInline(` +import cfnresponse +import boto3 +import json + +def lambda_handler(event, context): + print("start") + print(json.dumps(event)) + + if event['RequestType'] == 'Create': + client = boto3.client('cognito-idp') + ALBClientID = event['ResourceProperties']['albauthorizerid'] + DeviceCognitoClientID = event['ResourceProperties']['DeviceCognitoClientid'] + userPoolId = event['ResourceProperties']['cupid'] + + responseData = {} + + try: + response = client.describe_user_pool_client( + UserPoolId=userPoolId, + ClientId=ALBClientID + ) + responseData['ALBAuthorizerSecret'] = response['UserPoolClient']['ClientSecret'] + except: + print('Cannot retrive Cognito User Pool Client information for ALB') + cfnresponse.send(event, context, cfnresponse.FAILED, {}) + + try: + response = client.describe_user_pool_client( + UserPoolId=userPoolId, + ClientId=DeviceCognitoClientID + ) + responseData['DeviceCognitoClientSecret'] = response['UserPoolClient']['ClientSecret'] + except: + print('Cannot retrive Cognito User Pool Client information for Device') + cfnresponse.send(event, context, cfnresponse.FAILED, {}) + + cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData) + else: + cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) +`), + role: retrieveCognitoSecretsIamRole, + timeout: Duration.seconds(30), + } + ); + + const retrieveCognitoSecrets = new cdk.CustomResource(scope, 'RetrieveCognitoSecrets', { + serviceToken: retrieveCognitoSecretsLambda.functionArn, + properties: { + cupid: userPool.userPoolId, + albauthorizerid: grantDeviceAlbCognitoClient.userPoolClientId, + DeviceCognitoClientid: deviceCognitoClient.userPoolClientId, + }, + }); + + new cdk.CfnOutput(scope, 'CfnOutputDeviceCognitoClientClientSecret', { + description: 'Device Client Secret for CLI', + value: retrieveCognitoSecrets.getAttString('DeviceCognitoClientSecret'), + }); + + const deviceGrantToken = new lambda.Function(scope, 'DeviceGrantToken', { + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'index.handler', + code: lambda.Code.fromAsset( + path.join(__dirname, '../../../resources/functions/auth-device-grant-token') + ), + environment: { + APP_CLIENT_ID: grantDeviceAlbCognitoClient.userPoolClientId, + APP_CLIENT_SECRET: retrieveCognitoSecrets.getAttString('ALBAuthorizerSecret'), + CODE_EXPIRATION: '1800', + CODE_VERIFICATION_URI: fqdn, + CUP_DOMAIN: cognitoDomain, + CUP_ID: userPool.userPoolId!, + CUP_REGION: cdk.Fn.select(0, cdk.Fn.split('_', userPool.userPoolId!)), + DEVICE_CODE_FORMAT: '#aA', + DEVICE_CODE_LENGTH: '64', + DYNAMODB_AUTHZ_STATE_INDEX: 'AuthZ_state-index', + DYNAMODB_TABLE: deviceGrantDynamoDbTable.tableName, + DYNAMODB_USERCODE_INDEX: 'User_code-index', + POLLING_INTERVAL: '5', + USER_CODE_FORMAT: '#B', + USER_CODE_LENGTH: '8', + }, + role: deviceGrantTokenIamRole, + timeout: Duration.seconds(30), + }); + + const deviceGrantTokenCleaning = new lambda.Function(scope, 'DeviceGrantTokenCleaning', { + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'index.handler', + code: lambda.Code.fromAsset( + path.join(__dirname, '../../../resources/functions/auth-token-cleaning') + ), + environment: { + DYNAMODB_TABLE: deviceGrantDynamoDbTable.tableName, + }, + role: deviceGrantCleaningIamRole, + timeout: Duration.seconds(30), + }); + + deviceGrantToken.addPermission('ALBToLambdaPerms', { + action: 'lambda:InvokeFunction', + principal: new iam.ServicePrincipal('elasticloadbalancing.amazonaws.com'), + }); + + const cwRuleForCleaning = new events.Rule(scope, 'CWRuleForCleaning', { + description: 'Invoke Cleaning Lambda', + schedule: events.Schedule.rate(cdk.Duration.hours(1)), + targets: [new targets.LambdaFunction(deviceGrantTokenCleaning)], + }); + + cwRuleForCleaning.addTarget(new targets.LambdaFunction(deviceGrantTokenCleaning)); + + const deviceGrantAlbTarget = new elasticloadbalancingv2.ApplicationTargetGroup( + scope, + 'DeviceGrantALBTarget', + { + vpc: deviceGrantVpc, + targetType: elasticloadbalancingv2.TargetType.LAMBDA, + targets: [new elbv2Targets.LambdaTarget(deviceGrantToken)], + } + ); + + const deviceGrantAlb = new elasticloadbalancingv2.ApplicationLoadBalancer( + scope, + 'DeviceGrantALB', + { + vpc: deviceGrantVpc, + internetFacing: true, + securityGroup: deviceGrantAlbsg, + vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, + } + ); + + NagSuppressions.addResourceSuppressions(deviceGrantAlb, [ + { + id: 'AwsSolutions-ELB2', + reason: 'Access logging is not required for this demo ALB', + }, + ]); + + new route53.ARecord(scope, 'ALBRecordSet', { + zone: hostedZone, + target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(deviceGrantAlb)), + recordName: fqdn, + }); + + const devicegrantAlb443 = new elasticloadbalancingv2.ApplicationListener( + scope, + 'DevicegrantALB443', + { + port: 443, + protocol: elasticloadbalancingv2.ApplicationProtocol.HTTPS, + certificates: [ + elasticloadbalancingv2.ListenerCertificate.fromArn(albCertificate.certificateArn), + ], + loadBalancer: deviceGrantAlb, + sslPolicy: elasticloadbalancingv2.SslPolicy.TLS12, + defaultAction: elasticloadbalancingv2.ListenerAction.fixedResponse(503, { + contentType: 'text/html', + messageBody: '', + }), + } + ); + + new elasticloadbalancingv2.ApplicationListenerRule(scope, 'DevicegrantALB443Device', { + listener: devicegrantAlb443, + priority: 1, + conditions: [elasticloadbalancingv2.ListenerCondition.pathPatterns(['/device'])], + action: elasticloadbalancingv2.ListenerAction.authenticateOidc({ + authorizationEndpoint: `https://${cognitoUserPoolDomain.domainName}.auth.${cdk.Stack.of(scope).region}.amazoncognito.com/oauth2/authorize`, + tokenEndpoint: `https://${cognitoUserPoolDomain.domainName}.auth.${cdk.Stack.of(scope).region}.amazoncognito.com/oauth2/token`, + userInfoEndpoint: `https://${cognitoUserPoolDomain.domainName}.auth.${cdk.Stack.of(scope).region}.amazoncognito.com/oauth2/userInfo`, + clientId: grantDeviceAlbCognitoClient.userPoolClientId, + clientSecret: grantDeviceAlbCognitoClient.userPoolClientSecret, + issuer: `https://cognito-idp.${cdk.Stack.of(scope).region}.amazonaws.com/${userPool.userPoolId}`, + next: elasticloadbalancingv2.ListenerAction.forward([deviceGrantAlbTarget]), + }), + }); + + new elasticloadbalancingv2.ApplicationListenerRule(scope, 'DevicegrantALB443TokenOrCallback', { + listener: devicegrantAlb443, + priority: 2, + conditions: [elasticloadbalancingv2.ListenerCondition.pathPatterns(['/token', '/callback'])], + action: elasticloadbalancingv2.ListenerAction.forward([deviceGrantAlbTarget]), + }); + + new cdk.CfnOutput(scope, 'CfnOutputALBCNAMEForDNSConfiguration', { + description: 'CNAME of the ALB Endpoint to point your DNS to', + value: deviceGrantAlb.loadBalancerDnsName, + }); + + new cdk.CfnOutput(scope, 'CfnOutputTestEndPointForDevice', { + description: 'HTTPS Endpoint for the simulated DEVICE to make their requests', + value: `https://${fqdn}/token`, + }); + + new cdk.CfnOutput(scope, 'CfnOutputTestEndPointForUser', { + description: 'HTTPS Endpoint for the USER to make their requests', + value: `https://${fqdn}/device`, + }); + + new cdk.CfnOutput(scope, 'CfnOutputDeviceCognitoClientClientID', { + description: 'Device Client ID for CLI', + value: deviceCognitoClient.userPoolClientId, + }); + + new cdk.CfnOutput(scope, 'CognitoDomain', { + value: `${cognitoDomain}.auth.${cdk.Stack.of(scope).region}.amazoncognito.com`, + description: 'Cognito Domain', + }); + + new cdk.CfnOutput(scope, 'ControlPlaneFQDN', { + value: fqdn || '', + }); + + jwtAudience.push(deviceCognitoClient.userPoolClientId); + } +} diff --git a/src/control-plane/auth/cognito-auth.ts b/src/control-plane/auth/cognito-auth.ts index 87ce935f..b975d390 100644 --- a/src/control-plane/auth/cognito-auth.ts +++ b/src/control-plane/auth/cognito-auth.ts @@ -17,6 +17,7 @@ import { Runtime, IFunction, LayerVersion } from 'aws-cdk-lib/aws-lambda'; import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; import { CreateAdminUserProps, IAuth } from './auth-interface'; +import { cognitoAuthCLI } from './cli-auth'; import { addTemplateTag } from '../../utils'; /** @@ -35,6 +36,25 @@ export interface CognitoAuthProps { * @default true */ readonly setAPIGWScopes?: boolean; + + /** + * Parameters for CLI authentication setup + */ + readonly cliProps?: { + /** + * The ID of the hosted zone in Route 53 where the domain is registered. + */ + hostedZoneId: string; + + /** + */ + readonly zoneName: string; + + /** + * Initalize as a string-indexed map for properties + */ + [key: string]: string; + }; } /** @@ -218,7 +238,12 @@ export class CognitoAuth extends Construct implements IAuth { super(scope, id); addTemplateTag(this, 'CognitoAuth'); + const cognitoDomain = `sbt${cdk.Stack.of(this).account}-${this.node.addr}`; + // https://docs.powertools.aws.dev/lambda/python/2.31.0/#lambda-layer + + this.jwtAudience = []; + const lambdaPowertoolsLayer = LayerVersion.fromLayerVersionArn( this, 'LambdaPowerTools', @@ -251,6 +276,25 @@ export class CognitoAuth extends Construct implements IAuth { advancedSecurityMode: cognito.AdvancedSecurityMode.ENFORCED, }); + if (props?.cliProps) { + new cognitoAuthCLI(this, 'cognitoAuthCLI', { + hostedZoneId: props.cliProps.hostedZoneId, + userPool: this.userPool, + jwtAudience: this.jwtAudience, + zoneName: props.cliProps.zoneName, + cognitoDomain, + }); + this.tokenEndpoint = `https://${cognitoDomain}.auth.${Stack.of(this).region}.amazoncognito.com/oauth2/token`; + } else { + const userPoolDomain = new cognito.UserPoolDomain(this, 'UserPoolDomain', { + userPool: this.userPool, + cognitoDomain: { + domainPrefix: `${cdk.Stack.of(this).account}-${this.node.addr}`, + }, + }); + this.tokenEndpoint = `https://${userPoolDomain.domainName}.auth.${Stack.of(this).region}.amazoncognito.com/oauth2/token`; + } + NagSuppressions.addResourceSuppressions(this.userPool, [ { id: 'AwsSolutions-COG2', @@ -328,14 +372,6 @@ export class CognitoAuth extends Construct implements IAuth { this.deactivateTenantScope = tenantResourceServerWriteScope.scopeName; } - // Create a Cognito User Pool Domain - const userPoolDomain = new cognito.UserPoolDomain(this, 'UserPoolDomain', { - userPool: this.userPool, - cognitoDomain: { - domainPrefix: `${cdk.Stack.of(this).account}-${this.node.addr}`, - }, - }); - const userPoolMachineClient = new cognito.UserPoolClient(this, 'UserPoolMachineClient', { userPool: this.userPool, generateSecret: true, @@ -389,11 +425,8 @@ export class CognitoAuth extends Construct implements IAuth { this.machineClientSecret = userPoolMachineClient.userPoolClientSecret; this.wellKnownEndpointUrl = `https://cognito-idp.${region}.amazonaws.com/${this.userPool.userPoolId}/.well-known/openid-configuration`; this.jwtIssuer = `https://cognito-idp.${region}.amazonaws.com/${this.userPool.userPoolId}`; - this.jwtAudience = [ - userPoolUserClient.userPoolClientId, - userPoolMachineClient.userPoolClientId, - ]; - this.tokenEndpoint = `https://${userPoolDomain.domainName}.auth.${region}.amazoncognito.com/oauth2/token`; + this.jwtAudience.push(userPoolUserClient.userPoolClientId); + this.jwtAudience.push(userPoolMachineClient.userPoolClientId); // TODO: The caller should be surfacing these, not the implementor new cdk.CfnOutput(this, 'ControlPlaneIdpUserPoolId', { @@ -518,11 +551,18 @@ export class CognitoAuth extends Construct implements IAuth { NagSuppressions.addResourceSuppressions( this.createAdminUserFunction.role!, [ + { + id: 'AwsSolutions-IAM5', + reason: 'Auth user resource name(s) not known beforehand.', + }, { id: 'AwsSolutions-IAM4', - reason: 'Suppress usage of AWSLambdaBasicExecutionRole.', + reason: + 'Suppress usage of AWSLambdaBasicExecutionRole, CloudWatchLambdaInsightsExecutionRolePolicy, and AWSXrayWriteOnlyAccess.', appliesTo: [ 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', + 'Policy::arn::iam::aws:policy/CloudWatchLambdaInsightsExecutionRolePolicy', + 'Policy::arn::iam::aws:policy/AWSXrayWriteOnlyAccess', ], }, ], diff --git a/src/control-plane/integ.default.ts b/src/control-plane/integ.default.ts index 8bca5f65..871f82aa 100644 --- a/src/control-plane/integ.default.ts +++ b/src/control-plane/integ.default.ts @@ -18,6 +18,10 @@ export class IntegStack extends cdk.Stack { const cognitoAuth = new sbt.CognitoAuth(this, 'CognitoAuth', { setAPIGWScopes: false, // only for testing purposes! + cliProps: { + hostedZoneId: 'Z0019126SQOPPYL74TA5', + zoneName: 'beautse.people.aws.dev', + }, }); const controlPlane = new sbt.ControlPlane(this, 'ControlPlane', {