From 6256e09b8dd6afef3d423e205bcf97482f69d41a Mon Sep 17 00:00:00 2001 From: ribeirogab Date: Sun, 22 Sep 2024 05:54:48 -0300 Subject: [PATCH 1/3] feat: sso login structure and implement google sso --- .env.example | 6 + package-lock.json | 418 +++++++++++++----- package.json | 8 +- src/configs/dynamo.config.ts | 1 + src/configs/env.config.ts | 5 + src/configs/index.ts | 2 + src/configs/sso-google.config.ts | 91 ++++ src/configs/sso.config.ts | 30 ++ src/container.ts | 15 +- src/helpers/auth.helper.ts | 6 +- src/interfaces/helpers/auth.helper.ts | 4 +- src/interfaces/index.ts | 4 +- src/interfaces/models/session.ts | 5 + src/interfaces/models/user-auth-provider.ts | 12 + src/interfaces/models/user-token.ts | 10 - src/interfaces/models/user.ts | 2 +- .../user-auth-provider.repository.ts | 13 + .../repositories/user.repository.ts | 2 +- .../services/login-confirm.service.ts | 4 +- .../services/refresh-login.service.ts | 4 +- .../services/single-sign-on.service.ts | 13 + src/main.ts | 6 +- src/repositories/index.ts | 1 + .../user-auth-provider.repository.ts | 110 +++++ src/repositories/user.repository.ts | 48 +- src/services/index.ts | 1 + src/services/login-confirm.service.ts | 27 +- src/services/login.service.ts | 22 +- src/services/refresh-login.service.ts | 5 +- src/services/registration-confirm.service.ts | 44 +- src/services/registration.service.ts | 16 +- src/services/single-sign-on.service.ts | 92 ++++ 32 files changed, 829 insertions(+), 198 deletions(-) create mode 100644 src/configs/sso-google.config.ts create mode 100644 src/configs/sso.config.ts create mode 100644 src/interfaces/models/user-auth-provider.ts delete mode 100644 src/interfaces/models/user-token.ts create mode 100644 src/interfaces/repositories/user-auth-provider.repository.ts create mode 100644 src/interfaces/services/single-sign-on.service.ts create mode 100644 src/repositories/user-auth-provider.repository.ts create mode 100644 src/services/single-sign-on.service.ts diff --git a/.env.example b/.env.example index 5e04de0..cf43b0c 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ STAGE=dev # Application JWT_SECRET=auth-jwt-secret JWT_SECRET_VERIFICATION_TOKEN=verification-token-jwt-secret +APPLICATION_BASE_URL=http://localhost:8080 FRONTEND_CONFIRM_SIGN_UP_URL=http://localhost:3000/registration FRONTEND_CONFIRM_SIGN_IN_URL=http://localhost:3000/login @@ -15,3 +16,8 @@ AWS_DYNAMO_TABLE_NAME=dev-authentication # Email provider RESEND_API_KEY= DEFAULT_SENDER_EMAIL=norepy@example.com + +# Google SSO +GOOGLE_SSO_ENABLED=false +GOOGLE_CLIENT_SECRET= +GOOGLE_CLIENT_ID= diff --git a/package-lock.json b/package-lock.json index 9a12e24..c261b7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,12 @@ "@aws-sdk/lib-dynamodb": "^3.654.0", "@aws-sdk/util-dynamodb": "^3.654.0", "@fastify/aws-lambda": "^4.1.0", - "@fastify/cors": "^9.0.1", - "@fastify/rate-limit": "^9.1.0", + "@fastify/cors": "^10.0.0", + "@fastify/oauth2": "^8.0.0", + "@fastify/rate-limit": "^10.0.1", + "axios": "^1.7.7", "env-var": "^7.5.0", - "fastify": "^4.28.1", + "fastify": "^5.0.0", "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "reflect-metadata": "^0.2.2", @@ -1309,13 +1311,13 @@ } }, "node_modules/@fastify/ajv-compiler": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", - "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.0.tgz", + "integrity": "sha512-dt0jyLAlay14LpIn4Fg1SY7V5NJ9KH0YFDpYVQY5cgIVBvdI8908AMx5zQ0bBYPGT6Wh+bM3f2caMmOXLP3QsQ==", "dependencies": { - "ajv": "^8.11.0", - "ajv-formats": "^2.1.1", - "fast-uri": "^2.0.0" + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" } }, "node_modules/@fastify/ajv-compiler/node_modules/ajv": { @@ -1333,11 +1335,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv/node_modules/fast-uri": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", - "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" - }, "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1348,26 +1345,35 @@ "resolved": "https://registry.npmjs.org/@fastify/aws-lambda/-/aws-lambda-4.1.0.tgz", "integrity": "sha512-293HSdtr4muZZi4UxjrDgddxlLRDbNxT5x/eOX78obMA1Du3tfpuP7WuyfnA4GXaeckj/soJ2jiuD2sM4VIW9Q==" }, + "node_modules/@fastify/cookie": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-10.0.0.tgz", + "integrity": "sha512-S43spazwAfzm5nKlqq/spAGW+O6r+WQzg5vXXI1ArCXXFa8KBA/tiU3XRVQUehSNtbN5PA6+g183hzh5/dZ6Iw==", + "dependencies": { + "cookie-signature": "^1.2.1", + "fastify-plugin": "^5.0.0" + } + }, "node_modules/@fastify/cors": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz", - "integrity": "sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.0.0.tgz", + "integrity": "sha512-kb9fkc/LVbLTQ3lhA+ZZjC/Styzysodo/MTCdVCvTtgHa/gBwxrEEkcp3fuoKIfAQt85wksrpXjUGbw5NQffEQ==", "dependencies": { - "fastify-plugin": "^4.0.0", - "mnemonist": "0.39.6" + "fastify-plugin": "^5.0.0", + "mnemonist": "0.39.8" } }, "node_modules/@fastify/error": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", - "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.0.0.tgz", + "integrity": "sha512-OO/SA8As24JtT1usTUTKgGH7uLvhfwZPwlptRi2Dp5P4KKmJI3gvsZ8MIHnNwDs4sLf/aai5LzTyl66xr7qMxA==" }, "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", - "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.1.tgz", + "integrity": "sha512-f2d3JExJgFE3UbdFcpPwqNUEoHWmt8pAKf8f+9YuLESdefA0WgqxeT6DrGL4Yrf/9ihXNSKOqpjEmurV405meA==", "dependencies": { - "fast-json-stringify": "^5.7.0" + "fast-json-stringify": "^6.0.0" } }, "node_modules/@fastify/merge-json-schemas": { @@ -1378,14 +1384,65 @@ "fast-deep-equal": "^3.1.3" } }, + "node_modules/@fastify/oauth2": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@fastify/oauth2/-/oauth2-8.0.0.tgz", + "integrity": "sha512-mqB6jcBUfkBvHqJJ3gCi6yqpf0iLhtmKAZL0OuthqlAWzaUfUaoIgBeCB6wc/duMxw41ElzgJUPYCOJ3jhhvOw==", + "dependencies": { + "@fastify/cookie": "^10.0.0", + "fastify-plugin": "^5.0.0", + "simple-oauth2": "^5.0.0" + } + }, "node_modules/@fastify/rate-limit": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-9.1.0.tgz", - "integrity": "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.0.1.tgz", + "integrity": "sha512-wLGtemfKepoe9yUyHB0zcTvCWy7H3qAScDCxny8NE+mqGNg9L1HhkWK8bIlMDCLOLfng1i7+GTikYeZvM2HFWQ==", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.4.tgz", + "integrity": "sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@hapi/topo/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@hapi/wreck": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", + "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", "dependencies": { - "@lukeed/ms": "^2.0.1", - "fastify-plugin": "^4.0.0", - "toad-cache": "^3.3.1" + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/hoek": "^11.0.2" } }, "node_modules/@humanwhocodes/config-array": { @@ -2509,6 +2566,29 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/address/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, "node_modules/@smithy/abort-controller": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.4.tgz", @@ -3465,9 +3545,9 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dependencies": { "ajv": "^8.0.0" }, @@ -3495,11 +3575,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats/node_modules/fast-uri": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", - "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" - }, "node_modules/ajv-formats/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -3747,6 +3822,11 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -3771,11 +3851,11 @@ } }, "node_modules/avvio": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", - "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.0.0.tgz", + "integrity": "sha512-UbYrOXgE/I+knFG+3kJr9AgC7uNo8DG+FGGODpH9Bj1O1kL/QDjBXnTem9leD3VdQKtaHjV3O85DQ7hHh4IIHw==", "dependencies": { - "@fastify/error": "^3.3.0", + "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, @@ -3788,6 +3868,16 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.4.tgz", @@ -4104,6 +4194,17 @@ "text-hex": "1.0.x" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4151,6 +4252,14 @@ "node": ">= 0.6" } }, + "node_modules/cookie-signature": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.1.tgz", + "integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4238,7 +4347,6 @@ "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -4340,6 +4448,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5681,11 +5797,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, - "node_modules/fast-content-type-parse": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", - "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==" - }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -5737,15 +5848,15 @@ "dev": true }, "node_modules/fast-json-stringify": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", - "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.0.0.tgz", + "integrity": "sha512-FGMKZwniMTgZh7zQp9b6XnBVxUmKVahQLQeRQHqwYmPDqDhcEKZ3BaQsxelFFI5PY7nN71OEeiL47/zUWcYe1A==", "dependencies": { - "@fastify/merge-json-schemas": "^0.1.0", - "ajv": "^8.10.0", + "@fastify/merge-json-schemas": "^0.1.1", + "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.1.0", + "fast-uri": "^2.3.0", "json-schema-ref-resolver": "^1.0.1", "rfdc": "^1.2.0" } @@ -5765,27 +5876,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/fast-json-stringify/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", + "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==" + }, "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -5814,9 +5914,9 @@ } }, "node_modules/fast-uri": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", - "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", + "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" }, "node_modules/fast-xml-parser": { "version": "4.4.1", @@ -5840,9 +5940,9 @@ } }, "node_modules/fastify": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.28.1.tgz", - "integrity": "sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.0.0.tgz", + "integrity": "sha512-Qe4dU+zGOzg7vXjw4EvcuyIbNnMwTmcuOhlOrOJsgwzvjEZmsM/IeHulgJk+r46STjdJS/ZJbxO8N70ODXDMEQ==", "funding": [ { "type": "github", @@ -5854,28 +5954,27 @@ } ], "dependencies": { - "@fastify/ajv-compiler": "^3.5.0", - "@fastify/error": "^3.4.0", - "@fastify/fast-json-stringify-compiler": "^4.3.0", + "@fastify/ajv-compiler": "^4.0.0", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", "abstract-logging": "^2.0.1", - "avvio": "^8.3.0", - "fast-content-type-parse": "^1.1.0", - "fast-json-stringify": "^5.8.0", - "find-my-way": "^8.0.0", - "light-my-request": "^5.11.0", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", "pino": "^9.0.0", - "process-warning": "^3.0.0", + "process-warning": "^4.0.0", "proxy-addr": "^2.0.7", - "rfdc": "^1.3.0", + "rfdc": "^1.3.1", "secure-json-parse": "^2.7.0", - "semver": "^7.5.4", - "toad-cache": "^3.3.0" + "semver": "^7.6.0", + "toad-cache": "^3.7.0" } }, "node_modules/fastify-plugin": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", - "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.0.1.tgz", + "integrity": "sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==" }, "node_modules/fastq": { "version": "1.17.1", @@ -5915,13 +6014,13 @@ } }, "node_modules/find-my-way": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.0.tgz", - "integrity": "sha512-HdWXgFYc6b1BJcOBDBwjqWuHJj1WYiqrxSh25qtU4DabpMFdj/gSunNBQb83t+8Zt67D7CXEzJWTkxaShMTMOA==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.0.1.tgz", + "integrity": "sha512-/5NN/R0pFWuff16TMajeKt2JyiW+/OE8nOO8vo1DwZTxLaIURb7lcBYPIgRPh61yCNh9l8voeKwcrkUzmB00vw==", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", - "safe-regex2": "^3.1.0" + "safe-regex2": "^4.0.0" }, "engines": { "node": ">=14" @@ -5968,6 +6067,25 @@ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -5992,6 +6110,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7047,6 +7178,23 @@ "jiti": "bin/jiti.js" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/joi/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -7245,13 +7393,13 @@ } }, "node_modules/light-my-request": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.13.0.tgz", - "integrity": "sha512-9IjUN9ZyCS9pTG+KqTDEQo68Sui2lHsYBrfMyVUTTZ3XhH8PMZq7xO94Kr+eP9dhi/kcKsx4N41p2IXEBil1pQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.0.0.tgz", + "integrity": "sha512-kFkFXrmKCL0EEeOmJybMH5amWFd+AFvlvMlvFTRxCUwbhfapZqDmeLMPoWihntnYY6JpoQDE9k+vOzObF1fDqg==", "dependencies": { "cookie": "^0.6.0", - "process-warning": "^3.0.0", - "set-cookie-parser": "^2.4.1" + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" } }, "node_modules/lilconfig": { @@ -7445,6 +7593,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -7484,9 +7651,9 @@ } }, "node_modules/mnemonist": { - "version": "0.39.6", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", - "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", "dependencies": { "obliterator": "^2.0.1" } @@ -7984,11 +8151,6 @@ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==" }, - "node_modules/pino/node_modules/process-warning": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", - "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==" - }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -8215,9 +8377,9 @@ } }, "node_modules/process-warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", - "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", + "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==" }, "node_modules/prop-types": { "version": "15.8.1", @@ -8247,6 +8409,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8487,9 +8654,9 @@ } }, "node_modules/ret": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", - "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", "engines": { "node": ">=10" } @@ -8658,11 +8825,11 @@ } }, "node_modules/safe-regex2": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", - "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-4.0.0.tgz", + "integrity": "sha512-Hvjfv25jPDVr3U+4LDzBuZPPOymELG3PYcSk5hcevooo1yxxamQL/bHs/GrEPGmMoMEwRrHVGiCA1pXi97B8Ew==", "dependencies": { - "ret": "~0.4.0" + "ret": "~0.5.0" } }, "node_modules/safe-stable-stringify": { @@ -8800,6 +8967,17 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-oauth2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/simple-oauth2/-/simple-oauth2-5.1.0.tgz", + "integrity": "sha512-gWDa38Ccm4MwlG5U7AlcJxPv3lvr80dU7ARJWrGdgvOKyzSj1gr3GBPN1rABTedAYvC/LsGYoFuFxwDBPtGEbw==", + "dependencies": { + "@hapi/hoek": "^11.0.4", + "@hapi/wreck": "^18.0.0", + "debug": "^4.3.4", + "joi": "^17.6.4" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", diff --git a/package.json b/package.json index ddb04bc..b52c79c 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,12 @@ "@aws-sdk/lib-dynamodb": "^3.654.0", "@aws-sdk/util-dynamodb": "^3.654.0", "@fastify/aws-lambda": "^4.1.0", - "@fastify/cors": "^9.0.1", - "@fastify/rate-limit": "^9.1.0", + "@fastify/cors": "^10.0.0", + "@fastify/oauth2": "^8.0.0", + "@fastify/rate-limit": "^10.0.1", + "axios": "^1.7.7", "env-var": "^7.5.0", - "fastify": "^4.28.1", + "fastify": "^5.0.0", "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "reflect-metadata": "^0.2.2", diff --git a/src/configs/dynamo.config.ts b/src/configs/dynamo.config.ts index dd0eb21..3c83bfa 100644 --- a/src/configs/dynamo.config.ts +++ b/src/configs/dynamo.config.ts @@ -5,6 +5,7 @@ import type { EnvConfig } from './env.config'; export enum DynamoPartitionKeysEnum { VerificationCode = 'verification-code', + UserAuthProvider = 'user-auth-provider', User = 'user', } diff --git a/src/configs/env.config.ts b/src/configs/env.config.ts index e70dbb9..9c16fc4 100644 --- a/src/configs/env.config.ts +++ b/src/configs/env.config.ts @@ -15,6 +15,7 @@ export class EnvConfig { public readonly CORS_ORIGIN = get('CORS_ORIGIN').default('*').asString(); public readonly JWT_SECRET = get('JWT_SECRET').required().asString(); public readonly JWT_SECRET_VERIFICATION_TOKEN = get('JWT_SECRET_VERIFICATION_TOKEN').required().asString(); + public readonly APPLICATION_BASE_URL = get('APPLICATION_BASE_URL').required().asString(); public readonly FRONTEND_CONFIRM_SIGN_UP_URL = get('FRONTEND_CONFIRM_SIGN_UP_URL').required().asString(); public readonly FRONTEND_CONFIRM_SIGN_IN_URL = get('FRONTEND_CONFIRM_SIGN_IN_URL').required().asString(); @@ -26,6 +27,10 @@ export class EnvConfig { public readonly AWS_REGION = get('AWS_REGION').default('us-east-1').asString(); public readonly AWS_DYNAMO_TABLE_NAME = get('AWS_DYNAMO_TABLE_NAME').required().asString(); + public readonly GOOGLE_CLIENT_SECRET = get('GOOGLE_CLIENT_SECRET').required().asString(); + public readonly GOOGLE_CLIENT_ID = get('GOOGLE_CLIENT_ID').required().asString(); + public readonly GOOGLE_SSO_ENABLED = get('GOOGLE_SSO_ENABLED').default('true').asBool(); + public readonly EMAIL_PROVIDER = get('EMAIL_PROVIDER') .default(EmailProviderEnum.Resend) .asEnum(Object.values(EmailProviderEnum)); diff --git a/src/configs/index.ts b/src/configs/index.ts index f84afc9..f1e7f26 100644 --- a/src/configs/index.ts +++ b/src/configs/index.ts @@ -1,4 +1,6 @@ export * from './rate-limit.config'; +export * from './sso-google.config'; export * from './dynamo.config'; export * from './env.config'; export * from './jwt.config'; +export * from './sso.config'; diff --git a/src/configs/sso-google.config.ts b/src/configs/sso-google.config.ts new file mode 100644 index 0000000..edc5b43 --- /dev/null +++ b/src/configs/sso-google.config.ts @@ -0,0 +1,91 @@ +import oauth2, { FastifyOAuth2Options, OAuth2Namespace } from '@fastify/oauth2'; +import axios, { type AxiosInstance } from 'axios'; +import { FastifyInstance } from 'fastify'; +import { inject, injectable } from 'tsyringe'; + +import type { EnvConfig } from './env.config'; +import { type SingleSignOnService, UserAuthProviderEnum } from '@/interfaces'; + +type FastifyApp = FastifyInstance & { + googleOAuth2: OAuth2Namespace; +}; + +export type GoogleUserInfo = { + verified_email: boolean; + family_name?: string; + given_name?: string; + picture?: string; + email: string; + name: string; + id: string; +}; + +@injectable() +export class SSOGoogleConfig { + private readonly api: AxiosInstance; + + constructor( + @inject('EnvConfig') + private readonly envConfig: EnvConfig, + + @inject('SingleSignOnService') + private readonly singleSignOnService: SingleSignOnService, + ) { + this.api = axios.create({ + baseURL: 'https://www.googleapis.com', + }); + } + + public setup(app: FastifyInstance) { + app.register(oauth2, { + callbackUri: `${this.envConfig.APPLICATION_BASE_URL}/v1/auth/google/callback`, + scope: ['profile', 'email'], + name: 'googleOAuth2', + credentials: { + client: { + secret: this.envConfig.GOOGLE_CLIENT_SECRET, + id: this.envConfig.GOOGLE_CLIENT_ID, + }, + auth: oauth2.GOOGLE_CONFIGURATION, + }, + } as FastifyOAuth2Options); + + app.after(() => { + this.routers(app as FastifyApp); + }); + } + + private routers(app: FastifyApp) { + app.get('/v1/auth/google', async (request, reply) => { + const redirectUrl = await app.googleOAuth2.generateAuthorizationUri( + request, + reply, + ); + + return reply.redirect(redirectUrl); + }); + + app.get('/v1/auth/google/callback', async (request, reply) => { + const { token } = + await app.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request); + + const { data } = await this.api.get( + '/oauth2/v2/userinfo', + { + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + }, + ); + + const response = await this.singleSignOnService.execute({ + provider: UserAuthProviderEnum.Google, + providerId: data.id, + email: data.email, + name: data.name, + }); + + return reply.send(response); + }); + } +} diff --git a/src/configs/sso.config.ts b/src/configs/sso.config.ts new file mode 100644 index 0000000..7803567 --- /dev/null +++ b/src/configs/sso.config.ts @@ -0,0 +1,30 @@ +import type { FastifyInstance } from 'fastify'; +import { inject, injectable } from 'tsyringe'; + +import type { EnvConfig } from './env.config'; +import { SSOGoogleConfig } from './sso-google.config'; + +@injectable() +export class SSOConfig { + constructor( + @inject('SSOGoogleConfig') + private readonly ssoGoogleConfig: SSOGoogleConfig, + + @inject('EnvConfig') + private readonly envConfig: EnvConfig, + ) {} + + public plugin( + app: FastifyInstance, + _options?: unknown, + done?: (err?: Error) => void, + ) { + if (this.envConfig.GOOGLE_SSO_ENABLED) { + this.ssoGoogleConfig.setup(app); + } + + if (done) { + done(); + } + } +} diff --git a/src/container.ts b/src/container.ts index 2b371d8..66972fd 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,11 +1,17 @@ import { container } from 'tsyringe'; import { EmailAdapter, LoggerAdapter, UniqueIdAdapter } from './adapters'; -import { DynamoConfig, EnvConfig, JwtConfig, RateLimit } from './configs'; +import { DynamoConfig, EnvConfig, JwtConfig, RateLimit, SSOConfig, SSOGoogleConfig } from './configs'; import { AuthController, RegistrationController } from './controllers'; import { AuthHelper } from './helpers'; import { EnsureAuthenticatedMiddleware, ErrorHandlingMiddleware, RequestAuditMiddleware } from './middlewares'; -import { EmailTemplateRepository, SessionRepository, UserRepository, VerificationCodeRepository } from './repositories'; +import { + EmailTemplateRepository, + SessionRepository, + UserAuthProviderRepository, + UserRepository, + VerificationCodeRepository, +} from './repositories'; import { AppRouter, AuthRouter, RegistrationRouter } from './routers'; import { LoginConfirmService, @@ -14,6 +20,7 @@ import { RefreshLoginService, RegistrationConfirmService, RegistrationService, + SingleSignOnService, } from './services'; // Adapters @@ -22,7 +29,9 @@ container.registerSingleton('LoggerAdapter', LoggerAdapter); container.registerSingleton('EmailAdapter', EmailAdapter); // Configs +container.registerSingleton('SSOGoogleConfig', SSOGoogleConfig); container.registerSingleton('DynamoConfig', DynamoConfig); +container.registerSingleton('SSOConfig', SSOConfig); container.registerSingleton('EnvConfig', EnvConfig); container.registerSingleton('JwtConfig', JwtConfig); container.registerSingleton('RateLimit', RateLimit); @@ -36,6 +45,7 @@ container.registerSingleton('ErrorHandlingMiddleware', container.registerSingleton('RequestAuditMiddleware', RequestAuditMiddleware); // Repositories +container.registerSingleton('UserAuthProviderRepository', UserAuthProviderRepository); container.registerSingleton('VerificationCodeRepository', VerificationCodeRepository); container.registerSingleton('EmailTemplateRepository', EmailTemplateRepository); container.registerSingleton('SessionRepository', SessionRepository); @@ -46,6 +56,7 @@ container.registerSingleton('RegistrationConfirmServ container.registerSingleton('RegistrationService', RegistrationService); container.registerSingleton('RefreshLoginService', RefreshLoginService); container.registerSingleton('LoginConfirmService', LoginConfirmService); +container.registerSingleton('SingleSignOnService', SingleSignOnService); container.registerSingleton('LogoutService', LogoutService); container.registerSingleton('LoginService', LoginService); diff --git a/src/helpers/auth.helper.ts b/src/helpers/auth.helper.ts index 48133a5..e54ee34 100644 --- a/src/helpers/auth.helper.ts +++ b/src/helpers/auth.helper.ts @@ -4,7 +4,7 @@ import type { JwtConfig } from '@/configs'; import type { AuthHelperGenerateSessionDto, AuthHelper as AuthHelperInterface, - Session, + AuthenticationSession, SessionRepository, } from '@/interfaces'; @@ -19,8 +19,9 @@ export class AuthHelper implements AuthHelperInterface { ) {} public async createSession({ + provider, user_id, - }: AuthHelperGenerateSessionDto): Promise> { + }: AuthHelperGenerateSessionDto): Promise { const accessToken = this.jwtConfig.sign({ expiresIn: '15m', subject: user_id, @@ -40,6 +41,7 @@ export class AuthHelper implements AuthHelperInterface { refresh_token: refreshToken, access_token: accessToken, expires_at: expiresAt, + provider, user_id, }); diff --git a/src/interfaces/helpers/auth.helper.ts b/src/interfaces/helpers/auth.helper.ts index bad6c3d..e9e6029 100644 --- a/src/interfaces/helpers/auth.helper.ts +++ b/src/interfaces/helpers/auth.helper.ts @@ -1,11 +1,13 @@ import type { Session } from '../models/session'; +import type { UserAuthProviderEnum } from '../models/user-auth-provider'; export type AuthHelperGenerateSessionDto = { + provider: UserAuthProviderEnum; user_id: string; }; export interface AuthHelper { createSession( dto: AuthHelperGenerateSessionDto, - ): Promise>; + ): Promise>; } diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 5eda99f..f3fd8f4 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -11,13 +11,14 @@ export * from './middlewares/error.middleware'; export * from './middlewares/hook.middleware'; // Models +export * from './models/user-auth-provider'; export * from './models/verification-code'; export * from './models/register-token'; -export * from './models/user-token'; export * from './models/session'; export * from './models/user'; // Repositories +export * from './repositories/user-auth-provider.repository'; export * from './repositories/verification-code.repository'; export * from './repositories/email-template.repository'; export * from './repositories/session.repository'; @@ -28,6 +29,7 @@ export * from './routers/router'; // Services export * from './services/registration-confirm.service'; +export * from './services/single-sign-on.service'; export * from './services/login-confirm.service'; export * from './services/refresh-login.service'; export * from './services/registration.service'; diff --git a/src/interfaces/models/session.ts b/src/interfaces/models/session.ts index b3c544d..4369927 100644 --- a/src/interfaces/models/session.ts +++ b/src/interfaces/models/session.ts @@ -1,6 +1,11 @@ +import type { UserAuthProviderEnum } from './user-auth-provider'; + export type Session = { + provider: UserAuthProviderEnum; refresh_token: string; access_token: string; expires_at: string; user_id: string; }; + +export type AuthenticationSession = Omit; diff --git a/src/interfaces/models/user-auth-provider.ts b/src/interfaces/models/user-auth-provider.ts new file mode 100644 index 0000000..9356be0 --- /dev/null +++ b/src/interfaces/models/user-auth-provider.ts @@ -0,0 +1,12 @@ +export enum UserAuthProviderEnum { + Google = 'google', + Email = 'email', +} + +export type UserAuthProvider = { + provider: UserAuthProviderEnum; + provider_id: string; + user_id: string; + created_at?: string; + updated_at?: string; +}; diff --git a/src/interfaces/models/user-token.ts b/src/interfaces/models/user-token.ts deleted file mode 100644 index fff412e..0000000 --- a/src/interfaces/models/user-token.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum UserTokenTypeEnum { - RecoveryPassword = 'recovery-password', -} - -export type UserToken = { - type: UserTokenTypeEnum; - expires_at: string; - user_id: string; - id: string; -}; diff --git a/src/interfaces/models/user.ts b/src/interfaces/models/user.ts index 76ffb85..a01fc20 100644 --- a/src/interfaces/models/user.ts +++ b/src/interfaces/models/user.ts @@ -1,7 +1,7 @@ export type User = { id: string; name: string; - email: string; + email?: string; created_at?: string; updated_at?: string; }; diff --git a/src/interfaces/repositories/user-auth-provider.repository.ts b/src/interfaces/repositories/user-auth-provider.repository.ts new file mode 100644 index 0000000..0367556 --- /dev/null +++ b/src/interfaces/repositories/user-auth-provider.repository.ts @@ -0,0 +1,13 @@ +import type { + UserAuthProvider, + UserAuthProviderEnum, +} from '../models/user-auth-provider'; + +export interface UserAuthProviderRepository { + create(dto: UserAuthProvider): Promise; + + findOne(dto: { + provider: UserAuthProviderEnum; + provider_id: string; + }): Promise; +} diff --git a/src/interfaces/repositories/user.repository.ts b/src/interfaces/repositories/user.repository.ts index b47cd6e..ac44865 100644 --- a/src/interfaces/repositories/user.repository.ts +++ b/src/interfaces/repositories/user.repository.ts @@ -3,5 +3,5 @@ import type { User } from '../models/user'; export interface UserRepository { create(user: Omit): Promise; - findByEmail(dto: { email: string }): Promise; + findOne(dto: { id: string }): Promise; } diff --git a/src/interfaces/services/login-confirm.service.ts b/src/interfaces/services/login-confirm.service.ts index 07099e1..09f9480 100644 --- a/src/interfaces/services/login-confirm.service.ts +++ b/src/interfaces/services/login-confirm.service.ts @@ -1,4 +1,4 @@ -import type { Session } from '../models/session'; +import type { AuthenticationSession } from '../models/session'; export type LoginConfirmServiceDto = { token: string; @@ -6,5 +6,5 @@ export type LoginConfirmServiceDto = { }; export interface LoginConfirmService { - execute(dto: LoginConfirmServiceDto): Promise>; + execute(dto: LoginConfirmServiceDto): Promise; } diff --git a/src/interfaces/services/refresh-login.service.ts b/src/interfaces/services/refresh-login.service.ts index a93191e..dd2cf47 100644 --- a/src/interfaces/services/refresh-login.service.ts +++ b/src/interfaces/services/refresh-login.service.ts @@ -1,9 +1,9 @@ -import type { Session } from '../models/session'; +import type { AuthenticationSession } from '../models/session'; export type RefreshLoginServiceDto = { refresh_token: string; }; export interface RefreshLoginService { - execute(dto: RefreshLoginServiceDto): Promise>; + execute(dto: RefreshLoginServiceDto): Promise; } diff --git a/src/interfaces/services/single-sign-on.service.ts b/src/interfaces/services/single-sign-on.service.ts new file mode 100644 index 0000000..7fe27c2 --- /dev/null +++ b/src/interfaces/services/single-sign-on.service.ts @@ -0,0 +1,13 @@ +import type { AuthenticationSession } from '../models/session'; +import type { UserAuthProviderEnum } from '../models/user-auth-provider'; + +export type SingleSignOnServiceDto = { + provider: UserAuthProviderEnum; + providerId: string; + email?: string; + name: string; +}; + +export interface SingleSignOnService { + execute(data: SingleSignOnServiceDto): Promise; +} diff --git a/src/main.ts b/src/main.ts index a0e745f..7352ed3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ import 'reflect-metadata'; import { container } from 'tsyringe'; import { LoggerAdapter } from './adapters'; -import { EnvConfig, RateLimit } from './configs'; +import { EnvConfig, RateLimit, SSOConfig } from './configs'; import { HttpMethodEnum } from './constants'; import './container'; import { ErrorHandlingMiddleware, RequestAuditMiddleware } from './middlewares'; @@ -15,10 +15,11 @@ export const main = () => { const errorHandler = container.resolve(ErrorHandlingMiddleware); const requestAudit = container.resolve(RequestAuditMiddleware); + const logger = container.resolve(LoggerAdapter); const rateLimit = container.resolve(RateLimit); const envConfig = container.resolve(EnvConfig); const appRouter = container.resolve(AppRouter); - const logger = container.resolve(LoggerAdapter); + const ssoConfig = container.resolve(SSOConfig); app.setErrorHandler(errorHandler.middleware.bind(errorHandler)); app.addHook('preHandler', requestAudit.middleware.bind(requestAudit)); @@ -36,6 +37,7 @@ export const main = () => { }); app.register(appRouter.routes.bind(appRouter)); + app.register(ssoConfig.plugin.bind(ssoConfig)); return { app, logger, envConfig }; }; diff --git a/src/repositories/index.ts b/src/repositories/index.ts index 8e3ecfe..32823c0 100644 --- a/src/repositories/index.ts +++ b/src/repositories/index.ts @@ -1,4 +1,5 @@ export * from './email-template/email-template.repository'; +export * from './user-auth-provider.repository'; export * from './verification-code.repository'; export * from './session.repository'; export * from './user.repository'; diff --git a/src/repositories/user-auth-provider.repository.ts b/src/repositories/user-auth-provider.repository.ts new file mode 100644 index 0000000..2fc75f0 --- /dev/null +++ b/src/repositories/user-auth-provider.repository.ts @@ -0,0 +1,110 @@ +import { + GetItemCommand, + type GetItemInput, + PutItemCommand, + type PutItemCommandInput, +} from '@aws-sdk/client-dynamodb'; +import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; +import { inject, injectable } from 'tsyringe'; + +import { type DynamoConfig, DynamoPartitionKeysEnum } from '@/configs'; +import type { + LoggerAdapter, + UserAuthProvider, + UserAuthProviderEnum, + UserAuthProviderRepository as UserAuthProviderRepositoryInterface, +} from '@/interfaces'; + +/** DynamoDB structure + - PK: user-auth-provider + - SK: provider:{provider}::provider_id:{provider_id} + - Content: { user_id, provider_id, provider, created_at } + - TTL: INT + */ + +@injectable() +export class UserAuthProviderRepository + implements UserAuthProviderRepositoryInterface +{ + private readonly PK = DynamoPartitionKeysEnum.UserAuthProvider; + + constructor( + @inject('DynamoConfig') + private readonly dynamoConfig: DynamoConfig, + + @inject('LoggerAdapter') + private readonly logger: LoggerAdapter, + ) { + this.logger.setPrefix(this.logger, UserAuthProviderRepository.name); + } + + public async create({ + provider_id, + provider, + user_id, + }: UserAuthProvider): Promise { + try { + const userAuthProvider: UserAuthProvider = { + provider_id, + provider, + user_id, + created_at: new Date().toISOString(), + }; + + const params: PutItemCommandInput = { + TableName: this.dynamoConfig.tableName, + Item: marshall({ + PK: this.PK, + SK: `provider:${provider}::provider_id:${provider_id}`, + Content: userAuthProvider, + }), + }; + + await this.dynamoConfig.client.send(new PutItemCommand(params)); + + this.logger.info('Auth provider created:', userAuthProvider); + } catch (error) { + this.logger.error('Error creating auth provider:', error); + + throw error; + } + } + + public async findOne({ + provider_id, + provider, + }: { + provider: UserAuthProviderEnum; + provider_id: string; + }): Promise { + try { + const params: GetItemInput = { + TableName: this.dynamoConfig.tableName, + Key: marshall({ + PK: this.PK, + SK: `provider:${provider}::provider_id:${provider_id}`, + }), + }; + + const { Item } = await this.dynamoConfig.client.send( + new GetItemCommand(params), + ); + + if (!Item) { + return null; + } + + const userAuthProvider = unmarshall(Item) as { + Content: UserAuthProvider; + }; + + this.logger.debug('Auth provider retrieved:', userAuthProvider); + + return userAuthProvider.Content; + } catch (error) { + this.logger.error('Error retrieving verification code:', error); + + throw error; + } + } +} diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index 69250f9..baff49e 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -1,29 +1,24 @@ import { + GetItemCommand, + type GetItemInput, PutItemCommand, type PutItemCommandInput, - QueryCommand, - type QueryCommandInput, } from '@aws-sdk/client-dynamodb'; import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; import { inject, injectable } from 'tsyringe'; import { z } from 'zod'; +import { type DynamoConfig, DynamoPartitionKeysEnum } from '@/configs'; import { - type DynamoConfig, - DynamoGSIEnum, - DynamoPartitionKeysEnum, -} from '@/configs'; -import type { - LoggerAdapter, - UniqueIdAdapter, - User, - UserRepository as UserRepositoryInterface, + type LoggerAdapter, + type UniqueIdAdapter, + type User, + type UserRepository as UserRepositoryInterface, } from '@/interfaces'; /** DynamoDB structure - PK: user - SK: id:{id} - - ReferenceId: [reference_type]:{reference_value} - Content: { name, email, id, created_at } - TTL: INT */ @@ -32,8 +27,8 @@ import type { export class UserRepository implements UserRepositoryInterface { private readonly PK = DynamoPartitionKeysEnum.User; private readonly schema: z.ZodType> = z.object({ + email: z.string().email().optional(), name: z.string().min(2).max(255), - email: z.string().email(), }); constructor( @@ -64,7 +59,6 @@ export class UserRepository implements UserRepositoryInterface { Item: marshall({ PK: this.PK, SK: `id:${user.id}`, - ReferenceId: `email:${user.email}`, Content: user, }), }; @@ -81,33 +75,33 @@ export class UserRepository implements UserRepositoryInterface { } } - public async findByEmail(dto: { email: string }): Promise { + public async findOne({ id }: { id: string }): Promise { try { - const params: QueryCommandInput = { + const params: GetItemInput = { TableName: this.dynamoConfig.tableName, - IndexName: DynamoGSIEnum.ReferenceIdIndex, - KeyConditionExpression: 'PK = :pk AND ReferenceId = :reference', - ExpressionAttributeValues: marshall({ - ':pk': this.PK, - ':reference': `email:${dto.email}`, + Key: marshall({ + PK: this.PK, + SK: `id:${id}`, }), }; - const { Items } = await this.dynamoConfig.client.send( - new QueryCommand(params), + const { Item } = await this.dynamoConfig.client.send( + new GetItemCommand(params), ); - if (!Items || Items.length === 0) { + if (!Item) { return null; } - const user = unmarshall(Items[0]) as { Content: User }; + const user = unmarshall(Item) as { + Content: User; + }; - this.logger.debug('User found by email:', user); + this.logger.debug('User data retrieved:', user); return user.Content; } catch (error) { - this.logger.error('Error finding user by email:', error); + this.logger.error('Error retrieving verification code:', error); throw error; } diff --git a/src/services/index.ts b/src/services/index.ts index ee2c5f5..827e62d 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,4 +1,5 @@ export * from './registration-confirm.service'; +export * from './single-sign-on.service'; export * from './refresh-login.service'; export * from './login-confirm.service'; export * from './registration.service'; diff --git a/src/services/login-confirm.service.ts b/src/services/login-confirm.service.ts index 6282ad7..8d06c64 100644 --- a/src/services/login-confirm.service.ts +++ b/src/services/login-confirm.service.ts @@ -5,9 +5,11 @@ import { AppErrorCodeEnum, HttpStatusCodesEnum } from '@/constants'; import { AppError } from '@/errors'; import { type AuthHelper, + type AuthenticationSession, type LoginConfirmServiceDto, type LoginConfirmService as LoginConfirmServiceInterface, - type Session, + UserAuthProviderEnum, + type UserAuthProviderRepository, type UserRepository, type VerificationCodeRepository, VerificationCodeTypeEnum, @@ -16,6 +18,9 @@ import { @injectable() export class LoginConfirmService implements LoginConfirmServiceInterface { constructor( + @inject('UserAuthProviderRepository') + private readonly userAuthProviderRepository: UserAuthProviderRepository, + @inject('VerificationCodeRepository') private verificationCodeRepository: VerificationCodeRepository, @@ -43,7 +48,7 @@ export class LoginConfirmService implements LoginConfirmServiceInterface { public async execute({ token, code, - }: LoginConfirmServiceDto): Promise> { + }: LoginConfirmServiceDto): Promise { const decoded = this.jwtConfig.verify<{ sub: string }>({ secret: this.envConfig.JWT_SECRET_VERIFICATION_TOKEN, token, @@ -62,13 +67,27 @@ export class LoginConfirmService implements LoginConfirmServiceInterface { throw this.genericAuthError; } - const user = await this.userRepository.findByEmail({ email: decoded.sub }); + const authProvider = await this.userAuthProviderRepository.findOne({ + provider: UserAuthProviderEnum.Email, + provider_id: decoded.sub, + }); + + if (!authProvider) { + throw this.genericAuthError; + } + + const user = await this.userRepository.findOne({ + id: authProvider.user_id, + }); if (!user) { throw this.genericAuthError; } - const session = await this.authHelper.createSession({ user_id: user.id }); + const session = await this.authHelper.createSession({ + provider: UserAuthProviderEnum.Email, + user_id: user.id, + }); await this.verificationCodeRepository.deleteOne({ code_type: VerificationCodeTypeEnum.Login, diff --git a/src/services/login.service.ts b/src/services/login.service.ts index 279f844..f672da9 100644 --- a/src/services/login.service.ts +++ b/src/services/login.service.ts @@ -7,6 +7,8 @@ import { type EmailTemplateRepository, type LoginServiceDto, type LoginService as LoginServiceInterface, + UserAuthProviderEnum, + type UserAuthProviderRepository, type UserRepository, type VerificationCodeRepository, VerificationCodeTypeEnum, @@ -15,6 +17,9 @@ import { @injectable() export class LoginService implements LoginServiceInterface { constructor( + @inject('UserAuthProviderRepository') + private readonly userAuthProviderRepository: UserAuthProviderRepository, + @inject('EmailTemplateRepository') private readonly emailTemplateRepository: EmailTemplateRepository, @@ -35,15 +40,28 @@ export class LoginService implements LoginServiceInterface { ) {} public async execute({ email }: LoginServiceDto): Promise<{ token: string }> { - const user = await this.userRepository.findByEmail({ email }); - const token = this.jwtConfig.sign({ secret: this.envConfig.JWT_SECRET_VERIFICATION_TOKEN, expiresIn: '10m', subject: email, }); + const authProvider = await this.userAuthProviderRepository.findOne({ + provider: UserAuthProviderEnum.Email, + provider_id: email, + }); + + if (!authProvider) { + // Return token anyway to avoid brute force attacks to valid emails + return { token }; + } + + const user = await this.userRepository.findOne({ + id: authProvider.user_id, + }); + if (!user) { + // Return token anyway to avoid brute force attacks to valid emails return { token }; } diff --git a/src/services/refresh-login.service.ts b/src/services/refresh-login.service.ts index 8cb3a1e..7a76f9d 100644 --- a/src/services/refresh-login.service.ts +++ b/src/services/refresh-login.service.ts @@ -5,10 +5,10 @@ import { HttpStatusCodesEnum } from '@/constants'; import { AppError } from '@/errors'; import type { AuthHelper, + AuthenticationSession, LoggerAdapter, RefreshLoginServiceDto, RefreshLoginService as RefreshLoginServiceInterface, - Session, SessionRepository, } from '@/interfaces'; @@ -30,7 +30,7 @@ export class RefreshLoginService implements RefreshLoginServiceInterface { public async execute({ refresh_token: refreshToken, - }: RefreshLoginServiceDto): Promise> { + }: RefreshLoginServiceDto): Promise { const userId = this.getUserId({ refreshToken }); const session = await this.sessionRepository.findByUserId({ @@ -49,6 +49,7 @@ export class RefreshLoginService implements RefreshLoginServiceInterface { } const refreshedSession = await this.authHelper.createSession({ + provider: session.provider, user_id: session.user_id, }); diff --git a/src/services/registration-confirm.service.ts b/src/services/registration-confirm.service.ts index d43c85f..eb6cdb7 100644 --- a/src/services/registration-confirm.service.ts +++ b/src/services/registration-confirm.service.ts @@ -7,6 +7,8 @@ import { type RegistrationConfirmServiceDto, type RegistrationConfirmService as RegistrationConfirmServiceInterface, type User, + UserAuthProviderEnum, + type UserAuthProviderRepository, type UserRepository, type VerificationCode, type VerificationCodeRepository, @@ -18,12 +20,15 @@ export class RegistrationConfirmService implements RegistrationConfirmServiceInterface { constructor( - @inject('UserRepository') - private readonly userRepository: UserRepository, + @inject('UserAuthProviderRepository') + private userAuthProviderRepository: UserAuthProviderRepository, @inject('VerificationCodeRepository') private verificationCodeRepository: VerificationCodeRepository, + @inject('UserRepository') + private readonly userRepository: UserRepository, + @inject('JwtConfig') private readonly jwtConfig: JwtConfig, @@ -35,8 +40,6 @@ export class RegistrationConfirmService token, code, }: RegistrationConfirmServiceDto): Promise { - this.jwtConfig.verify<{ sub: string }>({ token }); - const verificationCode = await this.getVerificationToken({ token }); if (!verificationCode || verificationCode.code !== code) { @@ -48,13 +51,22 @@ export class RegistrationConfirmService }); } - const user = this.parseUser(verificationCode); + const parsedUser = this.parseUser(verificationCode); + + if (!parsedUser.email) { + throw new AppError({ + status_code: HttpStatusCodesEnum.INTERNAL_SERVER_ERROR, + error_code: AppErrorCodeEnum.Unknown, + message: 'Invalid user data', + }); + } - const userExists = await this.userRepository.findByEmail({ - email: user.email, + const authProvider = await this.userAuthProviderRepository.findOne({ + provider: UserAuthProviderEnum.Email, + provider_id: parsedUser.email, }); - if (userExists) { + if (authProvider) { throw new AppError({ error_code: AppErrorCodeEnum.EmailAlreadyInUse, status_code: HttpStatusCodesEnum.CONFLICT, @@ -62,7 +74,13 @@ export class RegistrationConfirmService }); } - await this.userRepository.create(user); + const user = await this.userRepository.create(parsedUser); + + await this.userAuthProviderRepository.create({ + provider: UserAuthProviderEnum.Email, + provider_id: parsedUser.email, + user_id: user.id, + }); await this.verificationCodeRepository.deleteOne({ code_type: VerificationCodeTypeEnum.Registration, @@ -103,6 +121,14 @@ export class RegistrationConfirmService verificationCode, ); + if (!userData.email) { + throw new AppError({ + status_code: HttpStatusCodesEnum.INTERNAL_SERVER_ERROR, + error_code: AppErrorCodeEnum.Unknown, + message: 'Invalid user data', + }); + } + return userData as Omit; } } diff --git a/src/services/registration.service.ts b/src/services/registration.service.ts index 2fd7d27..9a52739 100644 --- a/src/services/registration.service.ts +++ b/src/services/registration.service.ts @@ -10,7 +10,8 @@ import { type RegistrationServiceDto, type RegistrationService as RegistrationServiceInterface, type User, - type UserRepository, + UserAuthProviderEnum, + type UserAuthProviderRepository, type VerificationCodeRepository, VerificationCodeTypeEnum, } from '@/interfaces'; @@ -18,15 +19,15 @@ import { @injectable() export class RegistrationService implements RegistrationServiceInterface { constructor( + @inject('UserAuthProviderRepository') + private readonly userAuthProviderRepository: UserAuthProviderRepository, + @inject('EmailTemplateRepository') private readonly emailTemplateRepository: EmailTemplateRepository, @inject('VerificationCodeRepository') private verificationCodeRepository: VerificationCodeRepository, - @inject('UserRepository') - private readonly userRepository: UserRepository, - @inject('EmailAdapter') private readonly emailAdapter: EmailAdapter, @@ -41,11 +42,12 @@ export class RegistrationService implements RegistrationServiceInterface { email, name, }: RegistrationServiceDto): Promise<{ token: string }> { - const userExists = await this.userRepository.findByEmail({ - email, + const userAuth = await this.userAuthProviderRepository.findOne({ + provider: UserAuthProviderEnum.Email, + provider_id: email, }); - if (userExists) { + if (userAuth) { throw new AppError({ error_code: AppErrorCodeEnum.EmailAlreadyInUse, status_code: HttpStatusCodesEnum.CONFLICT, diff --git a/src/services/single-sign-on.service.ts b/src/services/single-sign-on.service.ts new file mode 100644 index 0000000..0362662 --- /dev/null +++ b/src/services/single-sign-on.service.ts @@ -0,0 +1,92 @@ +import { inject, injectable } from 'tsyringe'; + +import { + type AuthHelper, + type AuthenticationSession, + type SingleSignOnServiceDto, + type SingleSignOnService as SingleSignOnServiceInterface, + UserAuthProviderEnum, + type UserAuthProviderRepository, + type UserRepository, +} from '@/interfaces'; + +@injectable() +export class SingleSignOnService implements SingleSignOnServiceInterface { + constructor( + @inject('UserAuthProviderRepository') + private readonly userAuthProviderRepository: UserAuthProviderRepository, + + @inject('UserRepository') + private readonly userRepository: UserRepository, + + @inject('AuthHelper') + private readonly authHelper: AuthHelper, + ) {} + + public async execute({ + providerId, + provider, + email, + name, + }: SingleSignOnServiceDto): Promise { + const providerUserAuth = await this.userAuthProviderRepository.findOne({ + provider_id: providerId, + provider, + }); + + if (providerUserAuth) { + return this.authHelper.createSession({ + user_id: providerUserAuth.user_id, + provider, + }); + } + + const userId = await this.getOrCreateUserId({ email, name }); + + await this.userAuthProviderRepository.create({ + provider_id: providerId, + user_id: userId, + provider, + }); + + return this.authHelper.createSession({ user_id: userId, provider }); + } + + private async getOrCreateUserId({ + email, + name, + }: { + email?: string; + name: string; + }): Promise { + const userAuthEmail = await this.getAuthProviderEmail({ email }); + + if (userAuthEmail) { + return userAuthEmail.user_id; + } + + const user = await this.userRepository.create({ email, name }); + const userId = user.id; + + if (email) { + await this.userAuthProviderRepository.create({ + provider: UserAuthProviderEnum.Email, + provider_id: email, + user_id: userId, + }); + } + + return userId; + } + + private async getAuthProviderEmail({ email }: { email?: string }) { + if (!email) { + return null; + } + + return this.userAuthProviderRepository.findOne({ + provider: UserAuthProviderEnum.Email, + provider_id: email, + }); + } +} From e2cf22c48db22399f06ec94f71b806a3c930d893 Mon Sep 17 00:00:00 2001 From: ribeirogab Date: Sun, 22 Sep 2024 06:01:16 -0300 Subject: [PATCH 2/3] chore: update debug log in user-auth-provider repository --- src/repositories/user-auth-provider.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repositories/user-auth-provider.repository.ts b/src/repositories/user-auth-provider.repository.ts index 2fc75f0..4a37719 100644 --- a/src/repositories/user-auth-provider.repository.ts +++ b/src/repositories/user-auth-provider.repository.ts @@ -62,7 +62,7 @@ export class UserAuthProviderRepository await this.dynamoConfig.client.send(new PutItemCommand(params)); - this.logger.info('Auth provider created:', userAuthProvider); + this.logger.debug('Auth provider created:', userAuthProvider); } catch (error) { this.logger.error('Error creating auth provider:', error); From ced53e36ff473392ccd54204d0f95191b3aba6ac Mon Sep 17 00:00:00 2001 From: ribeirogab Date: Sun, 22 Sep 2024 06:02:43 -0300 Subject: [PATCH 3/3] chore: remove unused DynamoGSIEnum from dynamo.config.ts --- infra/lambda/main.tf | 13 +------------ src/configs/dynamo.config.ts | 4 ---- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/infra/lambda/main.tf b/infra/lambda/main.tf index 64081ff..75f5304 100644 --- a/infra/lambda/main.tf +++ b/infra/lambda/main.tf @@ -18,20 +18,10 @@ resource "aws_dynamodb_table" "auth_resource_table" { name = "SK" type = "S" } - attribute { - name = "ReferenceId" - type = "S" - } ttl { attribute_name = "TTL" enabled = true } - global_secondary_index { - name = "ReferenceIdIndex" - hash_key = "PK" - range_key = "ReferenceId" - projection_type = "ALL" - } tags = { Environment = var.environment } @@ -127,8 +117,7 @@ resource "aws_iam_role" "lambda_exec_role" { ] Effect = "Allow" Resource = [ - "${aws_dynamodb_table.auth_resource_table.arn}", - "${aws_dynamodb_table.auth_resource_table.arn}/index/ReferenceIdIndex" + "${aws_dynamodb_table.auth_resource_table.arn}" ] }, { diff --git a/src/configs/dynamo.config.ts b/src/configs/dynamo.config.ts index 3c83bfa..a97b21e 100644 --- a/src/configs/dynamo.config.ts +++ b/src/configs/dynamo.config.ts @@ -9,10 +9,6 @@ export enum DynamoPartitionKeysEnum { User = 'user', } -export enum DynamoGSIEnum { - ReferenceIdIndex = 'ReferenceIdIndex', -} - @injectable() export class DynamoConfig { public readonly client: DynamoDBClient;