From 07c3bcf421fad154365c5eb4133b378881e98766 Mon Sep 17 00:00:00 2001 From: Yasir Abdulsalam Date: Mon, 22 Jun 2026 21:23:02 +0100 Subject: [PATCH 1/2] feat: add OAuth2 social authentication (Google, GitHub, Twitter) - Add SocialAccount entity to store linked social provider accounts - Implement Twitter/X OAuth provider alongside existing Google and GitHub - Add account linking logic to connect social accounts to existing users - Add OAuthController with endpoints for auth, callback, link, and unlink - Add audit logging for all OAuth login and account linking attempts - Add getLinkedProviders endpoint to list connected social accounts - Update User entity with socialAccounts relation - Update AuthModule to register SocialAccount entity and OAuthController - Add OAuth env vars to .env.example Closes # --- .env.example | 16 + package-lock.json | 89 +--- src/core/auth/auth.module.ts | 7 +- src/core/auth/dto/oauth.dto.ts | 26 ++ .../auth/entities/social-account.entity.ts | 57 +++ src/core/auth/oauth.controller.ts | 99 +++++ .../auth/strategies/oauth/oauth.strategy.ts | 383 +++++++++++------- src/core/user/entities/user.entity.ts | 5 + 8 files changed, 456 insertions(+), 226 deletions(-) create mode 100644 src/core/auth/dto/oauth.dto.ts create mode 100644 src/core/auth/entities/social-account.entity.ts create mode 100644 src/core/auth/oauth.controller.ts diff --git a/.env.example b/.env.example index deaaba4..a96109a 100644 --- a/.env.example +++ b/.env.example @@ -82,3 +82,19 @@ REFERRAL_ENABLE_BOT_DETECTION=true # Enable VPN/Proxy detection (requires external service) REFERRAL_ENABLE_VPN_DETECTION=false + +# OAuth Configuration +AUTH_OAUTH_ENABLED=true +OAUTH_REDIRECT_URI=http://localhost:3000/auth/oauth/callback + +# Google OAuth +OAUTH_GOOGLE_CLIENT_ID=your_google_client_id +OAUTH_GOOGLE_CLIENT_SECRET=your_google_client_secret + +# GitHub OAuth +OAUTH_GITHUB_CLIENT_ID=your_github_client_id +OAUTH_GITHUB_CLIENT_SECRET=your_github_client_secret + +# Twitter/X OAuth +OAUTH_TWITTER_CLIENT_ID=your_twitter_client_id +OAUTH_TWITTER_CLIENT_SECRET=your_twitter_client_secret diff --git a/package-lock.json b/package-lock.json index 7776472..20f38a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1030,7 +1030,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, "license": "MIT", "optional": true }, @@ -2911,7 +2910,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -2924,7 +2922,6 @@ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2939,7 +2936,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2952,7 +2948,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -2974,7 +2969,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -2989,7 +2983,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -6392,7 +6385,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -7356,7 +7348,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true, "license": "ISC", "optional": true }, @@ -7453,7 +7444,6 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7467,7 +7457,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7659,7 +7648,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "dev": true, "license": "ISC", "optional": true }, @@ -7668,7 +7656,6 @@ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", "deprecated": "This package is no longer supported.", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8392,7 +8379,6 @@ "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8423,7 +8409,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8436,7 +8421,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8458,7 +8442,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8472,7 +8455,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8486,7 +8468,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8501,7 +8482,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -8518,7 +8498,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -8751,7 +8730,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -8895,7 +8873,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, "license": "ISC", "optional": true, "bin": { @@ -8962,7 +8939,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -8990,7 +8967,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, "license": "ISC", "optional": true }, @@ -9371,7 +9347,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -9715,7 +9690,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -9726,7 +9700,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, "license": "MIT", "optional": true }, @@ -11304,7 +11277,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -11370,7 +11343,6 @@ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", "deprecated": "This package is no longer supported.", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11391,7 +11363,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, "license": "ISC", "optional": true }, @@ -11708,7 +11679,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/graphemer": { @@ -11841,7 +11812,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, "license": "ISC", "optional": true }, @@ -11925,7 +11895,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, "license": "BSD-2-Clause", "optional": true }, @@ -11953,7 +11922,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -11992,7 +11960,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12101,7 +12068,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -12111,7 +12078,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -12122,7 +12088,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, "license": "ISC", "optional": true }, @@ -12131,7 +12096,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -12509,7 +12474,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -13967,7 +13931,6 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -13996,7 +13959,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14010,7 +13972,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14024,7 +13985,6 @@ "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -14035,7 +13995,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -14253,7 +14212,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14267,7 +14225,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14281,7 +14238,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -14289,7 +14245,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14308,7 +14263,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14322,7 +14276,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -14330,7 +14283,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "dev": true, "license": "BlueOak-1.0.0", "optional": true, "dependencies": { @@ -14344,7 +14296,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14358,7 +14309,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -14366,7 +14316,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14380,7 +14329,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14394,7 +14342,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -14402,7 +14349,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14416,7 +14362,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14430,7 +14375,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -14679,7 +14623,6 @@ "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14731,7 +14674,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14744,7 +14686,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14766,7 +14707,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14781,7 +14721,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14914,7 +14853,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -14955,7 +14893,6 @@ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", "deprecated": "This package is no longer supported.", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -15277,7 +15214,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -15405,7 +15341,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15501,7 +15437,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", - "dev": true, "license": "MIT", "optional": true }, @@ -15941,7 +15876,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, "license": "ISC", "optional": true }, @@ -15949,7 +15883,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -16565,7 +16498,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -17187,7 +17119,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -17299,7 +17230,6 @@ "version": "2.8.9", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -17315,7 +17245,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -17447,7 +17376,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -17461,7 +17389,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -17475,7 +17402,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC", "optional": true }, @@ -18943,7 +18869,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -18954,7 +18879,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -19382,7 +19306,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, "license": "ISC", "optional": true, "dependencies": { diff --git a/src/core/auth/auth.module.ts b/src/core/auth/auth.module.ts index 39c83c5..d6715cd 100644 --- a/src/core/auth/auth.module.ts +++ b/src/core/auth/auth.module.ts @@ -1,3 +1,6 @@ +import { SocialAccount } from "./entities/social-account.entity"; +import { OAuthController } from "./oauth.controller"; +import { AuditLogService } from "src/infrastructure/audit/audit-log.service"; import { Module, OnModuleInit } from "@nestjs/common"; import { JwtModule } from "@nestjs/jwt"; import { PassportModule } from "@nestjs/passport"; @@ -74,10 +77,11 @@ import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity"; Wallet, RefreshToken, TwoFactorAuth, + SocialAccount, ]), AuditModule, ], - controllers: [AuthController], + controllers: [AuthController, OAuthController], providers: [ // Legacy services (for backward compatibility) AuthService, @@ -102,6 +106,7 @@ import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity"; ApiKeyStrategy, StrategyAuthGuard, AdminTwoFactorGuard, + AuditLogService, ], exports: [ // Legacy exports diff --git a/src/core/auth/dto/oauth.dto.ts b/src/core/auth/dto/oauth.dto.ts new file mode 100644 index 0000000..a30b5fb --- /dev/null +++ b/src/core/auth/dto/oauth.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsNotEmpty, IsOptional } from "class-validator"; + +export class OAuthCallbackDto { + @ApiProperty({ description: "Authorization code from OAuth provider" }) + @IsString() + @IsNotEmpty() + code: string; + + @ApiProperty({ description: "State parameter for CSRF protection", required: false }) + @IsString() + @IsOptional() + state?: string; +} + +export class OAuthLinkDto { + @ApiProperty({ description: "Authorization code from OAuth provider" }) + @IsString() + @IsNotEmpty() + code: string; + + @ApiProperty({ description: "State parameter", required: false }) + @IsString() + @IsOptional() + state?: string; +} diff --git a/src/core/auth/entities/social-account.entity.ts b/src/core/auth/entities/social-account.entity.ts new file mode 100644 index 0000000..9efa699 --- /dev/null +++ b/src/core/auth/entities/social-account.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, + Unique, +} from "typeorm"; +import { User } from "src/core/user/entities/user.entity"; + +export enum SocialProvider { + GOOGLE = "google", + GITHUB = "github", + TWITTER = "twitter", +} + +@Entity("social_accounts") +@Unique(["provider", "providerUserId"]) +export class SocialAccount { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "uuid" }) + @Index() + userId: string; + + @ManyToOne(() => User, (user) => user.socialAccounts, { onDelete: "CASCADE" }) + @JoinColumn({ name: "userId" }) + user: User; + + @Column({ type: "varchar" }) + provider: SocialProvider; + + @Column() + providerUserId: string; + + @Column({ nullable: true }) + email: string | null; + + @Column({ nullable: true }) + displayName: string | null; + + @Column({ nullable: true }) + avatarUrl: string | null; + + @Column({ default: false }) + emailVerified: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/core/auth/oauth.controller.ts b/src/core/auth/oauth.controller.ts new file mode 100644 index 0000000..b348fbc --- /dev/null +++ b/src/core/auth/oauth.controller.ts @@ -0,0 +1,99 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + UseGuards, + Request, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, +} from "@nestjs/swagger"; +import { OAuthStrategy } from "./strategies/oauth/oauth.strategy"; +import { JwtAuthGuard } from "./jwt.guard"; +import { Public } from "src/common/decorators/public.decorator"; +import { OAuthCallbackDto, OAuthLinkDto } from "./dto/oauth.dto"; +import { v4 as uuidv4 } from "uuid"; + +@ApiTags("OAuth / Social Auth") +@Controller("auth/oauth") +export class OAuthController { + constructor(private readonly oauthStrategy: OAuthStrategy) {} + + @Public() + @Get(":provider") + @ApiOperation({ summary: "Get OAuth authorization URL for a provider" }) + @ApiParam({ name: "provider", enum: ["google", "github", "twitter"] }) + @ApiResponse({ status: 200, description: "Returns the authorization URL" }) + getAuthorizationUrl(@Param("provider") provider: string) { + const state = uuidv4(); + const url = this.oauthStrategy.getAuthorizationUrl(provider, state); + return { url, state }; + } + + @Public() + @Post(":provider/callback") + @ApiOperation({ summary: "Handle OAuth callback and issue JWT" }) + @ApiParam({ name: "provider", enum: ["google", "github", "twitter"] }) + @ApiResponse({ status: 200, description: "Returns JWT token and user info" }) + @ApiResponse({ status: 401, description: "OAuth authentication failed" }) + async handleCallback( + @Param("provider") provider: string, + @Body() dto: OAuthCallbackDto, + ) { + return this.oauthStrategy.authenticate({ provider, code: dto.code, state: dto.state }); + } + + @Post(":provider/link") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: "Link a social provider to your existing account" }) + @ApiParam({ name: "provider", enum: ["google", "github", "twitter"] }) + @ApiResponse({ status: 200, description: "Social account linked successfully" }) + async linkProvider( + @Param("provider") provider: string, + @Body() dto: OAuthLinkDto, + @Request() req, + ) { + const userId = req.user.sub || req.user.id; + return this.oauthStrategy.linkProvider(userId, provider, dto.code); + } + + @Delete(":provider/unlink") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: "Unlink a social provider from your account" }) + @ApiParam({ name: "provider", enum: ["google", "github", "twitter"] }) + @ApiResponse({ status: 200, description: "Social account unlinked successfully" }) + async unlinkProvider( + @Param("provider") provider: string, + @Request() req, + ) { + const userId = req.user.sub || req.user.id; + return this.oauthStrategy.unlinkProvider(userId, provider); + } + + @Get("providers/linked") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: "Get all linked social providers for current user" }) + @ApiResponse({ status: 200, description: "List of linked providers" }) + async getLinkedProviders(@Request() req) { + const userId = req.user.sub || req.user.id; + return this.oauthStrategy.getLinkedProviders(userId); + } + + @Public() + @Get("providers/available") + @ApiOperation({ summary: "Get list of available OAuth providers" }) + @ApiResponse({ status: 200, description: "List of enabled providers" }) + getAvailableProviders() { + return { providers: this.oauthStrategy.getAvailableProviders() }; + } +} diff --git a/src/core/auth/strategies/oauth/oauth.strategy.ts b/src/core/auth/strategies/oauth/oauth.strategy.ts index 5ea4248..90fe537 100644 --- a/src/core/auth/strategies/oauth/oauth.strategy.ts +++ b/src/core/auth/strategies/oauth/oauth.strategy.ts @@ -15,10 +15,9 @@ import { OAuthCredentials, } from "../interfaces/auth-strategy.interface"; import { User } from "src/core/user/entities/user.entity"; +import { SocialAccount, SocialProvider } from "../../entities/social-account.entity"; +import { AuditLogService } from "src/infrastructure/audit/audit-log.service"; -/** - * OAuth provider configuration - */ interface OAuthProviderConfig { clientId: string; clientSecret: string; @@ -28,20 +27,13 @@ interface OAuthProviderConfig { scopes: string[]; } -/** - * OAuth user info from provider - */ interface OAuthUserInfo { id: string; - email: string; + email: string | null; name?: string; picture?: string; } -/** - * OAuth authentication strategy - * Supports multiple OAuth providers (Google, GitHub, etc.) - */ @Injectable() export class OAuthStrategy implements AuthStrategy { readonly name = "oauth"; @@ -53,21 +45,18 @@ export class OAuthStrategy implements AuthStrategy { private readonly jwtService: JwtService, @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(SocialAccount) + private readonly socialAccountRepository: Repository, + private readonly auditLogService: AuditLogService, ) { this.initializeProviders(); } - /** - * Initialize OAuth providers from configuration - */ private initializeProviders(): void { - // Google OAuth if (this.configService.get("OAUTH_GOOGLE_CLIENT_ID")) { this.providers.set("google", { clientId: this.configService.get("OAUTH_GOOGLE_CLIENT_ID")!, - clientSecret: this.configService.get( - "OAUTH_GOOGLE_CLIENT_SECRET", - )!, + clientSecret: this.configService.get("OAUTH_GOOGLE_CLIENT_SECRET")!, authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo", @@ -75,13 +64,10 @@ export class OAuthStrategy implements AuthStrategy { }); } - // GitHub OAuth if (this.configService.get("OAUTH_GITHUB_CLIENT_ID")) { this.providers.set("github", { clientId: this.configService.get("OAUTH_GITHUB_CLIENT_ID")!, - clientSecret: this.configService.get( - "OAUTH_GITHUB_CLIENT_SECRET", - )!, + clientSecret: this.configService.get("OAUTH_GITHUB_CLIENT_SECRET")!, authorizationUrl: "https://github.com/login/oauth/authorize", tokenUrl: "https://github.com/login/oauth/access_token", userInfoUrl: "https://api.github.com/user", @@ -89,12 +75,20 @@ export class OAuthStrategy implements AuthStrategy { }); } + if (this.configService.get("OAUTH_TWITTER_CLIENT_ID")) { + this.providers.set("twitter", { + clientId: this.configService.get("OAUTH_TWITTER_CLIENT_ID")!, + clientSecret: this.configService.get("OAUTH_TWITTER_CLIENT_SECRET")!, + authorizationUrl: "https://twitter.com/i/oauth2/authorize", + tokenUrl: "https://api.twitter.com/2/oauth2/token", + userInfoUrl: "https://api.twitter.com/2/users/me?user.fields=profile_image_url", + scopes: ["tweet.read", "users.read"], + }); + } + this.logger.log(`Initialized ${this.providers.size} OAuth providers`); } - /** - * Check if OAuth strategy is enabled - */ get isEnabled(): boolean { return ( this.configService.get("AUTH_OAUTH_ENABLED", false) && @@ -102,25 +96,37 @@ export class OAuthStrategy implements AuthStrategy { ); } - /** - * Get available OAuth providers - */ getAvailableProviders(): string[] { return Array.from(this.providers.keys()); } - /** - * Authenticate using OAuth - * @param credentials - OAuth credentials containing provider and code - * @returns Authentication result with JWT token - */ + getAuthorizationUrl(provider: string, state: string): string { + const config = this.providers.get(provider); + if (!config) { + throw new BadRequestException(`Unsupported OAuth provider: ${provider}`); + } + + const redirectUri = this.configService.get( + "OAUTH_REDIRECT_URI", + "http://localhost:3000/auth/oauth/callback", + ); + + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: `${redirectUri}/${provider}`, + response_type: "code", + scope: config.scopes.join(" "), + state, + }); + + return `${config.authorizationUrl}?${params.toString()}`; + } + async authenticate(credentials: unknown): Promise { const { provider, code } = credentials as OAuthCredentials; if (!provider || !code) { - throw new BadRequestException( - "Provider and authorization code are required", - ); + throw new BadRequestException("Provider and authorization code are required"); } const providerConfig = this.providers.get(provider); @@ -128,157 +134,250 @@ export class OAuthStrategy implements AuthStrategy { throw new BadRequestException(`Unsupported OAuth provider: ${provider}`); } - // Exchange code for access token - const accessToken = await this.exchangeCodeForToken(providerConfig, code); + try { + const accessToken = await this.exchangeCodeForToken(providerConfig, provider, code); + const userInfo = await this.getUserInfo(providerConfig, provider, accessToken); + const user = await this.findOrCreateUser(userInfo, provider); + + await this.auditLogService.recordVerification({ + action: "oauth_login", + userId: user.id, + provider, + email: userInfo.email, + timestamp: new Date().toISOString(), + }); - // Get user info from provider - const userInfo = await this.getUserInfo(providerConfig, accessToken); + return this.issueToken(user, provider); + } catch (error) { + await this.auditLogService.recordVerification({ + action: "oauth_login_failed", + provider, + error: (error as Error).message, + timestamp: new Date().toISOString(), + }); + throw error; + } + } - // Find or create user - const user = await this.findOrCreateUser(userInfo, provider); + async linkProvider(userId: string, provider: string, code: string): Promise<{ message: string }> { + const providerConfig = this.providers.get(provider); + if (!providerConfig) { + throw new BadRequestException(`Unsupported OAuth provider: ${provider}`); + } - // Generate JWT token - const payload: AuthPayload = { - sub: user.id, - email: user.email, - username: user.username, - role: user.role || "user", - iat: Math.floor(Date.now() / 1000), - type: "oauth", - }; + const accessToken = await this.exchangeCodeForToken(providerConfig, provider, code); + const userInfo = await this.getUserInfo(providerConfig, provider, accessToken); - const token = this.jwtService.sign(payload); + const existingSocial = await this.socialAccountRepository.findOne({ + where: { provider: provider as SocialProvider, providerUserId: userInfo.id }, + }); - this.logger.log( - `User authenticated via OAuth (${provider}): ${user.email}`, - ); + if (existingSocial && existingSocial.userId !== userId) { + throw new BadRequestException( + `This ${provider} account is already linked to another user`, + ); + } - return { - token, - user: { - id: user.id, - email: user.email, - username: user.username, - role: user.role || "user", - type: "oauth", - }, - }; + if (!existingSocial) { + const social = this.socialAccountRepository.create({ + userId, + provider: provider as SocialProvider, + providerUserId: userInfo.id, + email: userInfo.email, + displayName: userInfo.name, + avatarUrl: userInfo.picture, + emailVerified: !!userInfo.email, + }); + await this.socialAccountRepository.save(social); + } + + await this.auditLogService.recordVerification({ + action: "oauth_account_linked", + userId, + provider, + timestamp: new Date().toISOString(), + }); + + return { message: `${provider} account linked successfully` }; + } + + async unlinkProvider(userId: string, provider: string): Promise<{ message: string }> { + const social = await this.socialAccountRepository.findOne({ + where: { userId, provider: provider as SocialProvider }, + }); + + if (!social) { + throw new BadRequestException(`No ${provider} account linked to this user`); + } + + await this.socialAccountRepository.remove(social); + + await this.auditLogService.recordVerification({ + action: "oauth_account_unlinked", + userId, + provider, + timestamp: new Date().toISOString(), + }); + + return { message: `${provider} account unlinked successfully` }; + } + + async getLinkedProviders(userId: string): Promise<{ provider: string; email: string | null; linkedAt: Date }[]> { + const accounts = await this.socialAccountRepository.find({ where: { userId } }); + return accounts.map((a) => ({ + provider: a.provider, + email: a.email, + linkedAt: a.createdAt, + })); } - /** - * Exchange authorization code for access token - */ private async exchangeCodeForToken( config: OAuthProviderConfig, + provider: string, code: string, ): Promise { - try { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - client_secret: config.clientSecret, - code, - grant_type: "authorization_code", - redirect_uri: this.configService.get( - "OAUTH_REDIRECT_URI", - "http://localhost:3000/auth/oauth/callback", - ), - }), - }); + const redirectUri = this.configService.get( + "OAUTH_REDIRECT_URI", + "http://localhost:3000/auth/oauth/callback", + ); - if (!response.ok) { - throw new UnauthorizedException( - "Failed to exchange OAuth code for token", - ); - } + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; - const data = await response.json(); - return data.access_token; - } catch (error) { - this.logger.error("OAuth token exchange failed", error); - throw new UnauthorizedException("OAuth authentication failed"); + if (provider === "twitter") { + const credentials = Buffer.from( + `${config.clientId}:${config.clientSecret}`, + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + grant_type: "authorization_code", + redirect_uri: `${redirectUri}/${provider}`, + }), + }); + + if (!response.ok) { + this.logger.error(`Token exchange failed for ${provider}: ${response.status}`); + throw new UnauthorizedException("Failed to exchange OAuth code for token"); } + + const data = await response.json(); + return data.access_token; } - /** - * Get user info from OAuth provider - */ private async getUserInfo( config: OAuthProviderConfig, + provider: string, accessToken: string, ): Promise { - try { - const response = await fetch(config.userInfoUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + const response = await fetch(config.userInfoUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + throw new UnauthorizedException("Failed to fetch user info from OAuth provider"); + } - if (!response.ok) { - throw new UnauthorizedException( - "Failed to fetch user info from OAuth provider", - ); - } + const data = await response.json(); - const data = await response.json(); + if (provider === "twitter") { + const twitterUser = data.data || data; return { - id: data.id || data.sub, - email: data.email, - name: data.name || data.login, - picture: data.picture || data.avatar_url, + id: twitterUser.id, + email: null, + name: twitterUser.name, + picture: twitterUser.profile_image_url, }; - } catch (error) { - this.logger.error("OAuth user info fetch failed", error); - throw new UnauthorizedException("OAuth authentication failed"); } + + return { + id: String(data.id || data.sub), + email: data.email || null, + name: data.name || data.login, + picture: data.picture || data.avatar_url, + }; } - /** - * Find existing user or create new one - */ - private async findOrCreateUser( - userInfo: OAuthUserInfo, - provider: string, - ): Promise { - // Try to find user by email - let user = await this.userRepository.findOne({ - where: { email: userInfo.email }, + private async findOrCreateUser(userInfo: OAuthUserInfo, provider: string): Promise { + const existingSocial = await this.socialAccountRepository.findOne({ + where: { provider: provider as SocialProvider, providerUserId: userInfo.id }, + relations: ["user"], }); + if (existingSocial) { + return existingSocial.user; + } + + let user: User | null = null; + if (userInfo.email) { + user = await this.userRepository.findOne({ where: { email: userInfo.email } }); + } + if (!user) { - // Create new user user = this.userRepository.create({ email: userInfo.email, - username: userInfo.name || `oauth_${userInfo.id}`, + username: userInfo.name + ? `${userInfo.name.replace(/\s+/g, "_").toLowerCase()}_${Math.random().toString(36).slice(2, 6)}` + : `${provider}_${userInfo.id}`, walletAddress: `oauth_${provider}_${userInfo.id}`, - emailVerified: true, + emailVerified: !!userInfo.email, }); await this.userRepository.save(user); - this.logger.log(`Created new user from OAuth: ${userInfo.email}`); + this.logger.log(`Created new user from OAuth (${provider}): ${userInfo.email}`); } + const social = this.socialAccountRepository.create({ + userId: user.id, + provider: provider as SocialProvider, + providerUserId: userInfo.id, + email: userInfo.email, + displayName: userInfo.name, + avatarUrl: userInfo.picture, + emailVerified: !!userInfo.email, + }); + await this.socialAccountRepository.save(social); + return user; } - /** - * Validate a JWT token - * @param token - The JWT token to validate - * @returns The decoded payload or null if invalid - */ + private issueToken(user: User, provider: string): AuthResult { + const payload: AuthPayload = { + sub: user.id, + email: user.email ?? undefined, + username: user.username ?? undefined, + role: user.role || "user", + iat: Math.floor(Date.now() / 1000), + type: "oauth", + }; + + const token = this.jwtService.sign(payload); + + return { + token, + user: { + id: user.id, + email: user.email ?? undefined, + username: user.username ?? undefined, + role: user.role || "user", + type: "oauth", + }, + }; + } + async validateToken(token: string): Promise { try { return this.jwtService.verify(token) as AuthPayload; - } catch (error) { - this.logger.warn("Token validation failed", error); + } catch { return null; } } } - - - diff --git a/src/core/user/entities/user.entity.ts b/src/core/user/entities/user.entity.ts index a0d57d4..8c9c5cb 100644 --- a/src/core/user/entities/user.entity.ts +++ b/src/core/user/entities/user.entity.ts @@ -1,3 +1,4 @@ +import { SocialAccount } from "src/core/auth/entities/social-account.entity"; import { Entity, PrimaryGeneratedColumn, @@ -98,6 +99,10 @@ export class User { @OneToMany(() => User, (user) => user.referredBy) referrals: User[]; +@OneToMany(() => SocialAccount, (social) => social.user) +socialAccounts: SocialAccount[]; + + } From 86255dad4ebcc8df16b258b2fce2f71d8bd90889 Mon Sep 17 00:00:00 2001 From: Yasir Abdulsalam Date: Wed, 24 Jun 2026 13:01:10 +0100 Subject: [PATCH 2/2] fix: add socialAccounts relation to User entity and update test mocks --- src/core/auth/auth.service.spec.ts | 1 + src/core/auth/wallet-auth.service.spec.ts | 1 + src/core/user/entities/user.entity.ts | 8 ++------ 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/core/auth/auth.service.spec.ts b/src/core/auth/auth.service.spec.ts index d560206..448b5fb 100644 --- a/src/core/auth/auth.service.spec.ts +++ b/src/core/auth/auth.service.spec.ts @@ -43,6 +43,7 @@ describe("AuthService", () => { referredById: null, referredBy: null, referrals: [], + socialAccounts: [], }; const mockJwtService = { diff --git a/src/core/auth/wallet-auth.service.spec.ts b/src/core/auth/wallet-auth.service.spec.ts index 22c4d1d..d7ab0e7 100644 --- a/src/core/auth/wallet-auth.service.spec.ts +++ b/src/core/auth/wallet-auth.service.spec.ts @@ -95,6 +95,7 @@ describe("WalletAuthService", () => { referredById: null, referredBy: null, referrals: [], + socialAccounts: [], }; const mockChallengeService = { diff --git a/src/core/user/entities/user.entity.ts b/src/core/user/entities/user.entity.ts index 8c9c5cb..d09e4d1 100644 --- a/src/core/user/entities/user.entity.ts +++ b/src/core/user/entities/user.entity.ts @@ -99,11 +99,7 @@ export class User { @OneToMany(() => User, (user) => user.referredBy) referrals: User[]; -@OneToMany(() => SocialAccount, (social) => social.user) -socialAccounts: SocialAccount[]; - + @OneToMany(() => SocialAccount, (social) => social.user) + socialAccounts: SocialAccount[]; } - - -