diff --git a/.changeset/little-kids-double.md b/.changeset/little-kids-double.md new file mode 100644 index 00000000000..cb70f386429 --- /dev/null +++ b/.changeset/little-kids-double.md @@ -0,0 +1,6 @@ +--- +'@aws-amplify/seed': major +'@aws-amplify/backend-cli': minor +--- + +adding seed feature diff --git a/package-lock.json b/package-lock.json index a46b3f6fadd..db009276b3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -361,7 +361,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -374,7 +373,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -388,7 +386,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.0.0.tgz", "integrity": "sha512-rctU1VkziY84n5OXe3bPNpKR001ZCME2JCaBBFgtiM2hfKbHFudc/BkMuPab8hRbLd0j3vbnBTTZ1igBf0wgiQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.0.0", @@ -436,7 +433,6 @@ "version": "3.387.0", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.387.0.tgz", "integrity": "sha512-YTjFabNwjTF+6yl88f0/tWff018qmmgMmjlw45s6sdVKueWxdxV68U7gepNLF2nhaQPZa6FDOBoA51NaviVs0Q==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^2.1.0", @@ -450,7 +446,6 @@ "version": "2.12.0", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -463,7 +458,6 @@ "version": "15.8.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz", "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.x" @@ -683,6 +677,7 @@ "@aws-sdk/types": "3.398.0", "@smithy/util-hex-encoding": "2.0.0", "@types/uuid": "^9.0.0", + "cookie": "^0.7.0", "js-cookie": "^3.0.5", "rxjs": "^7.8.1", "tslib": "^2.5.0", @@ -693,7 +688,6 @@ "version": "3.398.0", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.398.0.tgz", "integrity": "sha512-r44fkS+vsEgKCuEuTV+TIk0t0m5ZlXHNjSDYEUvzLStbbfUFiNus/YG4UCa0wOk9R7VuQI67badsvvPeVPCGDQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^2.2.2", @@ -707,7 +701,6 @@ "version": "2.12.0", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -720,7 +713,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.0.0.tgz", "integrity": "sha512-c5xY+NUnFqG6d7HFh1IFfrm3mGl29lC+vF+geHv4ToiuJCBmIfzx6IeHLg+OgRdPFKDXIw6pvi+p3CsscaMcMA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" @@ -9140,6 +9132,10 @@ "resolved": "packages/schema-generator", "link": true }, + "node_modules/@aws-amplify/seed": { + "resolved": "packages/seed", + "link": true + }, "node_modules/@aws-amplify/storage": { "version": "6.7.13", "resolved": "https://registry.npmjs.org/@aws-amplify/storage/-/storage-6.7.13.tgz", @@ -9162,7 +9158,6 @@ "version": "3.398.0", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.398.0.tgz", "integrity": "sha512-r44fkS+vsEgKCuEuTV+TIk0t0m5ZlXHNjSDYEUvzLStbbfUFiNus/YG4UCa0wOk9R7VuQI67badsvvPeVPCGDQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^2.2.2", @@ -9176,7 +9171,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -9189,7 +9183,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-2.0.7.tgz", "integrity": "sha512-2i2BpXF9pI5D1xekqUsgQ/ohv5+H//G9FlawJrkOJskV18PgJ8LiNbLiskMeYt07yAsSTZR7qtlcAaa/GQLWww==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^2.3.1", @@ -9201,7 +9194,6 @@ "version": "2.12.0", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -9214,7 +9206,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -9228,7 +9219,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -11840,7 +11830,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, "node_modules/@aws-sdk/client-firehose/node_modules/@smithy/util-utf8": { @@ -13517,7 +13507,6 @@ "version": "3.621.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.621.0.tgz", "integrity": "sha512-707uiuReSt+nAx6d0c21xLjLm2lxeKc7padxjv92CIrIocnQSlJPxSCM7r5zBhwiahJA6MNQwmTl2xznU67KgA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -13569,7 +13558,6 @@ "version": "3.621.0", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.621.0.tgz", "integrity": "sha512-CtOwWmDdEiINkGXD93iGfXjN0WmCp9l45cDWHHGa8lRgEDyhuL7bwd/pH5aSzj0j8SiQBG2k0S7DHbd5RaqvbQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^2.3.1", @@ -13590,7 +13578,6 @@ "version": "3.620.1", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.620.1.tgz", "integrity": "sha512-ExuILJ2qLW5ZO+rgkNRj0xiAipKT16Rk77buvPP8csR7kkCflT/gXTyzRe/uzIiETTxM7tr8xuO9MP/DQXqkfg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13606,7 +13593,6 @@ "version": "3.621.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.621.0.tgz", "integrity": "sha512-/jc2tEsdkT1QQAI5Dvoci50DbSxtJrevemwFsm0B73pwCcOQZ5ZwwSdVqGsPutzYzUVx3bcXg3LRL7jLACqRIg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13627,7 +13613,6 @@ "version": "3.621.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.621.0.tgz", "integrity": "sha512-0EWVnSc+JQn5HLnF5Xv405M8n4zfdx9gyGdpnCmAmFqEDHA8LmBdxJdpUk1Ovp/I5oPANhjojxabIW5f1uU0RA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "3.620.1", @@ -13653,7 +13638,6 @@ "version": "3.621.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.621.0.tgz", "integrity": "sha512-4JqpccUgz5Snanpt2+53hbOBbJQrSFq7E1sAAbgY6BKVQUsW5qyXqnjvSF32kDeKa5JpBl3bBWLZl04IadcPHw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "3.620.1", @@ -13677,7 +13661,6 @@ "version": "3.620.1", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.620.1.tgz", "integrity": "sha512-hWqFMidqLAkaV9G460+1at6qa9vySbjQKKc04p59OT7lZ5cO5VH5S4aI05e+m4j364MBROjjk2ugNvfNf/8ILg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13694,7 +13677,6 @@ "version": "3.621.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.621.0.tgz", "integrity": "sha512-Kza0jcFeA/GEL6xJlzR2KFf1PfZKMFnxfGzJzl5yN7EjoGdMijl34KaRyVnfRjnCWcsUpBWKNIDk9WZVMY9yiw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-sso": "3.621.0", @@ -13733,7 +13715,6 @@ "version": "3.621.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.621.0.tgz", "integrity": "sha512-w7ASSyfNvcx7+bYGep3VBgC3K6vEdLmlpjT7nSIHxxQf+WSdvy+HynwJosrpZax0sK5q0D1Jpn/5q+r5lwwW6w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13752,7 +13733,6 @@ "version": "3.620.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.620.0.tgz", "integrity": "sha512-VMtPEZwqYrII/oUkffYsNWY9PZ9xpNJpMgmyU0rlDQ25O1c0Hk3fJmZRe6pEkAJ0omD7kLrqGl1DUjQVxpd/Rg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13768,7 +13748,6 @@ "version": "3.609.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.609.0.tgz", "integrity": "sha512-S62U2dy4jMDhDFDK5gZ4VxFdWzCtLzwbYyFZx2uvPYTECkepLUfzLic2BHg2Qvtu4QjX+oGE3P/7fwaGIsGNuQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13783,7 +13762,6 @@ "version": "3.620.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.620.0.tgz", "integrity": "sha512-nh91S7aGK3e/o1ck64sA/CyoFw+gAYj2BDOnoNa6ouyCrVJED96ZXWbhye/fz9SgmNUZR2g7GdVpiLpMKZoI5w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13799,7 +13777,6 @@ "version": "3.620.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.620.0.tgz", "integrity": "sha512-bvS6etn+KsuL32ubY5D3xNof1qkenpbJXf/ugGXbg0n98DvDFQ/F+SMLxHgbnER5dsKYchNnhmtI6/FC3HFu/A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13816,7 +13793,6 @@ "version": "3.614.0", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.614.0.tgz", "integrity": "sha512-vDCeMXvic/LU0KFIUjpC3RiSTIkkvESsEfbVHiHH0YINfl8HnEqR5rj+L8+phsCeVg2+LmYwYxd5NRz4PHxt5g==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13834,7 +13810,6 @@ "version": "3.609.0", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^3.3.0", @@ -13848,7 +13823,6 @@ "version": "3.614.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.614.0.tgz", "integrity": "sha512-wK2cdrXHH4oz4IomV/yrGkftU9A+ITB6nFL+rxxyO78is2ifHJpFdV4aqk4LSkXYPi6CXWNru/Dqc7yiKXgJPw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13864,7 +13838,6 @@ "version": "3.609.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.609.0.tgz", "integrity": "sha512-fojPU+mNahzQ0YHYBsx0ZIhmMA96H+ZIZ665ObU9tl+SGdbLneVZVikGve+NmHTQwHzwkFsZYYnVKAkreJLAtA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -13877,7 +13850,6 @@ "version": "3.614.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.614.0.tgz", "integrity": "sha512-15ElZT88peoHnq5TEoEtZwoXTXRxNrk60TZNdpl/TUBJ5oNJ9Dqb5Z4ryb8ofN6nm9aFf59GVAerFDz8iUoHBA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.609.0", @@ -27723,7 +27695,6 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -31171,7 +31142,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/idb/-/idb-5.0.6.tgz", "integrity": "sha512-/PFvOWPzRcEPmlDt5jEvzVZVs0wyd/EvGvkDIcbBpGuMMLQKrTPG0TxvE2UJtgZtCQCmOtM2QD7yQJBVEjKGOw==", - "dev": true, "license": "ISC" }, "node_modules/ieee754": { @@ -31207,7 +31177,6 @@ "version": "9.0.6", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.6.tgz", "integrity": "sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -32199,7 +32168,6 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -37798,7 +37766,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.3.0.tgz", "integrity": "sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==", - "dev": true, "license": "MIT", "bin": { "ulid": "bin/cli.js" @@ -40802,6 +40769,7 @@ "@aws-sdk/client-amplify": "^3.750.0", "@aws-sdk/client-cloudformation": "^3.750.0", "@aws-sdk/client-s3": "^3.750.0", + "@aws-sdk/client-sts": "^3.750.0", "@aws-sdk/credential-provider-ini": "^3.750.0", "@aws-sdk/credential-providers": "^3.750.0", "@aws-sdk/region-config-resolver": "^3.734.0", @@ -40827,7 +40795,8 @@ "node": ">=18.16.0" }, "peerDependencies": { - "@aws-sdk/types": "^3.734.0" + "@aws-sdk/types": "^3.734.0", + "aws-cdk-lib": "^2.180.0" } }, "packages/cli-core": { @@ -41262,6 +41231,7 @@ "@aws-amplify/deployed-backend-client": "^1.5.2", "@aws-amplify/platform-core": "^1.6.5", "@aws-amplify/plugin-types": "^1.8.1", + "@aws-amplify/seed": "^0.1.0", "@aws-cdk/toolkit-lib": "0.1.5", "@aws-sdk/client-accessanalyzer": "^3.750.0", "@aws-sdk/client-amplify": "^3.750.0", @@ -43414,6 +43384,20 @@ "@aws-amplify/graphql-schema-generator": "^0.11.0", "@aws-amplify/platform-core": "^1.6.5" } + }, + "packages/seed": { + "name": "@aws-amplify/seed", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/backend-secret": "^1.1.5", + "@aws-amplify/cli-core": "^1.2.3", + "@aws-amplify/client-config": "^1.5.5", + "@aws-amplify/platform-core": "^1.6.0", + "@aws-amplify/plugin-types": "^1.8.0", + "@aws-sdk/client-cognito-identity-provider": "^3.750.0", + "aws-amplify": "^6.0.16" + } } } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 0a89520caa2..080c96fbbde 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,6 +46,7 @@ "@aws-sdk/client-amplify": "^3.750.0", "@aws-sdk/client-cloudformation": "^3.750.0", "@aws-sdk/client-s3": "^3.750.0", + "@aws-sdk/client-sts": "^3.750.0", "@aws-sdk/credential-provider-ini": "^3.750.0", "@aws-sdk/credential-providers": "^3.750.0", "@aws-sdk/region-config-resolver": "^3.734.0", @@ -61,7 +62,8 @@ "zod": "^3.22.2" }, "peerDependencies": { - "@aws-sdk/types": "^3.734.0" + "@aws-sdk/types": "^3.734.0", + "aws-cdk-lib": "^2.180.0" }, "devDependencies": { "@types/envinfo": "^7.8.3", diff --git a/packages/cli/src/commands/sandbox/sandbox-seed/sandbox_seed_command.test.ts b/packages/cli/src/commands/sandbox/sandbox-seed/sandbox_seed_command.test.ts new file mode 100644 index 00000000000..5c6e0897ac0 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-seed/sandbox_seed_command.test.ts @@ -0,0 +1,152 @@ +import { after, before, describe, it, mock } from 'node:test'; +import fsp from 'fs/promises'; +import * as path from 'path'; +import { SandboxSeedCommand } from './sandbox_seed_command.js'; +import yargs, { CommandModule } from 'yargs'; +import { + TestCommandError, + TestCommandRunner, +} from '../../../test-utils/command_runner.js'; +import { createSandboxSecretCommand } from '../sandbox-secret/sandbox_secret_command_factory.js'; +import { EventHandler, SandboxCommand } from '../sandbox_command.js'; +import { SandboxSingletonFactory } from '@aws-amplify/sandbox'; +import { SandboxDeleteCommand } from '../sandbox-delete/sandbox_delete_command.js'; +import { ClientConfigGeneratorAdapter } from '../../../client-config/client_config_generator_adapter.js'; +import { format, printer } from '@aws-amplify/cli-core'; +import { CommandMiddleware } from '../../../command_middleware.js'; +import { SandboxSeedGeneratePolicyCommand } from './sandbox_seed_policy_command.js'; +import assert from 'node:assert'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { SandboxBackendIdResolver } from '../sandbox_id_resolver.js'; + +const seedFileContents = 'console.log(`seed has been run`);'; + +const testBackendNameSpace = 'testSandboxId'; +const testSandboxName = 'testSandboxName'; + +const testBackendId: BackendIdentifier = { + namespace: testBackendNameSpace, + name: testSandboxName, + type: 'sandbox', +}; + +void describe('sandbox seed command', () => { + let commandRunner: TestCommandRunner; + + const clientConfigGenerationMock = mock.fn(); + const clientConfigDeletionMock = mock.fn(); + + const clientConfigGeneratorAdapterMock = { + generateClientConfigToFile: clientConfigGenerationMock, + } as unknown as ClientConfigGeneratorAdapter; + + const commandMiddleware = new CommandMiddleware(printer); + const mockHandleProfile = mock.method( + commandMiddleware, + 'ensureAwsCredentialAndRegion', + () => null, + ); + + const mockProfileResolver = mock.fn(); + + let amplifySeedDir: string; + let fullPath: string; + + const sandboxIdResolver: SandboxBackendIdResolver = { + resolve: () => Promise.resolve(testBackendId), + } as SandboxBackendIdResolver; + + before(async () => { + const sandboxFactory = new SandboxSingletonFactory( + () => Promise.resolve(testBackendId), + mockProfileResolver, + printer, + format, + ); + + const sandboxSeedCommand = new SandboxSeedCommand(sandboxIdResolver, [ + new SandboxSeedGeneratePolicyCommand(sandboxIdResolver), + ]); + + const sandboxCommand = new SandboxCommand( + sandboxFactory, + [ + new SandboxDeleteCommand(sandboxFactory), + createSandboxSecretCommand(), + sandboxSeedCommand, + ], + clientConfigGeneratorAdapterMock, + commandMiddleware, + () => ({ + successfulDeployment: [clientConfigGenerationMock], + successfulDeletion: [clientConfigDeletionMock], + failedDeployment: [], + }), + ); + const parser = yargs().command(sandboxCommand as unknown as CommandModule); + commandRunner = new TestCommandRunner(parser); + mockHandleProfile.mock.resetCalls(); + }); + + void describe('seed script exists', () => { + before(async () => { + await fsp.mkdir(path.join(process.cwd(), 'amplify', 'seed'), { + recursive: true, + }); + amplifySeedDir = path.join(process.cwd(), 'amplify'); + fullPath = path.join(process.cwd(), 'amplify', 'seed', 'seed.ts'); + await fsp.writeFile(fullPath, seedFileContents, 'utf8'); + }); + + after(async () => { + await fsp.rm(amplifySeedDir, { recursive: true, force: true }); + if (process.env.AMPLIFY_BACKEND_IDENTIFIER) { + delete process.env.AMPLIFY_BACKEND_IDENTIFIER; + } + }); + + void it('runs seed if seed script is found', async () => { + const output = await commandRunner.runCommand('sandbox seed'); + + const successMessage = output.trimEnd().split('\n')[2].trimStart(); + + assert.ok(output !== undefined); + assert.deepStrictEqual( + successMessage, + '✔ seed has successfully completed', + ); + assert.strictEqual(mockHandleProfile.mock.callCount(), 1); + }); + }); + + void describe('seed script does not exist', () => { + before(async () => { + await fsp.mkdir(path.join(process.cwd(), 'amplify', 'seed'), { + recursive: true, + }); + amplifySeedDir = path.join(process.cwd(), 'amplify'); + }); + + after(async () => { + await fsp.rm(amplifySeedDir, { recursive: true, force: true }); + if (process.env.AMPLIFY_BACKEND_IDENTIFIER) { + delete process.env.AMPLIFY_BACKEND_IDENTIFIER; + } + }); + + void it('throws error if seed script does not exist', async () => { + await assert.rejects( + () => commandRunner.runCommand('sandbox seed'), + (err: TestCommandError) => { + assert.match(err.output, /SeedScriptNotFoundError/); + assert.match(err.output, /There is no file that corresponds to/); + assert.match( + err.output, + /Please make a file that corresponds to (.*) and put your seed logic in it/, + ); + return true; + }, + ); + }); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-seed/sandbox_seed_command.ts b/packages/cli/src/commands/sandbox/sandbox-seed/sandbox_seed_command.ts new file mode 100644 index 00000000000..b825cb82fe4 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-seed/sandbox_seed_command.ts @@ -0,0 +1,72 @@ +import { Argv, CommandModule } from 'yargs'; +import path from 'path'; +import { existsSync } from 'fs'; +import { execa } from 'execa'; +import { SandboxBackendIdResolver } from '../sandbox_id_resolver.js'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { SandboxCommandGlobalOptions } from '../option_types.js'; +import { format, printer } from '@aws-amplify/cli-core'; + +/** + * Command that runs seed in sandbox environment + */ +export class SandboxSeedCommand implements CommandModule { + /** + * @inheritDoc + */ + readonly command: string; + + /** + * @inheritDoc + */ + readonly describe: string; + + /** + * Seeds sandbox environment. + */ + constructor( + private readonly backendIDResolver: SandboxBackendIdResolver, + private readonly seedSubCommands: CommandModule[], + ) { + this.command = 'seed'; + this.describe = 'Seeds sandbox environment'; + } + + /** + * @inheritDoc + */ + handler = async (): Promise => { + printer.startSpinner('Running seed...'); + const backendID = await this.backendIDResolver.resolve(); + const seedPath = path.join('amplify', 'seed', 'seed.ts'); + try { + await execa('tsx', [seedPath], { + cwd: process.cwd(), + stdio: 'inherit', + env: { + AMPLIFY_BACKEND_IDENTIFIER: JSON.stringify(backendID), + }, + }); + } finally { + printer.stopSpinner(); + } + printer.printNewLine(); + printer.print(`${format.success('✔')} seed has successfully completed`); + }; + + /** + * @inheritDoc + */ + builder = (yargs: Argv): Argv => { + return yargs.command(this.seedSubCommands).check(() => { + const seedPath = path.join(process.cwd(), 'amplify', 'seed', 'seed.ts'); + if (!existsSync(seedPath)) { + throw new AmplifyUserError('SeedScriptNotFoundError', { + message: `There is no file that corresponds to ${seedPath}`, + resolution: `Please make a file that corresponds to ${seedPath} and put your seed logic in it`, + }); + } + return true; + }); + }; +} diff --git a/packages/cli/src/commands/sandbox/sandbox-seed/sandbox_seed_policy_command.ts b/packages/cli/src/commands/sandbox/sandbox-seed/sandbox_seed_policy_command.ts new file mode 100644 index 00000000000..3af14a01994 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-seed/sandbox_seed_policy_command.ts @@ -0,0 +1,43 @@ +import { Argv, CommandModule } from 'yargs'; +import { printer } from '@aws-amplify/cli-core'; +import { SandboxBackendIdResolver } from '../sandbox_id_resolver.js'; +import { generateSeedPolicyTemplate } from '../../../seed-policy-generation/generate_seed_policy_template.js'; + +/** + * Command that generates policy template with permissions to be able to run seed in sandbox environment + */ +export class SandboxSeedGeneratePolicyCommand implements CommandModule { + /** + * @inheritDoc + */ + readonly command: string; + + /** + * @inheritDoc + */ + readonly describe: string; + + /** + * Generates policy to run seed, is a subcommand of seed + */ + constructor(private readonly backendIdResolver: SandboxBackendIdResolver) { + this.command = 'generate-policy'; + this.describe = 'Generates policy for seeding'; + } + + /** + * @inheritDoc + */ + handler = async (): Promise => { + const backendId = await this.backendIdResolver.resolve(); + const policyDocument = await generateSeedPolicyTemplate(backendId); + printer.print(JSON.stringify(policyDocument.toJSON(), null, 2)); + }; + + /** + * @inheritDoc + */ + builder = (yargs: Argv) => { + return yargs; + }; +} diff --git a/packages/cli/src/commands/sandbox/sandbox_command_factory.ts b/packages/cli/src/commands/sandbox/sandbox_command_factory.ts index e71a70169fb..c0a2565d29e 100644 --- a/packages/cli/src/commands/sandbox/sandbox_command_factory.ts +++ b/packages/cli/src/commands/sandbox/sandbox_command_factory.ts @@ -6,6 +6,7 @@ import { } from './sandbox_command.js'; import { SandboxSingletonFactory } from '@aws-amplify/sandbox'; import { SandboxDeleteCommand } from './sandbox-delete/sandbox_delete_command.js'; +import { SandboxSeedCommand } from './sandbox-seed/sandbox_seed_command.js'; import { SandboxBackendIdResolver } from './sandbox_id_resolver.js'; import { ClientConfigGeneratorAdapter } from '../../client-config/client_config_generator_adapter.js'; import { LocalNamespaceResolver } from '../../backend-identifier/local_namespace_resolver.js'; @@ -25,6 +26,7 @@ import { S3Client } from '@aws-sdk/client-s3'; import { AmplifyClient } from '@aws-sdk/client-amplify'; import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; import { NoticesRenderer } from '../../notices/notices_renderer.js'; +import { SandboxSeedGeneratePolicyCommand } from './sandbox-seed/sandbox_seed_policy_command.js'; import { SDKProfileResolverProvider } from '../../sdk_profile_resolver_provider.js'; /** @@ -78,7 +80,13 @@ export const createSandboxCommand = ( const commandMiddleWare = new CommandMiddleware(printer); return new SandboxCommand( sandboxFactory, - [new SandboxDeleteCommand(sandboxFactory), createSandboxSecretCommand()], + [ + new SandboxDeleteCommand(sandboxFactory), + createSandboxSecretCommand(), + new SandboxSeedCommand(sandboxBackendIdPartsResolver, [ + new SandboxSeedGeneratePolicyCommand(sandboxBackendIdPartsResolver), + ]), + ], clientConfigGeneratorAdapter, commandMiddleWare, eventHandlerFactory.getSandboxEventHandlers, diff --git a/packages/cli/src/seed-policy-generation/generate_seed_policy_template.test.ts b/packages/cli/src/seed-policy-generation/generate_seed_policy_template.test.ts new file mode 100644 index 00000000000..a2d7af8f4ce --- /dev/null +++ b/packages/cli/src/seed-policy-generation/generate_seed_policy_template.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'assert'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { AWSAmplifyBackendOutputs } from '../../../client-config/src/client-config-schema/client_config_v1.3.js'; +import { generateSeedPolicyTemplate } from './generate_seed_policy_template.js'; +import { generateClientConfig } from '@aws-amplify/client-config'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { App, Stack } from 'aws-cdk-lib'; +import { AccountPrincipal, Policy, Role } from 'aws-cdk-lib/aws-iam'; +import { Template } from 'aws-cdk-lib/assertions'; +import { + GetCallerIdentityCommandInput, + GetCallerIdentityCommandOutput, + STSClient, +} from '@aws-sdk/client-sts'; + +const testBackendId = 'testBackendId'; +const testSandboxName = 'testSandboxName'; +const testBackendHash = '12345abcde'; +const testUserpoolId = 'us-east-1_userpoolTest'; +const testUserpoolClient = 'userPoolClientId'; +const testRegion = 'us-east-1'; +const testArn = + 'arn:aws:cognito-idp:us-east-1:123456789012:userpool/us-east-1_userpoolTest'; + +const testBackendIdentifier: BackendIdentifier = { + namespace: testBackendId, + name: testSandboxName, + type: 'sandbox', + hash: testBackendHash, +}; + +void describe('generate inline policy for seed', () => { + const mockConfigGenerator = mock.fn(async () => + Promise.resolve({ + version: '1.3', + auth: { + aws_region: testRegion, + user_pool_id: testUserpoolId, + user_pool_client_id: testUserpoolClient, + }, + } as AWSAmplifyBackendOutputs), + ); + + const mockStsClient = { + send: mock.fn< + ( + input: GetCallerIdentityCommandInput, + ) => Promise + >(async () => + Promise.resolve({ + Account: '123456789012', + Arn: '', + UserId: '', + } as GetCallerIdentityCommandOutput), + ), + }; + + const app = new App(); + const stack = new Stack(app); + + beforeEach(() => { + mockConfigGenerator.mock.resetCalls(); + mockStsClient.send.mock.resetCalls(); + }); + + void it('returns a policy with expected seed permissions', async () => { + const policyDoc = await generateSeedPolicyTemplate( + testBackendIdentifier, + mockConfigGenerator as unknown as typeof generateClientConfig, + mockStsClient as unknown as STSClient, + ); + + const policy = new Policy(stack, 'testSeedPolicy', { document: policyDoc }); + // we have to attach the policy to a role, otherwise CDK erases the policy from the stack + policy.attachToRole( + new Role(stack, 'testRole', { assumedBy: new AccountPrincipal('1234') }), + ); + + assert.ok(policy instanceof Policy); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 'cognito-idp:AdminCreateUser', + 'cognito-idp:AdminAddUserToGroup', + ], + Effect: 'Allow', + Resource: testArn, + }, + { + Action: ['ssm:PutParameter', 'ssm:GetParameter'], + Effect: 'Allow', + Resource: [ + `arn:aws:ssm:*:*:parameter/amplify/${testBackendId}/${testSandboxName}-sandbox-${testBackendHash}/*`, + `arn:aws:ssm:*:*:parameter/amplify/shared/${testBackendId}/*`, + ], + }, + ], + }, + }); + }); + + void it('throws error if there is no userpool attached to sandbox', async () => { + mockConfigGenerator.mock.mockImplementationOnce(async () => + Promise.resolve({ + version: '1.3', + storage: { + aws_region: testRegion, + bucket_name: 'my-cool-bucket', + }, + } as AWSAmplifyBackendOutputs), + ); + + const expectedErr = new AmplifyUserError('MissingAuthError', { + message: 'There is no auth resource in this sandbox', + resolution: + 'Please add an auth resource to your sandbox and rerun this command', + }); + + await assert.rejects( + async () => + generateSeedPolicyTemplate( + testBackendIdentifier, + mockConfigGenerator as unknown as typeof generateClientConfig, + ), + (err: AmplifyUserError) => { + assert.strictEqual(err.name, expectedErr.name); + assert.strictEqual(err.message, expectedErr.message); + assert.strictEqual(err.resolution, expectedErr.resolution); + return true; + }, + ); + }); +}); diff --git a/packages/cli/src/seed-policy-generation/generate_seed_policy_template.ts b/packages/cli/src/seed-policy-generation/generate_seed_policy_template.ts new file mode 100644 index 00000000000..5ce3dc4e2c7 --- /dev/null +++ b/packages/cli/src/seed-policy-generation/generate_seed_policy_template.ts @@ -0,0 +1,58 @@ +import { Effect, PolicyDocument, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { generateClientConfig } from '@aws-amplify/client-config'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { + AmplifyUserError, + ParameterPathConversions, +} from '@aws-amplify/platform-core'; +import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; + +/** + * Generates policy template which allows seed to be run + * @param backendId - backend identifier + * @returns - policy template as a string + */ +export const generateSeedPolicyTemplate = async ( + backendId: BackendIdentifier, + generateClientConfiguration = generateClientConfig, + stsClient = new STSClient(), +): Promise => { + const seedPolicy = new PolicyDocument(); + const clientConfig = await generateClientConfiguration(backendId, '1.3'); + + if (!clientConfig.auth) { + throw new AmplifyUserError('MissingAuthError', { + message: 'There is no auth resource in this sandbox', + resolution: + 'Please add an auth resource to your sandbox and rerun this command', + }); + } + + const stsResponse = await stsClient.send(new GetCallerIdentityCommand({})); + const arn = `arn:aws:cognito-idp:${clientConfig.auth.aws_region}:${stsResponse.Account}:userpool/${clientConfig.auth.user_pool_id}`; + + const cognitoGrant = new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['cognito-idp:AdminCreateUser', 'cognito-idp:AdminAddUserToGroup'], + resources: [arn], + }); + + const backendParamPrefix = + ParameterPathConversions.toParameterPrefix(backendId); + const sharedParamPrefix = ParameterPathConversions.toParameterPrefix( + backendId.namespace, + ); + + const secretsGrant = new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ssm:PutParameter', 'ssm:GetParameter'], + resources: [ + `arn:aws:ssm:*:*:parameter${backendParamPrefix}/*`, + `arn:aws:ssm:*:*:parameter${sharedParamPrefix}/*`, + ], + }); + + seedPolicy.addStatements(cognitoGrant, secretsGrant); + + return seedPolicy; +}; diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index e1bcc5a830a..e8dcc3d210c 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -16,6 +16,7 @@ "@aws-amplify/deployed-backend-client": "^1.5.2", "@aws-amplify/platform-core": "^1.6.5", "@aws-amplify/plugin-types": "^1.8.1", + "@aws-amplify/seed": "^0.1.0", "@aws-sdk/client-accessanalyzer": "^3.750.0", "@aws-sdk/client-amplify": "^3.750.0", "@aws-sdk/client-bedrock-runtime": "3.622.0", diff --git a/packages/integration-tests/src/test-e2e/sandbox/seed_test_project.sandbox.test.ts b/packages/integration-tests/src/test-e2e/sandbox/seed_test_project.sandbox.test.ts new file mode 100644 index 00000000000..011b5b906d2 --- /dev/null +++ b/packages/integration-tests/src/test-e2e/sandbox/seed_test_project.sandbox.test.ts @@ -0,0 +1,4 @@ +import { SeedTestProjectCreator } from '../../test-project-setup/seed_test_project.js'; +import { defineSandboxTest } from './sandbox.test.template.js'; + +defineSandboxTest(new SeedTestProjectCreator()); diff --git a/packages/integration-tests/src/test-project-setup/seed_test_project.ts b/packages/integration-tests/src/test-project-setup/seed_test_project.ts new file mode 100644 index 00000000000..ff0b04608a4 --- /dev/null +++ b/packages/integration-tests/src/test-project-setup/seed_test_project.ts @@ -0,0 +1,229 @@ +import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; +import { TestProjectBase } from './test_project_base.js'; +import { TestProjectCreator } from './test_project_creator.js'; +import { AmplifyClient } from '@aws-sdk/client-amplify'; +import { e2eToolingClientConfig } from '../e2e_tooling_client_config.js'; +import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; +import fsp from 'fs/promises'; +import assert from 'node:assert'; +import { createEmptyAmplifyProject } from './create_empty_amplify_project.js'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { ampxCli } from '../process-controller/process_controller.js'; +import { generateClientConfig } from '@aws-amplify/client-config'; +import { + ApolloClient, + ApolloLink, + HttpLink, + InMemoryCache, + gql, +} from '@apollo/client/core'; +import { AUTH_TYPE, createAuthLink } from 'aws-appsync-auth-link'; +import { AmplifyAuthCredentialsFactory } from '../amplify_auth_credentials_factory.js'; +import { execa, execaSync } from 'execa'; +import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts'; +import { SemVer } from 'semver'; +import crypto from 'node:crypto'; + +// TODO: this is a work around - in theory this should be fixed +// it seems like as of amplify v6 , some of the code only runs in the browser ... +// see https://github.com/aws-amplify/amplify-js/issues/12751 +if (process.versions.node) { + // node >= 20 now exposes crypto by default. This workaround is not needed: https://github.com/nodejs/node/pull/42083 + if (new SemVer(process.versions.node).major < 20) { + // @ts-expect-error altering typing for global to make compiler happy is not worth the effort assuming this is temporary workaround + globalThis.crypto = crypto; + } +} + +/** + * Creates test project for seed + */ +export class SeedTestProjectCreator implements TestProjectCreator { + readonly name = 'seed'; + + /** + * Creates project creator + */ + constructor( + private readonly cfnClient: CloudFormationClient = new CloudFormationClient( + e2eToolingClientConfig, + ), + private readonly amplifyClient: AmplifyClient = new AmplifyClient( + e2eToolingClientConfig, + ), + private readonly cognitoIdentityProviderClient: CognitoIdentityProviderClient = new CognitoIdentityProviderClient( + e2eToolingClientConfig, + ), + private readonly stsClient: STSClient = new STSClient( + e2eToolingClientConfig, + ), + ) {} + + createProject = async (e2eProjectDir: string): Promise => { + const { projectName, projectRoot, projectAmplifyDir } = + await createEmptyAmplifyProject(this.name, e2eProjectDir); + + const project = new SeedTestProject( + projectName, + projectRoot, + projectAmplifyDir, + this.cfnClient, + this.amplifyClient, + this.cognitoIdentityProviderClient, + this.stsClient, + ); + await fsp.cp( + project.sourceProjectAmplifyDirURL, + project.projectAmplifyDirPath, + { + recursive: true, + }, + ); + return project; + }; +} + +class SeedTestProject extends TestProjectBase { + readonly sourceProjectDirPath = '../../src/test-projects/seed-test-project'; + + readonly sourceProjectAmplifyDirSuffix = `${this.sourceProjectDirPath}/amplify`; + + readonly sourceProjectAmplifyDirURL: URL = new URL( + this.sourceProjectAmplifyDirSuffix, + import.meta.url, + ); + + constructor( + name: string, + projectDirPath: string, + projectAmplifyDirPath: string, + cfnClient: CloudFormationClient, + amplifyClient: AmplifyClient, + private readonly cognitoIdentityProviderClient: CognitoIdentityProviderClient, + private readonly stsClient: STSClient, + ) { + super( + name, + projectDirPath, + projectAmplifyDirPath, + cfnClient, + amplifyClient, + ); + } + + override async deploy( + backendIdentifier: BackendIdentifier, + environment?: Record, + ) { + await super.deploy(backendIdentifier, environment); + + const command = execaSync('npx', ['which', 'ampx'], { + cwd: this.projectDirPath, + }).stdout.trim(); + const seedPolicyProcessResult = await execa( + command, + ['sandbox', 'seed', 'generate-policy'], + { + cwd: this.projectDirPath, + env: environment, + }, + ); + + const startingInd = seedPolicyProcessResult.stdout.indexOf('{'); + const cleanedPolicyString = + seedPolicyProcessResult.stdout.slice(startingInd); + + const clientConfig = await generateClientConfig(backendIdentifier, '1.3'); + if (!clientConfig.custom) { + throw new Error('Client config missing custom section'); + } + const seedRoleArn = clientConfig.custom.seedRoleArn as string; + + const seedCredentials = await this.stsClient.send( + new AssumeRoleCommand({ + RoleArn: seedRoleArn, + RoleSessionName: `seedSession`, + Policy: cleanedPolicyString, + PolicyArns: [ + { + arn: 'arn:aws:iam::aws:policy/service-role/AmplifyBackendDeployFullAccess', + }, + ], + }), + ); + + assert.ok(seedCredentials.Credentials); + assert.ok(seedCredentials.Credentials.AccessKeyId); + assert.ok(seedCredentials.Credentials.SessionToken); + assert.ok(seedCredentials.Credentials.SecretAccessKey); + + await ampxCli(['sandbox', 'seed'], this.projectDirPath, { + env: { + AWS_ACCESS_KEY_ID: seedCredentials.Credentials!.AccessKeyId, + AWS_SECRET_ACCESS_KEY: seedCredentials.Credentials!.SecretAccessKey, + AWS_SESSION_TOKEN: seedCredentials.Credentials!.SessionToken, + ...environment, + }, + }).run(); + } + + override async assertPostDeployment( + backendId: BackendIdentifier, + ): Promise { + await super.assertPostDeployment(backendId); + const testUsername = 'testUser@amazon.com'; + const clientConfig = await generateClientConfig(backendId, '1.3'); + + if (!clientConfig.auth) { + throw new Error('Client config missing auth section'); + } + + if (!clientConfig.data) { + throw new Error('Client config missing data section'); + } + + const authenticatedUserCredentials = + await new AmplifyAuthCredentialsFactory( + this.cognitoIdentityProviderClient, + clientConfig.auth, + ).getNewAuthenticatedUserCredentials(); + + const httpLink = new HttpLink({ uri: clientConfig.data.url }); + const link = ApolloLink.from([ + createAuthLink({ + url: clientConfig.data?.url, + region: clientConfig.data?.aws_region, + auth: { + type: AUTH_TYPE.AWS_IAM, + credentials: authenticatedUserCredentials.iamCredentials, + }, + }), + httpLink, + ]); + + const apolloClient = new ApolloClient({ + link: link, + cache: new InMemoryCache(), + }); + + const content = await apolloClient.query({ + query: gql` + query TestQuery { + listTodos { + items { + id + content + owner + } + } + } + `, + }); + + assert.strictEqual( + content.data.listTodos.items[0].content, + `Todo list item for ${testUsername}`, + ); + assert.ok(content.data.listTodos.items[0].owner); + } +} diff --git a/packages/integration-tests/src/test-projects/seed-test-project/amplify/auth/resource.ts b/packages/integration-tests/src/test-projects/seed-test-project/amplify/auth/resource.ts new file mode 100644 index 00000000000..cd2d8595084 --- /dev/null +++ b/packages/integration-tests/src/test-projects/seed-test-project/amplify/auth/resource.ts @@ -0,0 +1,7 @@ +import { defineAuth } from '@aws-amplify/backend'; + +export const auth = defineAuth({ + loginWith: { + email: true, + }, +}); diff --git a/packages/integration-tests/src/test-projects/seed-test-project/amplify/backend.ts b/packages/integration-tests/src/test-projects/seed-test-project/amplify/backend.ts new file mode 100644 index 00000000000..8fece040f29 --- /dev/null +++ b/packages/integration-tests/src/test-projects/seed-test-project/amplify/backend.ts @@ -0,0 +1,35 @@ +import { defineBackend } from '@aws-amplify/backend'; +import { auth } from './auth/resource.js'; +import { data } from './data/resource.js'; +import { AccountPrincipal, ManagedPolicy, Role } from 'aws-cdk-lib/aws-iam'; +import { RemovalPolicy } from 'aws-cdk-lib'; + +/** + * @see https://docs.amplify.aws/react/build-a-backend/ to add storage, functions, and more + */ +const backend = defineBackend({ + auth, + data, +}); + +const seedRoleStack = backend.createStack('seed-policy'); + +/** + * This role has AdminAccess because the policies this role can assume are subset of the policies it initially has + * see: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html#policies_session + */ +const seedRole = new Role(seedRoleStack, 'SeedRole', { + assumedBy: new AccountPrincipal(seedRoleStack.account), + path: '/', + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess'), + ], +}); +seedRole.applyRemovalPolicy(RemovalPolicy.DESTROY); + +backend.addOutput({ + custom: { + seedRoleArn: seedRole.roleArn, + seedRoleName: seedRole.roleName, + }, +}); diff --git a/packages/integration-tests/src/test-projects/seed-test-project/amplify/data/resource.ts b/packages/integration-tests/src/test-projects/seed-test-project/amplify/data/resource.ts new file mode 100644 index 00000000000..c7845bdc6d4 --- /dev/null +++ b/packages/integration-tests/src/test-projects/seed-test-project/amplify/data/resource.ts @@ -0,0 +1,22 @@ +import { type ClientSchema, a, defineData } from '@aws-amplify/backend'; + +const schema = a.schema({ + Todo: a + .model({ + content: a.string(), + }) + .authorization((allow) => [ + allow.guest(), + allow.authenticated('identityPool'), + allow.owner(), + ]), +}); + +export type Schema = ClientSchema; + +export const data = defineData({ + schema, + authorizationModes: { + defaultAuthorizationMode: 'iam', + }, +}); diff --git a/packages/integration-tests/src/test-projects/seed-test-project/amplify/seed/seed.ts b/packages/integration-tests/src/test-projects/seed-test-project/amplify/seed/seed.ts new file mode 100644 index 00000000000..5e5d306457d --- /dev/null +++ b/packages/integration-tests/src/test-projects/seed-test-project/amplify/seed/seed.ts @@ -0,0 +1,64 @@ +import { createAndSignUpUser, signInUser } from '@aws-amplify/seed'; +import * as auth from 'aws-amplify/auth'; +import type { Schema } from './../data/resource.js'; +import { Amplify } from 'aws-amplify'; +import { generateClient } from 'aws-amplify/api'; +import { SemVer } from 'semver'; +import crypto from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFile } from 'node:fs/promises'; + +// TODO: this is a work around - in theory this should be fixed +// it seems like as of amplify v6 , some of the code only runs in the browser ... +// see https://github.com/aws-amplify/amplify-js/issues/12751 +if (process.versions.node) { + // node >= 20 now exposes crypto by default. This workaround is not needed: https://github.com/nodejs/node/pull/42083 + if (new SemVer(process.versions.node).major < 20) { + // @ts-expect-error altering typing for global to make compiler happy is not worth the effort assuming this is temporary workaround + globalThis.crypto = crypto; + } +} + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); +const outputFile = path.normalize( + path.join(dirname, '..', '..', 'amplify_outputs.json'), +); + +const outputs = JSON.parse(await readFile(outputFile, { encoding: 'utf8' })); + +Amplify.configure(outputs); + +const dataClient = generateClient(); + +const username1 = 'testUser@amazon.com'; +const randomSuffix = crypto.randomBytes(4).toString('hex'); +const password1 = `T3st_Passw0rd*${randomSuffix}`; + +const user1 = await createAndSignUpUser({ + username: username1, + password: password1, + signInAfterCreation: false, + signInFlow: 'Password', +}); + +await signInUser({ + username: username1, + password: password1, + signInFlow: user1.signInFlow, +}); + +let response1 = await dataClient.models.Todo.create( + { + content: `Todo list item for ${username1}`, + }, + { + authMode: 'userPool', + }, +); +if (response1.errors && response1.errors.length > 0) { + throw response1.errors; +} + +auth.signOut(); diff --git a/packages/integration-tests/tsconfig.json b/packages/integration-tests/tsconfig.json index 6cd53426695..3ab9f98c0ed 100644 --- a/packages/integration-tests/tsconfig.json +++ b/packages/integration-tests/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../deployed-backend-client" }, { "path": "../platform-core" }, { "path": "../plugin-types" }, + { "path": "../seed" }, { "path": "./src/test-projects/data-storage-auth-with-triggers-ts" } ], "exclude": ["**/node_modules", "**/lib", "src/e2e-tests"] diff --git a/packages/seed/.npmignore b/packages/seed/.npmignore new file mode 100644 index 00000000000..dbde1fb5dbc --- /dev/null +++ b/packages/seed/.npmignore @@ -0,0 +1,14 @@ +# Be very careful editing this file. It is crafted to work around [this issue](https://github.com/npm/npm/issues/4479) + +# First ignore everything +**/* + +# Then add back in transpiled js and ts declaration files +!lib/**/*.js +!lib/**/*.d.ts + +# Then ignore test js and ts declaration files +*.test.js +*.test.d.ts + +# This leaves us with including only js and ts declaration files of functional code diff --git a/packages/seed/API.md b/packages/seed/API.md new file mode 100644 index 00000000000..a19362362bf --- /dev/null +++ b/packages/seed/API.md @@ -0,0 +1,105 @@ +## API Report File for "@aws-amplify/seed" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as auth from 'aws-amplify/auth'; + +// @public +export const addToUserGroup: (user: AuthUserGroupInput, group: string) => Promise; + +// @public (undocumented) +export type AuthOutputs = { + signInFlow: 'Password' | 'MFA'; + username: string; +}; + +// @public (undocumented) +export type AuthSignUp = { + signInAfterCreation: boolean; + username: string; + userAttributes?: StandardUserAttributes; +} & (PasswordSignInFlow | MfaSignUpFlow | MfaWithTotpSignUpFlow); + +// @public (undocumented) +export type AuthUser = { + username: string; +} & (PasswordSignInFlow | MfaSignInFlow); + +// @public (undocumented) +export type AuthUserGroupInput = { + username: string; +}; + +// @public (undocumented) +export type ChallengeResponse = { + challengeResponse: string; +}; + +// @public +export const createAndSignUpUser: (newUser: AuthSignUp) => Promise; + +// @public +export const getSecret: (secretName: string) => Promise; + +// @public (undocumented) +export type MfaSignInFlow = { + signInFlow: 'MFA'; + password: string; + signInChallenge?: () => Promise; +}; + +// @public (undocumented) +export type MfaSignUpFlow = { + signInFlow: 'MFA'; + password: string; + mfaPreference?: 'EMAIL' | 'SMS' | 'TOTP'; + emailSignUpChallenge?: () => Promise; + smsSignUpChallenge?: () => Promise; + totpSignUpChallenge?: (totpSetup: auth.SetUpTOTPOutput) => Promise; +}; + +// @public (undocumented) +export type MfaWithTotpSignUpFlow = { + mfaPreference?: 'TOTP'; + totpSignUpChallenge: (totpSetup: auth.SetUpTOTPOutput) => Promise; +} & MfaSignUpFlow; + +// @public (undocumented) +export type PasswordSignInFlow = { + signInFlow: 'Password'; + password: string; +}; + +// @public +export const setSecret: (secretName: string, secretValue: string) => Promise; + +// @public +export const signInUser: (user: AuthUser) => Promise; + +// @public (undocumented) +export type StandardUserAttributes = { + name?: string; + familyName?: string; + givenName?: string; + middleName?: string; + nickname?: string; + preferredUsername?: string; + profile?: string; + picture?: string; + website?: string; + gender?: string; + birthdate?: string; + zoneinfo?: string; + locale?: string; + updatedAt?: string; + address?: string; + email?: string; + phoneNumber?: string; + sub?: string; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/seed/README.md b/packages/seed/README.md new file mode 100644 index 00000000000..793417be040 --- /dev/null +++ b/packages/seed/README.md @@ -0,0 +1,3 @@ +# Description + +Replace with a description of this package diff --git a/packages/seed/api-extractor.json b/packages/seed/api-extractor.json new file mode 100644 index 00000000000..0f56de03f66 --- /dev/null +++ b/packages/seed/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.base.json" +} diff --git a/packages/seed/package.json b/packages/seed/package.json new file mode 100644 index 00000000000..4cf2b5aa194 --- /dev/null +++ b/packages/seed/package.json @@ -0,0 +1,30 @@ +{ + "name": "@aws-amplify/seed", + "version": "0.1.0", + "type": "module", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js", + "require": "./lib/index.js" + } + }, + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "update:api": "api-extractor run --local" + }, + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/backend-secret": "^1.1.5", + "@aws-amplify/cli-core": "^1.2.3", + "@aws-amplify/client-config": "^1.5.5", + "@aws-amplify/platform-core": "^1.6.0", + "@aws-amplify/plugin-types": "^1.8.0", + "@aws-sdk/client-cognito-identity-provider": "^3.750.0", + "aws-amplify": "^6.0.16" + } +} diff --git a/packages/seed/src/auth-seed/auth_api.ts b/packages/seed/src/auth-seed/auth_api.ts new file mode 100644 index 00000000000..134018c368c --- /dev/null +++ b/packages/seed/src/auth-seed/auth_api.ts @@ -0,0 +1,48 @@ +import { AuthClient } from './auth_client.js'; +import { + AuthOutputs, + AuthSignUp, + AuthUser, + AuthUserGroupInput, +} from '../types.js'; +import { ConfigReader } from './config_reader.js'; + +const authClient = new AuthClient(new ConfigReader()); + +/** + * Creates and signs up a new user + * This API cannot be called concurrently with itself, other seed APIs, or APIs from AmplifyJS Auth. + * You must ensure this this API is synchronized and you wait for it to complete before using other seed or Amplify Auth APIs. + * @param newUser - contains properties required to create new user + * @returns - Username and Sign up flow used by the new user + */ +export const createAndSignUpUser = async ( + newUser: AuthSignUp, +): Promise => { + return await authClient.createAndSignUpUser(newUser); +}; + +/** + * Adds a user to a group + * @param user - user to add to a group + * @param group - group to add the user to + * @returns - Username and Sign up flow used by this user + */ +export const addToUserGroup = async ( + user: AuthUserGroupInput, + group: string, +): Promise => { + return await authClient.addToUserGroup(user, group); +}; + +/** + * Signs in a user + * This API cannot be called concurrently with itself, other seed APIs, or APIs from AmplifyJS Auth. + * You must ensure this this API is synchronized and you wait for it to complete before using other seed or Amplify Auth APIs. + * You must ensure any calls to data and storage that you make happen between signInUser and auth.signOut calls. + * @param user - user to sign in + * @returns - true if user was successfully signed in, false otherwise + */ +export const signInUser = async (user: AuthUser): Promise => { + return await authClient.signInUser(user); +}; diff --git a/packages/seed/src/auth-seed/auth_client.test.ts b/packages/seed/src/auth-seed/auth_client.test.ts new file mode 100644 index 00000000000..7a490add5dd --- /dev/null +++ b/packages/seed/src/auth-seed/auth_client.test.ts @@ -0,0 +1,398 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import { AuthClient } from './auth_client.js'; +import { AuthConfiguration, ConfigReader } from './config_reader.js'; +import assert from 'assert'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { + AdminAddUserToGroupCommand, + AdminCreateUserCommand, + AdminCreateUserResponse, + CognitoIdentityProviderClient, + NotAuthorizedException, + UserNotFoundException, + UsernameExistsException, +} from '@aws-sdk/client-cognito-identity-provider'; +import * as auth from 'aws-amplify/auth'; +import { AuthOutputs, AuthSignUp, AuthUser } from '../types.js'; +import { MfaFlow } from './mfa_flow.js'; +import { PersistentPasswordFlow } from './persistent_password_flow.js'; + +const testUsername = 'testUser1@test.com'; +const testPassword = 'T3st_Password*'; +const testGroup0 = 'TESTGROUP'; +const testGroup1 = 'OTHERGROUP'; + +const testUserpoolId = 'us-east-1_userpoolTest'; + +void describe('seeding auth APIs', () => { + void describe('adding to user group', () => { + void describe('no groups exist on userpool', () => { + const mockConfigReader = { + getAuthConfig: mock.fn<() => Promise>(async () => + Promise.resolve({ + userPoolId: testUserpoolId, + }), + ), + }; + + const authClient = new AuthClient( + mockConfigReader as unknown as ConfigReader, + ); + + void it('throws error if no groups exist', async () => { + const expectedErr = new AmplifyUserError('NoGroupsError', { + message: `There are no groups in this userpool.`, + resolution: `Create a group called ${testGroup0}.`, + }); + + await assert.rejects( + async () => + await authClient.addToUserGroup( + { username: testUsername }, + testGroup0, + ), + expectedErr, + ); + }); + }); + + void describe('userpool has groups defined', () => { + const mockConfigReader = { + getAuthConfig: mock.fn<() => Promise>(async () => + Promise.resolve({ + userPoolId: testUserpoolId, + groups: [testGroup0], + }), + ), + }; + + const mockCognitoIdProviderClient = { + send: mock.fn<(input: AdminAddUserToGroupCommand) => Promise>( + async () => Promise.resolve(), + ), + }; + + const authClient = new AuthClient( + mockConfigReader as unknown as ConfigReader, + mockCognitoIdProviderClient as unknown as CognitoIdentityProviderClient, + ); + + beforeEach(() => { + mockCognitoIdProviderClient.send.mock.resetCalls(); + mockConfigReader.getAuthConfig.mock.resetCalls(); + }); + + void it('adds user to an existing user group', async () => { + await authClient.addToUserGroup({ username: testUsername }, testGroup0); + + assert.strictEqual( + mockCognitoIdProviderClient.send.mock.callCount(), + 1, + ); + assert.strictEqual( + mockCognitoIdProviderClient.send.mock.calls[0].error, + undefined, + ); + }); + + void it('throws error if user does not exist', async () => { + const expectedErr = new AmplifyUserError('UserNotFoundError', { + message: `The user, ${testUsername}, does not exist`, + resolution: `Create a user called ${testUsername} or try again with a different user`, + }); + + mockCognitoIdProviderClient.send.mock.mockImplementationOnce(() => + Promise.reject( + new UserNotFoundException({ + $metadata: {}, + message: 'could not find user', + }), + ), + ); + + await assert.rejects( + async () => + await authClient.addToUserGroup( + { username: testUsername }, + testGroup0, + ), + (error: AmplifyUserError) => { + assert.strictEqual(error.name, expectedErr.name); + assert.strictEqual(error.message, expectedErr.message); + assert.strictEqual(error.resolution, expectedErr.resolution); + return true; + }, + ); + }); + + void it('throws error if group does not exist on userpool', async () => { + const expectedErr = new AmplifyUserError('NoGroupError', { + message: `There is no group called ${testGroup1} in this userpool.`, + resolution: `Either create a group called ${testGroup1} or assign this user to a group that exists on this userpool.`, + }); + + await assert.rejects( + async () => + await authClient.addToUserGroup( + { username: testUsername }, + testGroup1, + ), + expectedErr, + ); + }); + }); + }); + + // encompasses tests that apply to all createAndSignUpUser flows + void describe('userpool configured without MFA', () => { + const mockConfigReader = { + getAuthConfig: mock.fn<() => Promise>(async () => + Promise.resolve({ + userPoolId: testUserpoolId, + }), + ), + }; + + const mockCognitoIdProviderClient = { + send: mock.fn< + (input: AdminCreateUserCommand) => Promise + >(async () => + Promise.resolve({ + User: { + Username: testUsername, + }, + }), + ), + }; + + const mockAuthAPIs = { + signOut: mock.fn<() => Promise>(async () => Promise.resolve()), + }; + + const mockPasswordFlow = { + persistentPasswordSignUp: mock.fn< + (input: AuthSignUp) => Promise + >(async () => Promise.resolve(true)), + persistentPasswordSignIn: mock.fn<(input: AuthUser) => Promise>( + async () => Promise.resolve(true), + ), + }; + + const authClient = new AuthClient( + mockConfigReader as unknown as ConfigReader, + mockCognitoIdProviderClient as unknown as CognitoIdentityProviderClient, + mockAuthAPIs as unknown as typeof auth, + mockPasswordFlow as unknown as PersistentPasswordFlow, + ); + + beforeEach(() => { + mockCognitoIdProviderClient.send.mock.resetCalls(); + mockConfigReader.getAuthConfig.mock.resetCalls(); + mockAuthAPIs.signOut.mock.resetCalls(); + mockPasswordFlow.persistentPasswordSignIn.mock.resetCalls(); + mockPasswordFlow.persistentPasswordSignUp.mock.resetCalls(); + }); + + void it('creates and signs up user', async () => { + const output = await authClient.createAndSignUpUser({ + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'Password', + }); + + assert.strictEqual( + mockPasswordFlow.persistentPasswordSignUp.mock.callCount(), + 1, + ); + assert.strictEqual(mockCognitoIdProviderClient.send.mock.callCount(), 1); + assert.deepStrictEqual(output, { + signInFlow: 'Password', + username: testUsername, + } as AuthOutputs); + }); + + void it('throws error if attempting to create user that already exists', async () => { + const expectedErr = new AmplifyUserError('UsernameExistsError', { + message: `A user called ${testUsername} already exists.`, + resolution: 'Give this user a different name', + }); + + mockCognitoIdProviderClient.send.mock.mockImplementationOnce(() => { + throw new UsernameExistsException({ + $metadata: {}, + message: 'Username already exists', + }); + }); + + await assert.rejects( + async () => + authClient.createAndSignUpUser({ + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'Password', + }), + (err: AmplifyUserError) => { + assert.strictEqual(err.name, expectedErr.name); + assert.strictEqual(err.message, expectedErr.message); + assert.strictEqual(err.resolution, expectedErr.resolution); + return true; + }, + ); + }); + + void it('throws error if attempting to create user without proper permissions', async () => { + const expectedErr = new AmplifyUserError('NotAuthorizedError', { + message: 'You are not authorized to create a user', + resolution: + 'Run npx ampx sandbox seed generate-policy, attach the outputted policy template to a role with AmplifyBackendDeployFullAccess, assume that role to run seed', + }); + + mockCognitoIdProviderClient.send.mock.mockImplementationOnce(() => { + throw new NotAuthorizedException({ + $metadata: {}, + message: 'Not authorized to create users', + }); + }); + + await assert.rejects( + async () => + authClient.createAndSignUpUser({ + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'Password', + }), + (err: AmplifyUserError) => { + assert.strictEqual(err.name, expectedErr.name); + assert.strictEqual(err.message, expectedErr.message); + assert.strictEqual(err.resolution, expectedErr.resolution); + return true; + }, + ); + }); + + void it('throws error if attempting to create user with MFA when MFA is not configured', async () => { + const expectedErr = new AmplifyUserError('MFANotConfiguredError', { + message: `MFA is not configured for this userpool, you cannot create ${testUsername} with MFA.`, + resolution: `Enable MFA for this userpool or create ${testUsername} with a different sign up flow.`, + }); + + await assert.rejects( + async () => + authClient.createAndSignUpUser({ + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'MFA', + }), + (err: AmplifyUserError) => { + assert.strictEqual(err.name, expectedErr.name); + assert.strictEqual(err.message, expectedErr.message); + assert.strictEqual(err.resolution, expectedErr.resolution); + return true; + }, + ); + }); + + void it('signs in user with persistent password', async () => { + const output = await authClient.signInUser({ + username: testUsername, + password: testPassword, + signInFlow: 'Password', + }); + + assert.strictEqual( + mockPasswordFlow.persistentPasswordSignIn.mock.callCount(), + 1, + ); + assert.strictEqual(output, true); + }); + }); + + void describe('userpool configured with MFA', () => { + const testNumber = '+11234567890'; + + const mockConfigReader = { + getAuthConfig: mock.fn<() => Promise>(async () => + Promise.resolve({ + userPoolId: testUserpoolId, + mfaMethods: ['SMS', 'TOTP'], + mfaConfig: 'REQUIRED', + }), + ), + }; + + const mockCognitoIdProviderClient = { + send: mock.fn< + (input: AdminCreateUserCommand) => Promise + >(async () => + Promise.resolve({ + User: { + Username: testUsername, + }, + }), + ), + }; + + const mockAuthAPIs = { + signOut: mock.fn<() => Promise>(async () => Promise.resolve()), + }; + + const mockMfaFlow = { + mfaSignUp: mock.fn< + (user: AuthSignUp, tempPassword: string) => Promise + >(async () => Promise.resolve(true)), + mfaSignIn: mock.fn<(user: AuthUser) => Promise>(async () => + Promise.resolve(true), + ), + }; + + const authClient = new AuthClient( + mockConfigReader as unknown as ConfigReader, + mockCognitoIdProviderClient as unknown as CognitoIdentityProviderClient, + mockAuthAPIs as unknown as typeof auth, + undefined, + mockMfaFlow as unknown as MfaFlow, + ); + + beforeEach(() => { + mockCognitoIdProviderClient.send.mock.resetCalls(); + mockConfigReader.getAuthConfig.mock.resetCalls(); + mockAuthAPIs.signOut.mock.resetCalls(); + mockMfaFlow.mfaSignIn.mock.resetCalls(); + mockMfaFlow.mfaSignUp.mock.resetCalls(); + }); + + void it('creates a user with MFA', async () => { + const output = await authClient.createAndSignUpUser({ + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'MFA', + userAttributes: { + phoneNumber: testNumber, + }, + }); + + assert.strictEqual(mockMfaFlow.mfaSignUp.mock.callCount(), 1); + assert.strictEqual(mockCognitoIdProviderClient.send.mock.callCount(), 1); + assert.deepStrictEqual(output, { + signInFlow: 'MFA', + username: testUsername, + } as AuthOutputs); + }); + + void it('signs in a user with MFA', async () => { + const output = await authClient.signInUser({ + username: testUsername, + password: testPassword, + signInFlow: 'MFA', + }); + + assert.strictEqual(mockMfaFlow.mfaSignIn.mock.callCount(), 1); + assert.strictEqual(output, true); + }); + }); +}); diff --git a/packages/seed/src/auth-seed/auth_client.ts b/packages/seed/src/auth-seed/auth_client.ts new file mode 100644 index 00000000000..2dc03b3fbad --- /dev/null +++ b/packages/seed/src/auth-seed/auth_client.ts @@ -0,0 +1,173 @@ +import { + AuthOutputs, + AuthSignUp, + AuthUser, + AuthUserGroupInput, +} from '../types.js'; +import { + AdminAddUserToGroupCommand, + AdminCreateUserCommand, + CognitoIdentityProviderClient, +} from '@aws-sdk/client-cognito-identity-provider'; +import * as auth from 'aws-amplify/auth'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { PersistentPasswordFlow } from './persistent_password_flow.js'; +import { MfaFlow } from './mfa_flow.js'; +import { randomUUID } from 'node:crypto'; +import { ConfigReader } from './config_reader.js'; + +/** + * + */ +export class AuthClient { + private readonly authOutputs; + + /** + * Set up for auth APIs + */ + constructor( + configOutputs: ConfigReader, + private readonly cognitoIdentityProviderClient: CognitoIdentityProviderClient = new CognitoIdentityProviderClient(), + private readonly authApi = auth, + private readonly persistentPasswordFlow = new PersistentPasswordFlow(), + private readonly mfaFlow = new MfaFlow(), + ) { + this.authOutputs = configOutputs.getAuthConfig(); + } + + createAndSignUpUser = async (newUser: AuthSignUp): Promise => { + try { + const authConfig = await this.authOutputs; + await this.authApi.signOut(); + + const tempPassword = `Test1@Temp${randomUUID().toString()}`; + // in the future will need to check that the preferredSignInFlow is not passwordless + try { + await this.cognitoIdentityProviderClient.send( + new AdminCreateUserCommand({ + Username: newUser.username, + TemporaryPassword: tempPassword, + UserPoolId: authConfig.userPoolId, + MessageAction: 'SUPPRESS', + }), + ); + } catch (err) { + const error = err as Error; + if (error.name === 'UsernameExistsException') { + throw new AmplifyUserError( + 'UsernameExistsError', + { + message: `A user called ${newUser.username} already exists.`, + resolution: 'Give this user a different name', + }, + error, + ); + } else if (error.name === 'NotAuthorizedException') { + throw new AmplifyUserError( + 'NotAuthorizedError', + { + message: 'You are not authorized to create a user', + resolution: + 'Run npx ampx sandbox seed generate-policy, attach the outputted policy template to a role with AmplifyBackendDeployFullAccess, assume that role to run seed', + }, + error, + ); + } else { + throw err; + } + } + + switch (newUser.signInFlow) { + case 'Password': { + await this.persistentPasswordFlow.persistentPasswordSignUp( + newUser, + tempPassword, + ); + break; + } + case 'MFA': { + if ( + !authConfig.mfaConfig || + (authConfig.mfaConfig && authConfig.mfaConfig === 'NONE') + ) { + throw new AmplifyUserError('MFANotConfiguredError', { + message: `MFA is not configured for this userpool, you cannot create ${newUser.username} with MFA.`, + resolution: `Enable MFA for this userpool or create ${newUser.username} with a different sign up flow.`, + }); + } + + await this.mfaFlow.mfaSignUp(newUser, tempPassword); + break; + } + } + + return { + signInFlow: newUser.signInFlow, + username: newUser.username, + }; + } finally { + if (!newUser.signInAfterCreation) { + await this.authApi.signOut(); + } + } + }; + + addToUserGroup = async ( + user: AuthUserGroupInput, + group: string, + ): Promise => { + const authConfig = await this.authOutputs; + if (!authConfig.groups) { + throw new AmplifyUserError('NoGroupsError', { + message: `There are no groups in this userpool.`, + resolution: `Create a group called ${group}.`, + }); + } else { + if (authConfig.groups?.includes(group)) { + try { + await this.cognitoIdentityProviderClient.send( + new AdminAddUserToGroupCommand({ + UserPoolId: authConfig.userPoolId, + Username: user.username, + GroupName: group, + }), + ); + } catch (err) { + const error = err as Error; + if (error.name === 'UserNotFoundException') { + throw new AmplifyUserError( + 'UserNotFoundError', + { + message: `The user, ${user.username}, does not exist`, + resolution: `Create a user called ${user.username} or try again with a different user`, + }, + error, + ); + } else { + throw error; + } + } + } else { + throw new AmplifyUserError('NoGroupError', { + message: `There is no group called ${group} in this userpool.`, + resolution: `Either create a group called ${group} or assign this user to a group that exists on this userpool.`, + }); + } + } + }; + + signInUser = async (user: AuthUser): Promise => { + await this.authApi.signOut(); + switch (user.signInFlow) { + case 'Password': { + const result = + this.persistentPasswordFlow.persistentPasswordSignIn(user); + return result; + } + case 'MFA': { + const result = await this.mfaFlow.mfaSignIn(user); + return result; + } + } + }; +} diff --git a/packages/seed/src/auth-seed/config_reader.test.ts b/packages/seed/src/auth-seed/config_reader.test.ts new file mode 100644 index 00000000000..26ac6a2d400 --- /dev/null +++ b/packages/seed/src/auth-seed/config_reader.test.ts @@ -0,0 +1,107 @@ +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; +import assert from 'node:assert'; +import { ConfigReader } from './config_reader.js'; +import { generateClientConfig } from '@aws-amplify/client-config'; +import { AWSAmplifyBackendOutputs } from '../../../client-config/src/client-config-schema/client_config_v1.3.js'; + +const testBackendId = 'testBackendId'; +const testSandboxName = 'testSandboxName'; + +const testBackendIdentifier: BackendIdentifier = { + namespace: testBackendId, + name: testSandboxName, + type: 'sandbox', +}; + +const testUserpoolId = 'us-east-1_userpoolTest'; +const testUserpoolClient = 'userPoolClientId'; +const testRegion = 'us-east-1'; +const testMfaMethods = ['SMS', 'TOTP']; +const testMfaConfig = 'OPTIONAL'; +const testGroups = ['ADMIN']; + +void describe('reading client configuration', () => { + void describe('backendId exists', () => { + const mockConfigGenerator = mock.fn(async () => + Promise.resolve({ + version: '1.3', + storage: { + aws_region: testRegion, + bucket_name: 'my-cool-bucket', + }, + } as AWSAmplifyBackendOutputs), + ); + + const configReader = new ConfigReader( + mockConfigGenerator as unknown as typeof generateClientConfig, + ); + + beforeEach(() => { + process.env.AMPLIFY_BACKEND_IDENTIFIER = JSON.stringify( + testBackendIdentifier, + ); + mockConfigGenerator.mock.resetCalls(); + }); + + afterEach(() => { + delete process.env.AMPLIFY_BACKEND_IDENTIFIER; + }); + + void it('successfully reads client config if auth exists', async () => { + mockConfigGenerator.mock.mockImplementationOnce(async () => + Promise.resolve({ + version: '1.3', + auth: { + aws_region: testRegion, + user_pool_id: testUserpoolId, + user_pool_client_id: testUserpoolClient, + mfa_methods: testMfaMethods, + mfa_configuration: testMfaConfig, + groups: [{ ADMIN: { precedence: 1 } }], + }, + } as AWSAmplifyBackendOutputs), + ); + + const output = await configReader.getAuthConfig(); + + assert.strictEqual(output.userPoolId, testUserpoolId); + assert.strictEqual(output.mfaMethods, testMfaMethods); + assert.strictEqual(output.mfaConfig, testMfaConfig); + assert.deepStrictEqual(output.groups, testGroups); + }); + + void it('throws error if auth construct does not exist', async () => { + const expectedErr = new AmplifyUserError('MissingAuthError', { + message: + 'Outputs for Auth are missing, you may be missing an Auth resource', + resolution: + 'Create an Auth resource for your Amplify App or run ampx sandbox if you have generated your sandbox', + }); + + await assert.rejects( + async () => await configReader.getAuthConfig(), + expectedErr, + ); + }); + }); + void describe('backendId does not exist', () => { + void it('throws error if no backendId is provided', async () => { + const expectedErr = new AmplifyUserError( + 'SandboxIdentifierNotFoundError', + { + message: 'Sandbox Identifier is undefined', + resolution: + 'Run ampx sandbox before re-running ampx sandbox seed. If you are running the seed script directly through tsx seed.ts, try running it with ampx sandbox seed instead', + }, + ); + const configReader = new ConfigReader(); + + await assert.rejects( + async () => await configReader.getAuthConfig(), + expectedErr, + ); + }); + }); +}); diff --git a/packages/seed/src/auth-seed/config_reader.ts b/packages/seed/src/auth-seed/config_reader.ts new file mode 100644 index 00000000000..21200d21f87 --- /dev/null +++ b/packages/seed/src/auth-seed/config_reader.ts @@ -0,0 +1,65 @@ +import { generateClientConfig } from '@aws-amplify/client-config'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; + +export type AuthConfiguration = { + userPoolId: string; + mfaMethods?: ('SMS' | 'TOTP')[]; + mfaConfig?: 'NONE' | 'REQUIRED' | 'OPTIONAL'; + groups?: string[]; +}; + +/** + * Handles generating and reading from ClientConfig + */ +export class ConfigReader { + /** + * Constructor + */ + constructor( + private readonly generateClientConfiguration = generateClientConfig, + ) {} + + getAuthConfig = async () => { + if (!process.env.AMPLIFY_BACKEND_IDENTIFIER) { + throw new AmplifyUserError('SandboxIdentifierNotFoundError', { + message: 'Sandbox Identifier is undefined', + resolution: + 'Run ampx sandbox before re-running ampx sandbox seed. If you are running the seed script directly through tsx seed.ts, try running it with ampx sandbox seed instead', + }); + } + + const backendId = JSON.parse(process.env.AMPLIFY_BACKEND_IDENTIFIER); + + const authConfig = ( + await this.generateClientConfiguration(backendId, '1.3') + ).auth; + if (!authConfig) { + throw new AmplifyUserError('MissingAuthError', { + message: + 'Outputs for Auth are missing, you may be missing an Auth resource', + resolution: + 'Create an Auth resource for your Amplify App or run ampx sandbox if you have generated your sandbox', + }); + } + let userGroups: string[] | undefined = []; + + if (authConfig.groups) { + for (const group of authConfig.groups.values()) { + for (const k in group) { + userGroups.push(k); + } + } + } else { + userGroups = undefined; + } + + const configuration: AuthConfiguration = { + userPoolId: authConfig.user_pool_id, + mfaConfig: authConfig.mfa_configuration, + mfaMethods: authConfig.mfa_methods, + groups: userGroups, + }; + + return configuration; + }; +} diff --git a/packages/seed/src/auth-seed/mfa_flow.test.ts b/packages/seed/src/auth-seed/mfa_flow.test.ts new file mode 100644 index 00000000000..869b5302623 --- /dev/null +++ b/packages/seed/src/auth-seed/mfa_flow.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import * as auth from 'aws-amplify/auth'; +import { MfaFlow } from './mfa_flow.js'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import assert from 'assert'; +import { UserNotFoundException } from '@aws-sdk/client-cognito-identity-provider'; +import { AmplifyPrompter } from '@aws-amplify/cli-core'; + +const testUsername = 'testUser1@test.com'; +const testPassword = 'T3st_Password*'; +const testTempPassword = 'Test1@Temp123'; +const challengeResponse = '012345'; + +void describe('mfa flow tests', () => { + const mockAuthAPIs = { + signIn: mock.fn<(input: auth.SignInInput) => Promise>( + async () => + Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED', + }, + } as auth.SignInOutput), + ), + confirmSignIn: mock.fn< + (input: auth.ConfirmSignInInput) => Promise + >(async () => + Promise.resolve({ + isSignedIn: true, + nextStep: { signInStep: 'DONE' }, + } as auth.SignInOutput), + ), + updateMFAPreference: mock.fn< + (input: auth.UpdateMFAPreferenceInput) => Promise + >(async () => Promise.resolve()), + }; + + const mockPrompter = { + secretValue: mock.fn<(message: string) => Promise>(async () => + Promise.resolve(challengeResponse), + ), + }; + + const mfaFlow = new MfaFlow( + mockAuthAPIs as unknown as typeof auth, + mockPrompter as unknown as typeof AmplifyPrompter, + ); + + beforeEach(() => { + mockAuthAPIs.confirmSignIn.mock.resetCalls(); + mockAuthAPIs.signIn.mock.resetCalls(); + mockPrompter.secretValue.mock.resetCalls(); + }); + + void describe('sign up user with mfa', () => { + void it('signs up user with prompter if no challenge function provided', async () => { + mockAuthAPIs.confirmSignIn.mock.mockImplementationOnce( + (input: auth.ConfirmSignInInput) => { + if (input.challengeResponse === '012345') { + return Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'DONE', + }, + } as auth.ConfirmSignInOutput); + } + return Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_SMS_CODE', + }, + } as auth.ConfirmSignInOutput); + }, + ); + + await mfaFlow.mfaSignUp( + { + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'MFA', + }, + testTempPassword, + ); + + assert.strictEqual(mockPrompter.secretValue.mock.callCount(), 1); + assert.strictEqual(mockAuthAPIs.signIn.mock.callCount(), 1); + assert.strictEqual(mockAuthAPIs.confirmSignIn.mock.callCount(), 2); + }); + + void it('signs up user with challenge function if it is provided', async () => { + mockAuthAPIs.confirmSignIn.mock.mockImplementationOnce( + (input: auth.ConfirmSignInInput) => { + if (input.challengeResponse === '012345') { + return Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'DONE', + }, + } as auth.ConfirmSignInOutput); + } + return Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_SMS_CODE', + }, + } as auth.ConfirmSignInOutput); + }, + ); + + await mfaFlow.mfaSignUp( + { + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'MFA', + mfaPreference: 'SMS', + smsSignUpChallenge: async () => + Promise.resolve({ challengeResponse: challengeResponse }), + }, + testTempPassword, + ); + + assert.strictEqual(mockPrompter.secretValue.mock.callCount(), 0); + assert.strictEqual(mockAuthAPIs.signIn.mock.callCount(), 1); + assert.strictEqual(mockAuthAPIs.confirmSignIn.mock.callCount(), 2); + }); + + void it('sign up user with TOTP', async () => { + mockAuthAPIs.confirmSignIn.mock.mockImplementationOnce( + (input: auth.ConfirmSignInInput) => { + if (input.challengeResponse === '012345') { + return Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'DONE', + }, + } as auth.ConfirmSignInOutput); + } + return Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP', + }, + } as auth.ConfirmSignInOutput); + }, + ); + + await mfaFlow.mfaSignUp( + { + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'MFA', + totpSignUpChallenge: async () => + Promise.resolve({ challengeResponse: challengeResponse }), + }, + testTempPassword, + ); + + assert.strictEqual(mockPrompter.secretValue.mock.callCount(), 0); + assert.strictEqual(mockAuthAPIs.signIn.mock.callCount(), 1); + assert.strictEqual(mockAuthAPIs.confirmSignIn.mock.callCount(), 2); + }); + + void it('throws error if selected form of MFA is not available on userpool', async () => { + mockAuthAPIs.confirmSignIn.mock.mockImplementationOnce(() => + Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_MFA_SELECTION', + allowedMFATypes: ['EMAIL', 'TOTP'], + }, + } as auth.ConfirmSignInOutput), + ); + + const mfaPreference = 'SMS'; + const expectedErr = new AmplifyUserError( + 'MFAPreferenceNotAvailableError', + { + message: `${mfaPreference} is not available for this userpool`, + resolution: `Activate ${mfaPreference} for this userpool or sign in ${testUsername} with a different form of MFA`, + }, + ); + + await assert.rejects( + async () => + await mfaFlow.mfaSignUp( + { + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'MFA', + mfaPreference: mfaPreference, + }, + testTempPassword, + ), + (err: AmplifyUserError) => { + assert.strictEqual(err.name, expectedErr.name); + assert.strictEqual(err.message, expectedErr.message); + assert.strictEqual(err.resolution, expectedErr.resolution); + return true; + }, + ); + }); + + void it('throws error if multiple forms of MFA are enabled but none are specified', async () => { + mockAuthAPIs.confirmSignIn.mock.mockImplementationOnce(() => + Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_MFA_SELECTION', + allowedMFATypes: ['EMAIL', 'TOTP'], + }, + } as auth.ConfirmSignInOutput), + ); + + const expectedErr = new AmplifyUserError( + 'NoMFAPreferenceSpecifiedError', + { + message: `If multiple forms of MFA are enabled for a userpool, you must specify which form you intend to use for ${testUsername}`, + resolution: `Specify a form of MFA for the user, ${testUsername}, to use with the mfaPreference property`, + }, + ); + + await assert.rejects( + async () => + await mfaFlow.mfaSignUp( + { + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'MFA', + }, + testTempPassword, + ), + (err: AmplifyUserError) => { + assert.strictEqual(err.name, expectedErr.name); + assert.strictEqual(err.message, expectedErr.message); + assert.strictEqual(err.resolution, expectedErr.resolution); + return true; + }, + ); + }); + }); + + void describe('sign in user with mfa', () => { + void it('signs in user with prompter if no challenge function is provided', async () => { + mockAuthAPIs.signIn.mock.mockImplementationOnce(async () => + Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE', + }, + } as auth.SignInOutput), + ); + + await mfaFlow.mfaSignIn({ + username: testUsername, + password: testPassword, + signInFlow: 'MFA', + }); + + assert.strictEqual(mockPrompter.secretValue.mock.callCount(), 1); + assert.strictEqual(mockAuthAPIs.signIn.mock.callCount(), 1); + assert.strictEqual(mockAuthAPIs.confirmSignIn.mock.callCount(), 1); + }); + + void it('signs in user with challenge function if it is provided', async () => { + mockAuthAPIs.signIn.mock.mockImplementationOnce(async () => + Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE', + }, + } as auth.SignInOutput), + ); + + await mfaFlow.mfaSignIn({ + signInChallenge: async () => + Promise.resolve({ challengeResponse: challengeResponse }), + username: testUsername, + password: testPassword, + signInFlow: 'MFA', + }); + + assert.strictEqual(mockPrompter.secretValue.mock.callCount(), 0); + assert.strictEqual(mockAuthAPIs.signIn.mock.callCount(), 1); + assert.strictEqual(mockAuthAPIs.confirmSignIn.mock.callCount(), 1); + }); + + void it('throws error if user created with persistent password attempts MFA sign in flow', async () => { + const expectedErr = new AmplifyUserError('CannotSignInWithMFAError', { + message: `${testUsername} cannot be signed in with MFA`, + resolution: `Ensure that ${testUsername} exists and that MFA is set to REQUIRED.`, + }); + + await assert.rejects( + async () => + mfaFlow.mfaSignIn({ + username: testUsername, + password: testPassword, + signInFlow: 'MFA', + }), + (err: AmplifyUserError) => { + assert.strictEqual(err.name, expectedErr.name); + assert.strictEqual(err.message, expectedErr.message); + assert.strictEqual(err.resolution, expectedErr.resolution); + return true; + }, + ); + }); + + void it('throws error if attempting to sign in a user that does not exist', async () => { + const expectedErr = new AmplifyUserError('UserExistsError', { + message: `${testUsername} does not exist`, + resolution: `Create a user called ${testUsername}`, + }); + + mockAuthAPIs.signIn.mock.mockImplementationOnce(() => + Promise.reject( + new UserNotFoundException({ + $metadata: {}, + message: `${testUsername} does not exist`, + }), + ), + ); + + await assert.rejects( + async () => + mfaFlow.mfaSignIn({ + username: testUsername, + password: testPassword, + signInFlow: 'MFA', + }), + (err: AmplifyUserError) => { + assert.strictEqual(err.name, expectedErr.name); + assert.strictEqual(err.message, expectedErr.message); + assert.strictEqual(err.resolution, expectedErr.resolution); + return true; + }, + ); + }); + }); +}); diff --git a/packages/seed/src/auth-seed/mfa_flow.ts b/packages/seed/src/auth-seed/mfa_flow.ts new file mode 100644 index 00000000000..8a7ccfeabd7 --- /dev/null +++ b/packages/seed/src/auth-seed/mfa_flow.ts @@ -0,0 +1,200 @@ +import * as auth from 'aws-amplify/auth'; +import assert from 'assert'; +import { AmplifyPrompter } from '@aws-amplify/cli-core'; +import { AuthSignUp, AuthUser } from '../types.js'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { PersistentPasswordFlow } from './persistent_password_flow.js'; + +/** + * Handles users who enter the MFA flow + */ +export class MfaFlow { + /** + * Constructor for dependency injection + */ + constructor( + private readonly authApi = auth, + private readonly prompter = AmplifyPrompter, + ) {} + + /** + * Signs up user with MFA sign up flow + * @param user - properties to sign up user with MFA + * @param tempPassword - temporary password that was generated for sign up + * @returns - true if user is successfully signed up, false otherwise + */ + mfaSignUp = async (user: AuthSignUp, tempPassword: string) => { + assert.strictEqual(user.signInFlow, 'MFA'); + const passwordFlow = new PersistentPasswordFlow(this.authApi); + let passwordSignIn = await passwordFlow.persistentPasswordSignUp( + user, + tempPassword, + ); + + if ( + passwordSignIn.nextStep.signInStep === + 'CONTINUE_SIGN_IN_WITH_MFA_SELECTION' + ) { + if (!user.mfaPreference) { + throw new AmplifyUserError('NoMFAPreferenceSpecifiedError', { + message: `If multiple forms of MFA are enabled for a userpool, you must specify which form you intend to use for ${user.username}`, + resolution: `Specify a form of MFA for the user, ${user.username}, to use with the mfaPreference property`, + }); + } + + if ( + !passwordSignIn.nextStep.allowedMFATypes?.includes(user.mfaPreference) + ) { + throw new AmplifyUserError('MFAPreferenceNotAvailableError', { + message: `${user.mfaPreference} is not available for this userpool`, + resolution: `Activate ${user.mfaPreference} for this userpool or sign in ${user.username} with a different form of MFA`, + }); + } + + passwordSignIn = await this.authApi.confirmSignIn({ + challengeResponse: user.mfaPreference, + }); + } + + if ( + passwordSignIn.nextStep.signInStep === 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP' + ) { + if (!user.totpSignUpChallenge) { + throw new AmplifyUserError('MissingTOTPChallengeError', { + message: + 'MFA sign up flow with TOTP cannot be used without a totpSignUpChallenge', + resolution: `Add a totpSignupChallenge when signing up ${user.username}`, + }); + } + const challengeOutput = await user.totpSignUpChallenge( + passwordSignIn.nextStep.totpSetupDetails, + ); + const challengeResponse = challengeOutput.challengeResponse; + const totpSignIn = await this.authApi.confirmSignIn({ + challengeResponse: challengeResponse, + }); + + await this.authApi.updateMFAPreference({ totp: 'PREFERRED' }); + return totpSignIn; + } else if ( + passwordSignIn.nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_SMS_CODE' + ) { + let challengeResponse: string; + if (!user.smsSignUpChallenge) { + challengeResponse = await this.prompter.secretValue( + `Please input the SMS one-time password for ${user.username}:`, + ); + } else { + const challengeOutput = await user.smsSignUpChallenge(); + challengeResponse = challengeOutput.challengeResponse; + } + const smsSignIn = await this.authApi.confirmSignIn({ + challengeResponse: challengeResponse, + }); + + await this.authApi.updateMFAPreference({ sms: 'PREFERRED' }); + return smsSignIn; + } else if ( + passwordSignIn.nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE' + ) { + let challengeResponse: string; + if (!user.emailSignUpChallenge) { + challengeResponse = await this.prompter.secretValue( + `Please input one-time password from EMAIL for ${user.username}:`, + ); + } else { + const challengeOutput = await user.emailSignUpChallenge(); + challengeResponse = challengeOutput.challengeResponse; + } + const emailSignIn = await this.authApi.confirmSignIn({ + challengeResponse: challengeResponse, + }); + + await this.authApi.updateMFAPreference({ email: 'PREFERRED' }); + return emailSignIn; + } + return passwordSignIn; + }; + + /** + * Signs in user with MFA + * @param user - properties to sign in user with MFA + * @returns - true if user is successfully signed in, false otherwise + */ + mfaSignIn = async (user: AuthUser) => { + assert.strictEqual(user.signInFlow, 'MFA'); + let signInResult: auth.SignInOutput; + try { + signInResult = await this.authApi.signIn({ + username: user.username, + password: user.password, + }); + } catch (err) { + const error = err as Error; + if (error.name === 'UserNotFoundException') { + throw new AmplifyUserError( + 'UserExistsError', + { + message: `${user.username} does not exist`, + resolution: `Create a user called ${user.username}`, + }, + error, + ); + } else { + throw err; + } + } + + if (signInResult.nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_TOTP_CODE') { + let challengeResponse: string; + if (!user.signInChallenge) { + challengeResponse = await this.prompter.secretValue( + `Please input the one-time password from your TOTP App for ${user.username}:`, + ); + } else { + const challengeOutput = await user.signInChallenge(); + challengeResponse = challengeOutput.challengeResponse; + } + const totpSignIn = await this.authApi.confirmSignIn({ + challengeResponse: challengeResponse, + }); + return totpSignIn.nextStep.signInStep === 'DONE'; + } else if ( + signInResult.nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_SMS_CODE' + ) { + let challengeResponse: string; + if (!user.signInChallenge) { + challengeResponse = await this.prompter.secretValue( + `Please input one-time password from SMS for ${user.username}:`, + ); + } else { + const challengeOutput = await user.signInChallenge(); + challengeResponse = challengeOutput.challengeResponse; + } + const smsSignIn = await this.authApi.confirmSignIn({ + challengeResponse: challengeResponse, + }); + return smsSignIn.nextStep.signInStep === 'DONE'; + } else if ( + signInResult.nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE' + ) { + let challengeResponse: string; + if (!user.signInChallenge) { + challengeResponse = await this.prompter.secretValue( + `Please input one-time password from EMAIL for ${user.username}:`, + ); + } else { + const challengeOutput = await user.signInChallenge(); + challengeResponse = challengeOutput.challengeResponse; + } + const emailSignIn = await this.authApi.confirmSignIn({ + challengeResponse: challengeResponse, + }); + return emailSignIn.nextStep.signInStep === 'DONE'; + } + throw new AmplifyUserError('CannotSignInWithMFAError', { + message: `${user.username} cannot be signed in with MFA`, + resolution: `Ensure that ${user.username} exists and that MFA is set to REQUIRED.`, + }); + }; +} diff --git a/packages/seed/src/auth-seed/persistent_password_flow.test.ts b/packages/seed/src/auth-seed/persistent_password_flow.test.ts new file mode 100644 index 00000000000..962711e8354 --- /dev/null +++ b/packages/seed/src/auth-seed/persistent_password_flow.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import * as auth from 'aws-amplify/auth'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import assert from 'assert'; +import { PersistentPasswordFlow } from './persistent_password_flow.js'; +import { UserNotFoundException } from '@aws-sdk/client-cognito-identity-provider'; + +const testUsername = 'testUser1@test.com'; +const testPassword = 'T3st_Password*'; +const testTempPassword = 'Test1@Temp123'; + +void describe('persistent password flow test', () => { + const mockAuthAPIs = { + signIn: mock.fn<(input: auth.SignInInput) => Promise>( + async () => + Promise.resolve({ + isSignedIn: true, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED', + }, + } as auth.SignInOutput), + ), + confirmSignIn: mock.fn< + (input: auth.ConfirmSignInInput) => Promise + >(async () => + Promise.resolve({ + isSignedIn: true, + nextStep: { signInStep: 'DONE' }, + } as auth.SignInOutput), + ), + }; + const passwordFlow = new PersistentPasswordFlow( + mockAuthAPIs as unknown as typeof auth, + ); + + void beforeEach(() => { + mockAuthAPIs.confirmSignIn.mock.resetCalls(); + mockAuthAPIs.signIn.mock.resetCalls(); + }); + + void it('confirms sign up of user with password flow', async () => { + await passwordFlow.persistentPasswordSignUp( + { + username: testUsername, + password: testPassword, + signInAfterCreation: true, + signInFlow: 'Password', + }, + testTempPassword, + ); + + assert.strictEqual(mockAuthAPIs.confirmSignIn.mock.callCount(), 1); + assert.strictEqual(mockAuthAPIs.signIn.mock.callCount(), 1); + }); + + void it('throws error if attempting to sign in user that does not exist with password flow', async () => { + const expectedErr = new AmplifyUserError('UserExistsError', { + message: `${testUsername} does not exist`, + resolution: `Create a user called ${testUsername}`, + }); + + mockAuthAPIs.signIn.mock.mockImplementationOnce(() => + Promise.reject( + new UserNotFoundException({ + $metadata: {}, + message: `${testUsername} does not exist`, + }), + ), + ); + + await assert.rejects( + async () => + passwordFlow.persistentPasswordSignIn({ + username: testUsername, + password: testPassword, + signInFlow: 'Password', + }), + (err: AmplifyUserError) => { + assert.strictEqual(err.name, expectedErr.name); + assert.strictEqual(err.message, expectedErr.message); + assert.strictEqual(err.resolution, expectedErr.resolution); + return true; + }, + ); + }); +}); diff --git a/packages/seed/src/auth-seed/persistent_password_flow.ts b/packages/seed/src/auth-seed/persistent_password_flow.ts new file mode 100644 index 00000000000..726ec26365f --- /dev/null +++ b/packages/seed/src/auth-seed/persistent_password_flow.ts @@ -0,0 +1,91 @@ +import * as auth from 'aws-amplify/auth'; +import assert from 'assert'; +import { AuthSignUp, AuthUser } from '../types.js'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; + +/** + * Handles users who enter Persistent Password flow + */ +export class PersistentPasswordFlow { + /** + * constructor + */ + constructor(private readonly authApi = auth) {} + + /** + * Signs up user with persistent password sign up flow + * @param user - properties for signing up a user with persistent password flow + * @param tempPassword - temporary password used generated for sign up + * @returns - true if user makes it through the sign up flow, false otherwise + */ + persistentPasswordSignUp = async (user: AuthSignUp, tempPassword: string) => { + const signInResult = await this.authApi.signIn({ + username: user.username, + password: tempPassword, + }); + + assert.strictEqual( + signInResult.nextStep.signInStep, + 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED', + ); + + const confirmResult = await this.authApi.confirmSignIn({ + challengeResponse: user.password, + options: { + userAttributes: { + name: user.userAttributes?.name, + family_name: user.userAttributes?.familyName, + given_name: user.userAttributes?.givenName, + middle_name: user.userAttributes?.middleName, + nickname: user.userAttributes?.nickname, + preferred_username: user.userAttributes?.preferredUsername, + profile: user.userAttributes?.profile, + picture: user.userAttributes?.picture, + website: user.userAttributes?.website, + gender: user.userAttributes?.gender, + birthdate: user.userAttributes?.birthdate, + zoneinfo: user.userAttributes?.zoneinfo, + locale: user.userAttributes?.locale, + updated_at: user.userAttributes?.updatedAt, + address: user.userAttributes?.address, + email: user.userAttributes?.email, + phone_number: user.userAttributes?.phoneNumber, + sub: user.userAttributes?.sub, + }, + }, + }); + + return confirmResult; + }; + + /** + * Signs in user with password + * @param user - properties to sign in user with password + * @returns - true if user is successfully signed in, false otherwise + */ + persistentPasswordSignIn = async (user: AuthUser) => { + let signInResult: auth.SignInOutput; + try { + signInResult = await this.authApi.signIn({ + username: user.username, + password: user.password, + }); + } catch (err) { + const error = err as Error; + if (error.name === 'UserNotFoundException') { + throw new AmplifyUserError( + 'UserExistsError', + { + message: `${user.username} does not exist`, + resolution: `Create a user called ${user.username}`, + }, + error, + ); + } else { + throw err; + } + } + + return signInResult.nextStep.signInStep === 'DONE'; + }; +} diff --git a/packages/seed/src/index.ts b/packages/seed/src/index.ts new file mode 100644 index 00000000000..c15c04df8e9 --- /dev/null +++ b/packages/seed/src/index.ts @@ -0,0 +1,36 @@ +import { getSecret, setSecret } from './secrets-seed/seed_secret.js'; +import { + addToUserGroup, + createAndSignUpUser, + signInUser, +} from './auth-seed/auth_api.js'; +import { + AuthOutputs, + AuthSignUp, + AuthUser, + AuthUserGroupInput, + ChallengeResponse, + MfaSignInFlow, + MfaSignUpFlow, + MfaWithTotpSignUpFlow, + PasswordSignInFlow, + StandardUserAttributes, +} from './types.js'; + +export { + addToUserGroup, + createAndSignUpUser, + getSecret, + setSecret, + signInUser, + AuthOutputs, + AuthSignUp, + AuthUser, + AuthUserGroupInput, + ChallengeResponse, + PasswordSignInFlow, + MfaSignInFlow, + MfaSignUpFlow, + MfaWithTotpSignUpFlow, + StandardUserAttributes, +}; diff --git a/packages/seed/src/secrets-seed/seed_secret.ts b/packages/seed/src/secrets-seed/seed_secret.ts new file mode 100644 index 00000000000..e1392b7ca18 --- /dev/null +++ b/packages/seed/src/secrets-seed/seed_secret.ts @@ -0,0 +1,85 @@ +import { + SecretClient, + getSecretClientWithAmplifyErrorHandling, +} from '@aws-amplify/backend-secret'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; + +/** + * + */ +export class SeedSecretClient { + /** + * constructor + */ + constructor( + private readonly getSecretClient: SecretClient = getSecretClientWithAmplifyErrorHandling(), + ) {} + + getSecret = async (secretName: string): Promise => { + if (!process.env.AMPLIFY_BACKEND_IDENTIFIER) { + throw new AmplifyUserError('SandboxIdentifierNotFoundError', { + message: 'Sandbox Identifier is undefined', + resolution: + 'Run npx ampx sandbox before re-running npx ampx sandbox seed', + }); + } + + const backendId: BackendIdentifier = JSON.parse( + process.env.AMPLIFY_BACKEND_IDENTIFIER, + ); + + const secretClient = this.getSecretClient; + const secret = await secretClient.getSecret(backendId, { + name: secretName, + }); + return secret.value; + }; + + setSecret = async ( + secretName: string, + secretValue: string, + ): Promise => { + if (!process.env.AMPLIFY_BACKEND_IDENTIFIER) { + throw new AmplifyUserError('SandboxIdentifierNotFoundError', { + message: 'Sandbox Identifier is undefined', + resolution: + 'Run npx ampx sandbox before re-running npx ampx sandbox seed', + }); + } + + const backendId: BackendIdentifier = JSON.parse( + process.env.AMPLIFY_BACKEND_IDENTIFIER, + ); + + const secretClient = this.getSecretClient; + const secret = await secretClient.setSecret( + backendId, + secretName, + secretValue, + ); + return secret.name; + }; +} + +/** + * Allows for programmatic getting of secrets in Parameter store + * @param secretName - identifier for secret + * @returns - specified secret from AWS Systems Manager Parameter Store + */ +export const getSecret = async (secretName: string): Promise => { + return await new SeedSecretClient().getSecret(secretName); +}; + +/** + * Allows for programmatic setting of secrets in Parameter store + * @param secretName - identifier for secret + * @param secretValue - value secret is set to + * @returns - name of the secret that has been added to AWS Systems Manager Parameter Store + */ +export const setSecret = async ( + secretName: string, + secretValue: string, +): Promise => { + return await new SeedSecretClient().setSecret(secretName, secretValue); +}; diff --git a/packages/seed/src/secrets-seed/seed_secrets.test.ts b/packages/seed/src/secrets-seed/seed_secrets.test.ts new file mode 100644 index 00000000000..f79ff0f99e1 --- /dev/null +++ b/packages/seed/src/secrets-seed/seed_secrets.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; +import assert from 'node:assert'; +import { SeedSecretClient, getSecret, setSecret } from './seed_secret.js'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { + Secret, + SecretClient, + SecretIdentifier, +} from '@aws-amplify/backend-secret'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; + +const testBackendId = 'testBackendId'; +const testSandboxName = 'testSandboxName'; +const testSecretName = 'testSecretName'; + +const testBackendIdentifier: BackendIdentifier = { + namespace: testBackendId, + name: testSandboxName, + type: 'sandbox', +}; +const testSecretValue = 'testSecret'; + +void describe('secrets APIs for seed', () => { + void describe('no backendId', () => { + void it('getSecret throws AmplifyUserError if no backendId is set', async () => { + const expectedErr = new AmplifyUserError( + 'SandboxIdentifierNotFoundError', + { + message: 'Sandbox Identifier is undefined', + resolution: + 'Run npx ampx sandbox before re-running npx ampx sandbox seed', + }, + ); + await assert.rejects( + async () => await getSecret(testSecretName), + expectedErr, + ); + }); + + void it('setSecret throws AmplifyUserError if no backendId is set', async () => { + const expectedErr = new AmplifyUserError( + 'SandboxIdentifierNotFoundError', + { + message: 'Sandbox Identifier is undefined', + resolution: + 'Run npx ampx sandbox before re-running npx ampx sandbox seed', + }, + ); + await assert.rejects( + async () => await setSecret(testSecretName, testSecretValue), + expectedErr, + ); + }); + }); + + void describe('getting/setting secrets', () => { + const secretClientMock = { + getSecret: mock.fn< + ( + backendID: BackendIdentifier, + secretId: SecretIdentifier, + ) => Promise + >(() => + Promise.resolve({ + name: testSecretName, + value: testSecretValue, + } as Secret), + ), + setSecret: mock.fn< + ( + secretName: string, + secretValue: string, + backendID: BackendIdentifier, + ) => Promise + >(() => + Promise.resolve({ + name: testSecretName, + } as SecretIdentifier), + ), + }; + + const seedSecretClient = new SeedSecretClient( + secretClientMock as unknown as SecretClient, + ); + + beforeEach(() => { + secretClientMock.getSecret.mock.resetCalls(); + secretClientMock.setSecret.mock.resetCalls(); + process.env.AMPLIFY_BACKEND_IDENTIFIER = JSON.stringify( + testBackendIdentifier, + ); + }); + + afterEach(() => { + delete process.env.AMPLIFY_BACKEND_IDENTIFIER; + }); + + void it('getSecret properly calls getSecret from secretClientWithAmplifyErrorHandling', async () => { + const secretVal = await seedSecretClient.getSecret(testSecretName); + + assert.strictEqual(secretClientMock.getSecret.mock.callCount(), 1); + assert.strictEqual(secretVal, testSecretValue); + }); + + void it('setSecret properly calls setSecret from secretClientWithAmplifyErrorHandling', async () => { + const secretName = await seedSecretClient.setSecret( + testSecretName, + testSecretValue, + ); + + assert.strictEqual(secretClientMock.setSecret.mock.callCount(), 1); + assert.strictEqual(secretName, testSecretName); + }); + }); +}); diff --git a/packages/seed/src/types.ts b/packages/seed/src/types.ts new file mode 100644 index 00000000000..772d145d83c --- /dev/null +++ b/packages/seed/src/types.ts @@ -0,0 +1,76 @@ +import * as auth from 'aws-amplify/auth'; + +export type AuthSignUp = { + signInAfterCreation: boolean; + username: string; + userAttributes?: StandardUserAttributes; +} & (PasswordSignInFlow | MfaSignUpFlow | MfaWithTotpSignUpFlow); + +export type AuthUser = { + username: string; +} & (PasswordSignInFlow | MfaSignInFlow); + +export type AuthUserGroupInput = { + username: string; +}; + +export type AuthOutputs = { + signInFlow: 'Password' | 'MFA'; + username: string; +}; + +export type ChallengeResponse = { + challengeResponse: string; +}; + +export type PasswordSignInFlow = { + signInFlow: 'Password'; + password: string; +}; + +export type MfaSignUpFlow = { + signInFlow: 'MFA'; + password: string; + mfaPreference?: 'EMAIL' | 'SMS' | 'TOTP'; + emailSignUpChallenge?: () => Promise; + smsSignUpChallenge?: () => Promise; + totpSignUpChallenge?: ( + totpSetup: auth.SetUpTOTPOutput, + ) => Promise; +}; + +export type MfaWithTotpSignUpFlow = { + mfaPreference?: 'TOTP'; + totpSignUpChallenge: ( + totpSetup: auth.SetUpTOTPOutput, + ) => Promise; +} & MfaSignUpFlow; + +export type MfaSignInFlow = { + signInFlow: 'MFA'; + password: string; + signInChallenge?: () => Promise; +}; + +// Standard User Attributes come from here: https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html#cognito-user-pools-standard-attributes +// Types come from here (address is typed as string here instead of JSON Object): https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims +export type StandardUserAttributes = { + name?: string; + familyName?: string; + givenName?: string; + middleName?: string; + nickname?: string; + preferredUsername?: string; + profile?: string; + picture?: string; + website?: string; + gender?: string; + birthdate?: string; + zoneinfo?: string; + locale?: string; + updatedAt?: string; + address?: string; + email?: string; + phoneNumber?: string; + sub?: string; +}; diff --git a/packages/seed/tsconfig.json b/packages/seed/tsconfig.json new file mode 100644 index 00000000000..39b5e3f6109 --- /dev/null +++ b/packages/seed/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "lib" }, + "references": [ + { "path": "../backend-secret" }, + { "path": "../cli-core" }, + { "path": "../client-config" }, + { "path": "../platform-core" }, + { "path": "../plugin-types" } + ] +} diff --git a/packages/seed/typedoc.json b/packages/seed/typedoc.json new file mode 100644 index 00000000000..35fed2c958c --- /dev/null +++ b/packages/seed/typedoc.json @@ -0,0 +1,3 @@ +{ + "entryPoints": ["src/index.ts"] +} diff --git a/scripts/check_dependencies.ts b/scripts/check_dependencies.ts index 2a2ce62eaa0..089c1ed8f4a 100644 --- a/scripts/check_dependencies.ts +++ b/scripts/check_dependencies.ts @@ -5,19 +5,20 @@ await new DependenciesValidator( await glob('packages/*'), { 'aws-amplify': { - allowList: ['@aws-amplify/integration-tests'], + allowList: ['@aws-amplify/integration-tests', '@aws-amplify/seed'], }, '@aws-amplify/datastore': { - allowList: ['@aws-amplify/integration-tests'], + allowList: ['@aws-amplify/integration-tests', '@aws-amplify/seed'], }, '@aws-amplify/core': { - allowList: ['@aws-amplify/integration-tests'], + allowList: ['@aws-amplify/integration-tests', '@aws-amplify/seed'], }, '@aws-amplify/cli-core': { allowList: [ '@aws-amplify/backend-cli', '@aws-amplify/integration-tests', '@aws-amplify/sandbox', + '@aws-amplify/seed', 'create-amplify', ], },