feat: implement Google OAuth authentication #1

Merged
andrey merged 1 commits from 001-google-oauth-auth into main 2026-01-29 16:37:28 +00:00
32 changed files with 2477 additions and 98 deletions

View File

@@ -15,3 +15,16 @@ YOUTUBE_API_KEY=YOUR_YOUTUBE_API_KEY_HERE
# Upload # Upload
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
MAX_FILE_SIZE=5242880 MAX_FILE_SIZE=5242880
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:4000/api/auth/google/callback
# JWT Configuration
JWT_SECRET=your-secure-random-secret-min-32-characters-long
JWT_ACCESS_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
# Frontend URL (for OAuth redirects)
FRONTEND_URL=http://localhost:3000

View File

@@ -13,11 +13,17 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"multer": "^2.0.2", "multer": "^2.0.2",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"pg": "^8.17.2", "pg": "^8.17.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@@ -32,10 +38,14 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0", "@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@@ -2714,6 +2724,29 @@
} }
} }
}, },
"node_modules/@nestjs/jwt": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
"integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "9.0.10",
"jsonwebtoken": "9.0.3"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/passport": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
"integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"passport": "^0.5.0 || ^0.6.0 || ^0.7.0"
}
},
"node_modules/@nestjs/platform-express": { "node_modules/@nestjs/platform-express": {
"version": "11.1.12", "version": "11.1.12",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz",
@@ -3376,6 +3409,16 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -3395,6 +3438,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": { "node_modules/@types/cookiejar": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -3511,12 +3564,28 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true "dev": true
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/methods": { "node_modules/@types/methods": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
"dev": true "dev": true
}, },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/multer": { "node_modules/@types/multer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
@@ -3530,11 +3599,76 @@
"version": "22.19.7", "version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"devOptional": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/oauth": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
"integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/passport-google-oauth20": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz",
"integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*",
"@types/passport-oauth2": "*"
}
},
"node_modules/@types/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "*",
"@types/passport-strategy": "*"
}
},
"node_modules/@types/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/oauth": "*",
"@types/passport": "*"
}
},
"node_modules/@types/passport-strategy": {
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -4836,6 +4970,15 @@
} }
] ]
}, },
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.19", "version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -4845,6 +4988,20 @@
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
} }
}, },
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bin-version": { "node_modules/bin-version": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/bin-version/-/bin-version-6.0.0.tgz", "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-6.0.0.tgz",
@@ -5021,6 +5178,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -5468,6 +5631,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -5783,6 +5965,15 @@
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -8129,6 +8320,49 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -8241,6 +8475,42 @@
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true "dev": true
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -8253,6 +8523,12 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true "dev": true
}, },
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-symbols": { "node_modules/log-symbols": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -8608,6 +8884,15 @@
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"dev": true "dev": true
}, },
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-emoji": { "node_modules/node-emoji": {
"version": "1.11.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
@@ -8617,6 +8902,17 @@
"lodash": "^4.17.21" "lodash": "^4.17.21"
} }
}, },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-int64": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -8662,6 +8958,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/oauth": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
"license": "MIT"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -8846,6 +9148,64 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-google-oauth20": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
"license": "MIT",
"dependencies": {
"base64url": "3.x.x",
"oauth": "0.10.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -8921,6 +9281,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/pend": { "node_modules/pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -11068,6 +11433,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
"license": "MIT"
},
"node_modules/uint8array-extras": { "node_modules/uint8array-extras": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
@@ -11092,8 +11463,7 @@
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
"devOptional": true
}, },
"node_modules/universalify": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
@@ -11156,6 +11526,15 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
}, },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "11.1.0", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",

View File

@@ -24,11 +24,17 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"multer": "^2.0.2", "multer": "^2.0.2",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"pg": "^8.17.2", "pg": "^8.17.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@@ -43,10 +49,14 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0", "@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",

View File

@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ThumbnailsModule } from './modules/thumbnails/thumbnails.module'; import { ThumbnailsModule } from './modules/thumbnails/thumbnails.module';
import { YouTubeModule } from './modules/youtube/youtube.module'; import { YouTubeModule } from './modules/youtube/youtube.module';
import { AuthModule } from './modules/auth/auth.module';
@Module({ @Module({
imports: [ imports: [
@@ -25,6 +26,7 @@ import { YouTubeModule } from './modules/youtube/youtube.module';
}), }),
ThumbnailsModule, ThumbnailsModule,
YouTubeModule, YouTubeModule,
AuthModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -0,0 +1,36 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
userId: string;
@ManyToOne(() => User, (user) => user.refreshTokens, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column({ unique: true })
@Index('idx_refresh_token_token')
token: string;
@Column({ type: 'timestamp' })
expiresAt: Date;
@CreateDateColumn()
createdAt: Date;
@Column({ type: 'timestamp', nullable: true })
revokedAt: Date;
}

View File

@@ -0,0 +1,38 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
OneToMany,
} from 'typeorm';
import { RefreshToken } from './refresh-token.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
@Index('idx_user_google_id')
googleId: string;
@Column({ unique: true })
@Index('idx_user_email')
email: string;
@Column()
displayName: string;
@Column({ nullable: true })
avatarUrl: string;
@CreateDateColumn()
createdAt: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
lastLoginAt: Date;
@OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user)
refreshTokens: RefreshToken[];
}

View File

@@ -3,10 +3,14 @@ import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { join } from 'path'; import { join } from 'path';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import * as cookieParser from 'cookie-parser';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Cookie parser for refresh tokens
app.use(cookieParser());
// Global prefix // Global prefix
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
@@ -19,8 +23,9 @@ async function bootstrap() {
); );
// CORS // CORS
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
app.enableCors({ app.enableCors({
origin: ['http://localhost:3000', 'http://localhost:5173'], origin: [frontendUrl, 'http://localhost:3000', 'http://localhost:5173'],
credentials: true, credentials: true,
}); });

View File

@@ -0,0 +1,114 @@
import {
Controller,
Get,
Post,
UseGuards,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { GoogleAuthGuard } from './guards/google-auth.guard';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { User } from '../../entities/user.entity';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {}
@Get('google')
@UseGuards(GoogleAuthGuard)
googleAuth() {
// Guard redirects to Google
}
@Get('google/callback')
@UseGuards(GoogleAuthGuard)
async googleCallback(@Req() req: Request, @Res() res: Response) {
try {
const user = req.user as User;
const { accessToken, refreshToken } =
await this.authService.generateTokens(user);
// Set HttpOnly cookie for refresh token
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// Redirect to frontend with access token
const frontendUrl = this.configService.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
res.redirect(`${frontendUrl}/auth/callback?token=${accessToken}`);
} catch {
const frontendUrl = this.configService.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
res.redirect(`${frontendUrl}/login?error=auth_failed`);
}
}
@Post('refresh')
async refresh(@Req() req: Request, @Res() res: Response) {
const refreshToken = (req.cookies as Record<string, string>)?.refreshToken;
if (!refreshToken) {
throw new UnauthorizedException('No refresh token provided');
}
const result = await this.authService.refreshTokens(refreshToken);
// Set new HttpOnly cookie for refresh token
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res.json({
accessToken: result.accessToken,
user: result.user,
});
}
@Get('me')
@UseGuards(JwtAuthGuard)
getMe(@Req() req: Request) {
const user = req.user as User;
return {
id: user.id,
email: user.email,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
};
}
@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Req() req: Request, @Res() res: Response) {
const refreshToken = (req.cookies as Record<string, string>)?.refreshToken;
if (refreshToken) {
await this.authService.revokeRefreshToken(refreshToken);
}
res.clearCookie('refreshToken', {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
sameSite: 'strict',
});
return res.json({ message: 'Successfully logged out' });
}
}

View File

@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy';
import { User } from '../../entities/user.entity';
import { RefreshToken } from '../../entities/refresh-token.entity';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService): JwtModuleOptions => {
return {
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: '15m',
},
};
},
inject: [ConfigService],
}),
TypeOrmModule.forFeature([User, RefreshToken]),
],
controllers: [AuthController],
providers: [AuthService, GoogleStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,158 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, MoreThan } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from '../../entities/user.entity';
import { RefreshToken } from '../../entities/refresh-token.entity';
interface GoogleProfile {
id: string;
emails: Array<{ value: string; verified: boolean }>;
displayName: string;
photos: Array<{ value: string }>;
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(RefreshToken)
private readonly refreshTokenRepository: Repository<RefreshToken>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async validateOAuthUser(profile: GoogleProfile): Promise<User> {
const email = profile.emails?.[0]?.value;
const googleId = profile.id;
const displayName = profile.displayName;
const avatarUrl = profile.photos?.[0]?.value;
if (!email || !googleId) {
throw new UnauthorizedException('Invalid Google profile');
}
let user = await this.userRepository.findOne({
where: { googleId },
});
if (user) {
// Update last login and profile info
user.lastLoginAt = new Date();
user.displayName = displayName;
user.avatarUrl = avatarUrl;
await this.userRepository.save(user);
} else {
// Create new user
user = this.userRepository.create({
googleId,
email,
displayName,
avatarUrl,
lastLoginAt: new Date(),
});
await this.userRepository.save(user);
}
return user;
}
async generateTokens(
user: User,
): Promise<{ accessToken: string; refreshToken: string }> {
// Generate access token
const payload = { sub: user.id, email: user.email };
const accessToken = this.jwtService.sign(payload);
// Generate refresh token
const refreshTokenValue = this.generateRandomToken();
const hashedToken = await bcrypt.hash(refreshTokenValue, 10);
// Calculate expiration (7 days)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
// Save refresh token to database
const refreshTokenEntity = this.refreshTokenRepository.create({
userId: user.id,
token: hashedToken,
expiresAt,
});
await this.refreshTokenRepository.save(refreshTokenEntity);
return {
accessToken,
refreshToken: `${refreshTokenEntity.id}:${refreshTokenValue}`,
};
}
async refreshTokens(
token: string,
): Promise<{ accessToken: string; refreshToken: string; user: User }> {
const [tokenId, tokenValue] = token.split(':');
if (!tokenId || !tokenValue) {
throw new UnauthorizedException('Invalid refresh token format');
}
const storedToken = await this.refreshTokenRepository.findOne({
where: {
id: tokenId,
revokedAt: IsNull(),
expiresAt: MoreThan(new Date()),
},
relations: ['user'],
});
if (!storedToken) {
throw new UnauthorizedException('Invalid or expired refresh token');
}
const isValid = await bcrypt.compare(tokenValue, storedToken.token);
if (!isValid) {
throw new UnauthorizedException('Invalid refresh token');
}
// Revoke old token (rotation)
await this.revokeRefreshToken(token);
// Generate new tokens
const { accessToken, refreshToken } = await this.generateTokens(
storedToken.user,
);
return {
accessToken,
refreshToken,
user: storedToken.user,
};
}
async revokeRefreshToken(token: string): Promise<void> {
const [tokenId] = token.split(':');
if (tokenId) {
await this.refreshTokenRepository.update(
{ id: tokenId },
{ revokedAt: new Date() },
);
}
}
async validateUserById(userId: string): Promise<User | null> {
return this.userRepository.findOne({ where: { id: userId } });
}
private generateRandomToken(): string {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 64; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
}

View File

@@ -0,0 +1,28 @@
import { IsString, IsUUID, IsEmail, IsOptional, IsUrl } from 'class-validator';
export class UserResponseDto {
@IsUUID()
id: string;
@IsEmail()
email: string;
@IsString()
displayName: string;
@IsOptional()
@IsUrl()
avatarUrl?: string;
}
export class AuthResponseDto {
@IsString()
accessToken: string;
user: UserResponseDto;
}
export class MessageResponseDto {
@IsString()
message: string;
}

View File

@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback, Profile, StrategyOptions } from 'passport-google-oauth20';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
const options: StrategyOptions = {
clientID: configService.get<string>('GOOGLE_CLIENT_ID') || '',
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') || '',
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL') || '',
scope: ['email', 'profile'],
};
super(options);
}
async validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
done: VerifyCallback,
): Promise<void> {
try {
const user = await this.authService.validateOAuthUser({
id: profile.id,
emails: profile.emails as Array<{ value: string; verified: boolean }>,
displayName: profile.displayName,
photos: profile.photos as Array<{ value: string }>,
});
done(null, user);
} catch (error) {
done(error as Error, undefined);
}
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,41 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import {
Strategy,
ExtractJwt,
StrategyOptionsWithoutRequest,
} from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
interface JwtPayload {
sub: string;
email: string;
iat: number;
exp: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
const options: StrategyOptionsWithoutRequest = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET') || 'fallback-secret',
};
super(options);
}
async validate(payload: JwtPayload) {
const user = await this.authService.validateUserById(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
return user;
}
}

View File

@@ -1,100 +1,29 @@
import { ThumbnailUploader } from './components/ThumbnailUploader'; import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { SearchInput } from './components/SearchInput'; import { LandingPage } from './pages/LandingPage';
import { ThumbnailSelector } from './components/ThumbnailSelector'; import { ToolPage } from './pages/ToolPage';
import { ViewSwitcher } from './components/ViewSwitcher'; import { LogoPreview } from './pages/LogoPreview';
import { PreviewGrid } from './components/PreviewGrid'; import { LoginPage } from './pages/LoginPage';
import { UserInfoInputs } from './components/UserInfoInputs'; import { AuthCallbackPage } from './pages/AuthCallbackPage';
import { usePreviewStore } from './store/previewStore'; import { AuthGuard } from './components/AuthGuard';
function App() { function App() {
const { thumbnails, youtubeResults } = usePreviewStore();
const hasContent = thumbnails.length > 0 || youtubeResults.length > 0;
return ( return (
<div className="min-h-screen bg-gray-950 text-white"> <BrowserRouter>
{/* Header */} <Routes>
<header className="border-b border-gray-800"> <Route path="/" element={<LandingPage />} />
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between"> <Route path="/login" element={<LoginPage />} />
<div className="flex items-center gap-2"> <Route path="/auth/callback" element={<AuthCallbackPage />} />
<div className="w-10 h-10 bg-red-600 rounded-lg flex items-center justify-center"> <Route
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> path="/tool"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} element={
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /> <AuthGuard>
</svg> <ToolPage />
</div> </AuthGuard>
<span className="text-xl font-bold">ThumbPreview</span> }
</div> />
<Route path="/logo-preview" element={<LogoPreview />} />
<nav className="flex items-center gap-4"> </Routes>
<a href="#" className="text-gray-400 hover:text-white transition-colors"> </BrowserRouter>
Pricing
</a>
<button className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg font-medium transition-colors">
Sign In
</button>
</nav>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
{!hasContent ? (
/* Landing / Upload Section */
<div className="max-w-2xl mx-auto text-center space-y-8">
<div className="space-y-4">
<h1 className="text-4xl font-bold">
See how your thumbnail looks
<span className="text-red-500"> before publishing</span>
</h1>
<p className="text-xl text-gray-400">
Preview your YouTube thumbnails against real competitors.
Make data-driven decisions to maximize clicks.
</p>
</div>
<ThumbnailUploader />
<div className="pt-4">
<p className="text-gray-500 text-sm mb-4">Then search for competitors:</p>
<SearchInput />
</div>
</div>
) : (
/* Preview Section */
<div className="space-y-6">
{/* Controls */}
<div className="flex flex-col lg:flex-row gap-6 items-start lg:items-end justify-between">
<div className="flex-1 w-full lg:w-auto">
<SearchInput />
</div>
<ViewSwitcher />
</div>
{/* Thumbnail Management */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<ThumbnailSelector />
<UserInfoInputs />
</div>
<div>
<p className="text-sm text-gray-400 mb-2">Add more thumbnails</p>
<ThumbnailUploader />
</div>
</div>
{/* Preview */}
<PreviewGrid />
</div>
)}
</main>
{/* Footer */}
<footer className="border-t border-gray-800 mt-16">
<div className="max-w-7xl mx-auto px-4 py-8 text-center text-gray-500 text-sm">
<p>ThumbPreview - Preview your YouTube thumbnails before publishing</p>
<p className="mt-2">Built with React + NestJS</p>
</div>
</footer>
</div>
); );
} }

94
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,94 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';
import type { AuthResponse, User } from '../types/auth';
const authClient = axios.create({
baseURL: '/api/auth',
withCredentials: true,
});
// Token refresh state
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value: string) => void;
reject: (error: Error) => void;
}> = [];
const processQueue = (error: Error | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token!);
}
});
failedQueue = [];
};
// Response interceptor for token refresh
authClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return authClient(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const response = await authClient.post<AuthResponse>('/refresh');
const { accessToken } = response.data;
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return authClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError as Error, null);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);
export const authApi = {
refreshToken: async (): Promise<AuthResponse> => {
const response = await authClient.post<AuthResponse>('/refresh');
return response.data;
},
getMe: async (accessToken: string): Promise<User> => {
const response = await authClient.get<User>('/me', {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
logout: async (accessToken: string): Promise<void> => {
await authClient.post(
'/logout',
{},
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
},
};
export { authClient };

View File

@@ -0,0 +1,33 @@
import { type ReactNode, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
interface AuthGuardProps {
children: ReactNode;
}
export function AuthGuard({ children }: AuthGuardProps) {
const { isAuthenticated, isLoading, checkAuth } = useAuth();
const location = useLocation();
useEffect(() => {
if (!isAuthenticated && !isLoading) {
checkAuth();
}
}, [isAuthenticated, isLoading, checkAuth]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
// Preserve the intended destination for post-login redirect
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,46 @@
import { LogOut, User as UserIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAuth } from '../hooks/useAuth';
export function UserMenu() {
const { user, logout, isLoading } = useAuth();
if (!user) return null;
const handleLogout = async () => {
await logout();
window.location.href = '/login';
};
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.displayName}
className="h-8 w-8 rounded-full"
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
<UserIcon className="h-4 w-4 text-muted-foreground" />
</div>
)}
<span className="hidden text-sm font-medium sm:inline">
{user.displayName}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
disabled={isLoading}
className="gap-2"
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Sign out</span>
</Button>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useCallback } from 'react';
import { useAuthStore } from '../store/authStore';
import { authApi } from '../api/auth';
export function useAuth() {
const {
user,
accessToken,
isLoading,
isAuthenticated,
setAuth,
clearAuth,
setLoading,
logout,
} = useAuthStore();
const checkAuth = useCallback(async () => {
setLoading(true);
try {
// Try to refresh the token using the HttpOnly cookie
const response = await authApi.refreshToken();
setAuth(response.user, response.accessToken);
} catch (error) {
// No valid session
clearAuth();
}
}, [setAuth, clearAuth, setLoading]);
useEffect(() => {
// Only check auth on mount if we don't already have a user
if (!user && !accessToken) {
checkAuth();
} else {
setLoading(false);
}
}, []);
return {
user,
accessToken,
isLoading,
isAuthenticated,
checkAuth,
logout,
};
}

View File

@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { authApi } from '../api/auth';
export function AuthCallbackPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setAuth, clearAuth } = useAuthStore();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handleCallback = async () => {
const token = searchParams.get('token');
const errorParam = searchParams.get('error');
if (errorParam) {
clearAuth();
navigate(`/login?error=${errorParam}`, { replace: true });
return;
}
if (!token) {
clearAuth();
navigate('/login?error=invalid_request', { replace: true });
return;
}
try {
// Fetch user info with the access token
const user = await authApi.getMe(token);
setAuth(user, token);
// Get stored redirect destination
const redirectTo = sessionStorage.getItem('auth_redirect') || '/tool';
sessionStorage.removeItem('auth_redirect');
// Navigate to intended destination
navigate(redirectTo, { replace: true });
} catch (err) {
console.error('Auth callback error:', err);
setError('Failed to complete authentication');
clearAuth();
navigate('/login?error=auth_failed', { replace: true });
}
};
handleCallback();
}, [searchParams, navigate, setAuth, clearAuth]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-destructive">{error}</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-muted-foreground">Completing sign in...</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useEffect } from 'react';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuthStore } from '../store/authStore';
export function LoginPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const { isAuthenticated, isLoading } = useAuthStore();
const error = searchParams.get('error');
const from = (location.state as { from?: string })?.from || '/tool';
useEffect(() => {
if (isAuthenticated && !isLoading) {
navigate(from, { replace: true });
}
}, [isAuthenticated, isLoading, navigate, from]);
const handleGoogleLogin = () => {
// Store intended destination in sessionStorage for callback
sessionStorage.setItem('auth_redirect', from);
// Redirect to backend OAuth endpoint
window.location.href = '/api/auth/google';
};
const getErrorMessage = (errorCode: string | null): string | null => {
if (!errorCode) return null;
const errorMessages: Record<string, string> = {
auth_failed: 'Authentication failed. Please try again.',
access_denied: 'Access was denied. Please grant the required permissions.',
invalid_request: 'Invalid request. Please try again.',
};
return errorMessages[errorCode] || 'An error occurred during sign in.';
};
const errorMessage = getErrorMessage(error);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">
Welcome to ThumbPreview
</CardTitle>
<p className="text-muted-foreground">
Sign in to start previewing your thumbnails
</p>
</CardHeader>
<CardContent className="space-y-4">
{errorMessage && (
<div className="rounded-md bg-destructive/10 p-3 text-center text-sm text-destructive">
{errorMessage}
</div>
)}
<Button
onClick={handleGoogleLogin}
className="w-full"
size="lg"
>
<svg
className="mr-2 h-5 w-5"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Sign in with Google
</Button>
<p className="text-center text-xs text-muted-foreground">
By signing in, you agree to our terms of service and privacy policy.
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { create } from 'zustand';
import type { User } from '../types/auth';
import { authApi } from '../api/auth';
interface AuthStore {
user: User | null;
accessToken: string | null;
isLoading: boolean;
isAuthenticated: boolean;
setAuth: (user: User, accessToken: string) => void;
clearAuth: () => void;
setLoading: (isLoading: boolean) => void;
logout: () => Promise<void>;
}
export const useAuthStore = create<AuthStore>((set, get) => ({
user: null,
accessToken: null,
isLoading: true,
isAuthenticated: false,
setAuth: (user: User, accessToken: string) => {
set({
user,
accessToken,
isAuthenticated: true,
isLoading: false,
});
},
clearAuth: () => {
set({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: false,
});
},
setLoading: (isLoading: boolean) => {
set({ isLoading });
},
logout: async () => {
try {
const token = get().accessToken;
if (token) {
await authApi.logout(token);
}
} catch (error) {
console.error('Logout error:', error);
} finally {
set({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: false,
});
}
},
}));

View File

@@ -0,0 +1,18 @@
export interface User {
id: string;
email: string;
displayName: string;
avatarUrl?: string;
}
export interface AuthState {
user: User | null;
accessToken: string | null;
isLoading: boolean;
isAuthenticated: boolean;
}
export interface AuthResponse {
accessToken: string;
user: User;
}

View File

@@ -0,0 +1,41 @@
# Specification Quality Checklist: Google OAuth Authentication Screen
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-29
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
All checklist items pass validation. The specification is ready for `/speckit.clarify` or `/speckit.plan`.
**Validation Summary**:
- 3 user stories covering sign-in, session persistence, and sign-out
- 10 functional requirements, all testable
- 5 success criteria with measurable outcomes
- 4 edge cases identified
- Assumptions clearly documented

View File

@@ -0,0 +1,236 @@
openapi: 3.0.3
info:
title: ThumbPreview Auth API
description: Authentication endpoints for Google OAuth integration
version: 1.0.0
servers:
- url: /api/auth
description: Auth API base path
paths:
/google:
get:
summary: Initiate Google OAuth flow
description: Redirects user to Google consent screen. Called via browser navigation (not AJAX).
operationId: initiateGoogleAuth
tags:
- OAuth
responses:
'302':
description: Redirect to Google OAuth consent screen
headers:
Location:
schema:
type: string
description: Google OAuth authorization URL
/google/callback:
get:
summary: Google OAuth callback
description: |
Handles Google OAuth callback. Creates/updates user, issues tokens,
redirects to frontend with access token.
operationId: googleCallback
tags:
- OAuth
parameters:
- name: code
in: query
required: true
schema:
type: string
description: OAuth authorization code from Google
- name: state
in: query
required: false
schema:
type: string
description: OAuth state parameter (CSRF protection)
responses:
'302':
description: Redirect to frontend with access token
headers:
Location:
schema:
type: string
description: Frontend callback URL with token parameter
Set-Cookie:
schema:
type: string
description: HttpOnly refresh token cookie
'401':
description: OAuth validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/refresh:
post:
summary: Refresh access token
description: |
Uses refresh token from HttpOnly cookie to issue new access token.
Rotates refresh token on each use.
operationId: refreshToken
tags:
- Session
security: []
responses:
'200':
description: New tokens issued
headers:
Set-Cookie:
schema:
type: string
description: New HttpOnly refresh token cookie
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'401':
description: Invalid or expired refresh token
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/me:
get:
summary: Get current user
description: Returns the authenticated user's profile information
operationId: getCurrentUser
tags:
- User
security:
- bearerAuth: []
responses:
'200':
description: User profile
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'401':
description: Not authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/logout:
post:
summary: Sign out user
description: |
Revokes current refresh token and clears cookie.
Access token remains valid until expiration (short-lived).
operationId: logout
tags:
- Session
security:
- bearerAuth: []
responses:
'200':
description: Successfully logged out
headers:
Set-Cookie:
schema:
type: string
description: Cleared refresh token cookie
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
'401':
description: Not authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT access token in Authorization header
schemas:
AuthResponse:
type: object
required:
- accessToken
- user
properties:
accessToken:
type: string
description: JWT access token (15 min expiry)
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
user:
$ref: '#/components/schemas/UserResponse'
UserResponse:
type: object
required:
- id
- email
- displayName
properties:
id:
type: string
format: uuid
description: User unique identifier
example: 550e8400-e29b-41d4-a716-446655440000
email:
type: string
format: email
description: User email address
example: user@example.com
displayName:
type: string
description: User display name
example: John Doe
avatarUrl:
type: string
format: uri
nullable: true
description: User profile picture URL
example: https://lh3.googleusercontent.com/a/...
MessageResponse:
type: object
required:
- message
properties:
message:
type: string
description: Success message
example: Successfully logged out
ErrorResponse:
type: object
required:
- statusCode
- message
properties:
statusCode:
type: integer
description: HTTP status code
example: 401
message:
type: string
description: Error message
example: Invalid or expired token
error:
type: string
description: Error type
example: Unauthorized
tags:
- name: OAuth
description: Google OAuth authentication flow
- name: Session
description: Token management and session operations
- name: User
description: User profile operations

View File

@@ -0,0 +1,102 @@
# Data Model: Google OAuth Authentication
**Feature**: 001-google-oauth-auth
**Date**: 2026-01-29
## Entities
### User
Represents an authenticated user in the system.
| Field | Type | Constraints | Description |
|-------|------|-------------|-------------|
| id | UUID | PK, auto-generated | Unique identifier |
| googleId | string | UNIQUE, NOT NULL, indexed | Google account identifier |
| email | string | UNIQUE, NOT NULL, indexed | User's email from Google |
| displayName | string | NOT NULL | User's display name from Google |
| avatarUrl | string | NULLABLE | Profile picture URL from Google |
| createdAt | timestamp | NOT NULL, auto | Account creation timestamp |
| lastLoginAt | timestamp | NOT NULL | Last successful login timestamp |
**Indexes**:
- `idx_user_google_id` on `googleId` (OAuth lookup)
- `idx_user_email` on `email` (user queries)
**Validation Rules**:
- `googleId`: Non-empty string from Google profile
- `email`: Valid email format (validated by Google)
- `displayName`: Non-empty string, max 255 characters
### RefreshToken
Tracks issued refresh tokens for session management and revocation.
| Field | Type | Constraints | Description |
|-------|------|-------------|-------------|
| id | UUID | PK, auto-generated | Unique identifier |
| userId | UUID | FK → User.id, NOT NULL | Associated user |
| token | string | UNIQUE, NOT NULL, indexed | Hashed refresh token |
| expiresAt | timestamp | NOT NULL | Token expiration time |
| createdAt | timestamp | NOT NULL, auto | Token creation timestamp |
| revokedAt | timestamp | NULLABLE | Revocation timestamp (soft delete) |
**Indexes**:
- `idx_refresh_token_token` on `token` (token lookup)
- `idx_refresh_token_user_id` on `userId` (user token queries)
**Validation Rules**:
- `expiresAt`: Must be in the future when created
- `token`: Hashed with bcrypt or similar before storage
**Lifecycle**:
- Created: On successful OAuth login
- Active: `revokedAt` is NULL and `expiresAt` > now
- Revoked: When user logs out or token is rotated
- Expired: `expiresAt` <= now (cleaned up periodically)
## Relationships
```
User 1 ──────< RefreshToken
(one-to-many)
```
- One User can have multiple RefreshTokens (multiple devices/sessions)
- When User is deleted, cascade delete all RefreshTokens
## State Transitions
### User Lifecycle
```
[New] ──(first OAuth login)──> [Active]
(subsequent logins)
[Active] (lastLoginAt updated)
```
### RefreshToken Lifecycle
```
[Created] ──(user logout)──> [Revoked]
├──(token refresh)──> [Rotated/New Created]
└──(time passes)──> [Expired]
```
## Data Volume Assumptions
- Users: Expected < 10,000 initially
- RefreshTokens: ~1-3 per user (multiple devices)
- Token cleanup: Daily job to remove expired/revoked tokens older than 30 days
## Migration Notes
New tables required:
1. `users` - Core user table
2. `refresh_tokens` - Token tracking for session management
No modifications to existing tables (`thumbnails`, `youtube_cache`).

View File

@@ -0,0 +1,126 @@
# Implementation Plan: Google OAuth Authentication Screen
**Branch**: `001-google-oauth-auth` | **Date**: 2026-01-29 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-google-oauth-auth/spec.md`
## Summary
Implement a Google OAuth 2.0 authentication screen that gates access to the ThumbPreview tool. Users must authenticate via Google before accessing protected routes (/tool). The implementation uses redirect-based OAuth flow with session persistence (7-day validity), preserving the user's originally requested destination after login. Backend handles OAuth callback and session management; frontend provides auth UI and route protection.
## Technical Context
**Language/Version**: TypeScript 5.x (frontend + backend)
**Primary Dependencies**:
- Frontend: React 19.x, React Router 7.x, Zustand 5.x, @tanstack/react-query 5.x, shadcn/ui
- Backend: NestJS 11.x, TypeORM 0.3.x, @nestjs/passport, passport-google-oauth20
**Storage**: PostgreSQL (users, sessions via TypeORM)
**Testing**: Jest (backend), manual testing (frontend)
**Target Platform**: Web (desktop + mobile browsers)
**Project Type**: Web application (frontend + backend monorepo)
**Performance Goals**: OAuth flow completion < 30 seconds, session validation < 100ms
**Constraints**: Must work with existing Vite proxy setup, no .env in frontend
**Scale/Scope**: Single user role, 7-day session validity with refresh
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Tech Stack | ✅ PASS | Using React 19, NestJS 11, TypeORM, PostgreSQL per constitution |
| II. Architecture | ✅ PASS | Following monorepo structure: frontend/src/, backend/src/modules/ |
| III. Styling & UI | ✅ PASS | Will use shadcn/ui Button, Card components with Tailwind |
| IV. Data Management | ✅ PASS | Zustand for auth state, React Query for user data, TypeORM for persistence |
| V. Development Practices | ✅ PASS | TypeScript strict mode, class-validator DTOs, ESLint |
**New Dependencies Required**:
- `@nestjs/passport` + `passport` + `passport-google-oauth20` - OAuth authentication (NestJS official, MIT license)
- `@types/passport-google-oauth20` - TypeScript types
**Justification**: Passport.js is the de facto standard for Node.js authentication. @nestjs/passport provides official NestJS integration. These are well-maintained, MIT-licensed, and have minimal bundle impact (backend only).
## Project Structure
### Documentation (this feature)
```text
specs/001-google-oauth-auth/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
│ └── auth-api.yaml # OpenAPI spec for auth endpoints
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)
```text
backend/
├── src/
│ ├── modules/
│ │ ├── auth/ # NEW: Authentication module
│ │ │ ├── auth.module.ts
│ │ │ ├── auth.controller.ts
│ │ │ ├── auth.service.ts
│ │ │ ├── google.strategy.ts
│ │ │ ├── jwt.strategy.ts
│ │ │ ├── guards/
│ │ │ │ ├── jwt-auth.guard.ts
│ │ │ │ └── google-auth.guard.ts
│ │ │ └── dto/
│ │ │ └── auth-response.dto.ts
│ │ ├── thumbnails/ # Existing
│ │ └── youtube/ # Existing
│ └── entities/
│ ├── user.entity.ts # NEW
│ ├── thumbnail.entity.ts # Existing
│ └── youtube-cache.entity.ts # Existing
└── tests/
frontend/
├── src/
│ ├── components/
│ │ ├── AuthGuard.tsx # NEW: Route protection wrapper
│ │ └── UserMenu.tsx # NEW: User avatar + sign out
│ ├── pages/
│ │ ├── LoginPage.tsx # NEW: Auth screen
│ │ ├── AuthCallbackPage.tsx # NEW: OAuth callback handler
│ │ ├── LandingPage.tsx # Existing (public)
│ │ └── ToolPage.tsx # Existing (protected)
│ ├── store/
│ │ └── authStore.ts # NEW: Auth state (Zustand)
│ ├── api/
│ │ └── auth.ts # NEW: Auth API client
│ └── hooks/
│ └── useAuth.ts # NEW: Auth hook
└── tests/
```
**Structure Decision**: Web application structure with frontend/backend separation per constitution. Auth module added to backend following NestJS module pattern. Frontend gets new pages for login flow and components for route protection.
## Complexity Tracking
No constitution violations. All implementation follows established patterns.
---
## Post-Design Constitution Re-Check
*Re-validated after Phase 1 design completion.*
| Principle | Status | Validation |
|-----------|--------|------------|
| I. Tech Stack | ✅ PASS | Dependencies align: @nestjs/passport, passport-google-oauth20 (MIT, maintained) |
| II. Architecture | ✅ PASS | Auth module follows NestJS pattern, frontend follows components/pages/store structure |
| III. Styling & UI | ✅ PASS | LoginPage will use shadcn/ui Button, Card; Tailwind for layout |
| IV. Data Management | ✅ PASS | User entity uses UUID PK, Zustand for auth state, TypeORM for persistence |
| V. Development Practices | ✅ PASS | DTOs with class-validator, explicit TypeScript types, ESLint compliance |
**Phase 1 Artifacts Generated**:
-`research.md` - OAuth implementation patterns documented
-`data-model.md` - User and RefreshToken entities defined
-`contracts/auth-api.yaml` - OpenAPI spec for 5 auth endpoints
-`quickstart.md` - Setup and testing guide
-`CLAUDE.md` - Agent context updated with new technologies

View File

@@ -0,0 +1,127 @@
# Quickstart: Google OAuth Authentication
**Feature**: 001-google-oauth-auth
**Date**: 2026-01-29
## Prerequisites
1. **Google Cloud Console Setup**
- Create a project at https://console.cloud.google.com
- Enable "Google+ API" or "Google Identity" API
- Configure OAuth consent screen (External, if not Google Workspace)
- Create OAuth 2.0 credentials (Web application type)
- Add authorized redirect URI: `http://localhost:3000/api/auth/google/callback`
2. **Environment Variables**
Add to `backend/.env`:
```env
# Google OAuth
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
# JWT Configuration
JWT_SECRET=your-secure-random-secret-min-32-chars
JWT_ACCESS_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
# Frontend URL (for redirects)
FRONTEND_URL=http://localhost:5173
```
## Installation
### Backend Dependencies
```bash
cd backend
npm install @nestjs/passport @nestjs/jwt passport passport-google-oauth20 bcrypt
npm install -D @types/passport-google-oauth20 @types/bcrypt
```
### Frontend Dependencies
No additional dependencies required. Uses existing:
- react-router-dom (routing)
- zustand (auth state)
- axios (API calls)
- shadcn/ui (UI components)
## Database Migration
Create and run migration for new tables:
```bash
cd backend
npm run typeorm:generate -- -n CreateAuthTables
npm run typeorm:run
```
Tables created:
- `users` (id, googleId, email, displayName, avatarUrl, createdAt, lastLoginAt)
- `refresh_tokens` (id, userId, token, expiresAt, createdAt, revokedAt)
## Quick Test Flow
1. **Start the backend**:
```bash
cd backend && npm run start:dev
```
2. **Start the frontend**:
```bash
cd frontend && npm run dev
```
3. **Test OAuth flow**:
- Navigate to `http://localhost:5173/tool`
- Should redirect to login page
- Click "Sign in with Google"
- Complete Google consent
- Should redirect back to `/tool` with active session
4. **Verify session persistence**:
- Refresh the page
- Should remain on `/tool` without re-authenticating
5. **Test sign out**:
- Click user menu → Sign out
- Should redirect to login page
- Navigating to `/tool` should redirect to login
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/auth/google` | Initiate OAuth (browser redirect) |
| GET | `/api/auth/google/callback` | OAuth callback handler |
| POST | `/api/auth/refresh` | Refresh access token |
| GET | `/api/auth/me` | Get current user (requires auth) |
| POST | `/api/auth/logout` | Sign out (requires auth) |
## Frontend Routes
| Path | Component | Protected |
|------|-----------|-----------|
| `/` | LandingPage | No |
| `/login` | LoginPage | No |
| `/auth/callback` | AuthCallbackPage | No |
| `/tool` | ToolPage | Yes |
## Troubleshooting
**"redirect_uri_mismatch" error**:
- Ensure callback URL in Google Console matches `GOOGLE_CALLBACK_URL` exactly
- Include protocol (http/https) and port
**"Invalid token" after refresh**:
- Check `JWT_SECRET` is set and consistent
- Verify refresh token cookie is being sent (check browser DevTools)
**CORS errors**:
- Ensure `FRONTEND_URL` matches exactly (including port)
- Check `credentials: true` in CORS config
**Session not persisting**:
- Verify `httpOnly` cookie is set (check Application → Cookies in DevTools)
- Check `sameSite` and `secure` settings match environment

View File

@@ -0,0 +1,114 @@
# Research: Google OAuth Authentication
**Feature**: 001-google-oauth-auth
**Date**: 2026-01-29
## 1. NestJS Passport Google OAuth Setup
**Decision**: Use `@nestjs/passport` with `passport-google-oauth20` strategy
**Rationale**:
- Official NestJS integration provides decorator-based guards and strategies
- `passport-google-oauth20` is the actively maintained Google OAuth package
- Seamless integration with NestJS dependency injection and module system
- Built-in validate method simplifies user profile handling
**Alternatives Considered**:
- `passport-google-oauth2`: Older package, less TypeScript support
- Custom OAuth implementation: Too much boilerplate, harder to maintain
## 2. Authentication Strategy for SPAs
**Decision**: Hybrid approach - JWT access tokens (short-lived, in-memory) + HttpOnly cookie refresh tokens (7-day)
**Rationale**:
- Access tokens in memory provide stateless, scalable API authentication
- HttpOnly refresh tokens prevent XSS attacks while enabling token rotation
- Best balance between security, UX, and distributed system requirements
- Industry standard for modern SPAs
**Alternatives Considered**:
- Pure JWT (stateless): Can't invalidate tokens on logout/compromise
- Pure sessions: Doesn't scale well, requires sticky sessions
- localStorage for tokens: Vulnerable to XSS attacks
## 3. Token Storage in React
**Decision**: Access tokens in Zustand store (memory), refresh tokens in HttpOnly cookies
**Rationale**:
- HttpOnly cookies are inaccessible to JavaScript, preventing XSS token theft
- In-memory storage for short-lived access tokens (15-30 min)
- CSRF protection via `sameSite: 'strict'` and CORS configuration
- Silent refresh mechanism restores access token on page reload
**Alternatives Considered**:
- localStorage: Vulnerable to XSS attacks
- sessionStorage: Data lost on tab close, still XSS vulnerable
## 4. React Router Protected Routes
**Decision**: Layout-based protection using AuthGuard wrapper component with Navigate
**Rationale**:
- React Router v7 provides clean, declarative route protection
- Easy to preserve intended destination via `location.state.from`
- Simple loading state handling during auth check
**Alternatives Considered**:
- Higher-Order Component (HOC): More verbose in modern React
- Route-level guards: Duplicates auth logic across routes
## 5. OAuth Redirect Flow Implementation
**Decision**: Backend-initiated flow with frontend callback handler
**Flow**:
1. Frontend links to `/api/auth/google` (backend initiates OAuth)
2. NestJS redirects to Google consent screen
3. Google redirects to `/api/auth/google/callback`
4. Backend validates tokens, creates/updates user, issues JWT
5. Backend redirects to frontend `/auth/callback?token=...`
6. Frontend stores token, clears URL, redirects to intended destination
**Rationale**:
- NestJS handles OAuth state management and token exchange securely
- Supports TypeORM user creation/lookup with transaction safety
- Token cleared from URL immediately for security
**Alternatives Considered**:
- Frontend-initiated PKCE flow: More complex state management
- Popup-based flow: Poor UX on mobile, blocked by some browsers
## 6. Session Validity & Token Expiration
**Decision**:
- Access tokens: 15 minutes (short-lived for security)
- Refresh tokens: 7 days (per spec assumptions)
- Automatic token refresh via 401 interceptor in Axios
**Rationale**:
- Short access tokens limit exposure window if compromised
- 7-day refresh aligns with spec requirements
- Auto-refresh provides seamless UX
## Implementation Notes
**CORS Configuration Required**:
```
origin: FRONTEND_URL
credentials: true (allow cookies)
```
**Environment Variables Needed**:
- GOOGLE_CLIENT_ID
- GOOGLE_CLIENT_SECRET
- GOOGLE_CALLBACK_URL
- JWT_SECRET
- JWT_ACCESS_EXPIRATION (15m)
- JWT_REFRESH_EXPIRATION (7d)
- FRONTEND_URL
**TypeORM Indexes**:
- Index `googleId` for OAuth lookups
- Index `email` for user queries

View File

@@ -0,0 +1,106 @@
# Feature Specification: Google OAuth Authentication Screen
**Feature Branch**: `001-google-oauth-auth`
**Created**: 2026-01-29
**Status**: Draft
**Input**: User description: "реализовать экран авторизации для того чтобы начать пользоваться инструментом, авторизация должна быть через кнопку google oauth"
## Clarifications
### Session 2026-01-29
- Q: Which OAuth flow type should be used (popup vs redirect)? → A: Redirect flow (full page redirect to Google, then back)
- Q: Post-login redirect behavior for deep links? → A: Return to originally requested page (preserve intended destination)
## User Scenarios & Testing *(mandatory)*
### User Story 1 - First-Time User Sign In (Priority: P1)
A new user visits the ThumbPreview tool for the first time. Before accessing the main functionality (thumbnail preview tool), they are presented with a clean authentication screen. The user clicks the "Sign in with Google" button, completes the Google OAuth flow via full-page redirect to Google and back, and upon successful authentication is automatically redirected to the tool page.
**Why this priority**: This is the core functionality - without authentication, users cannot access the tool. It gates the entire application experience.
**Independent Test**: Can be fully tested by visiting the app as a logged-out user, clicking the Google sign-in button, completing OAuth, and verifying access to the tool page is granted.
**Acceptance Scenarios**:
1. **Given** a user is not authenticated, **When** they navigate to any protected page, **Then** they are redirected to the authentication screen
2. **Given** a user is on the authentication screen, **When** they click "Sign in with Google", **Then** the Google OAuth consent flow initiates
3. **Given** a user completes Google OAuth successfully, **When** the callback is processed, **Then** the user is redirected to their originally requested page (or /tool by default) with an active session
---
### User Story 2 - Returning User Session (Priority: P2)
A previously authenticated user returns to the application. If their session is still valid, they bypass the authentication screen and go directly to the tool. If the session has expired, they are shown the authentication screen to sign in again.
**Why this priority**: Provides a seamless experience for returning users while maintaining security through session management.
**Independent Test**: Can be tested by authenticating, closing the browser, returning within session validity period, and verifying automatic access without re-authentication.
**Acceptance Scenarios**:
1. **Given** a user has an active session, **When** they navigate to the application, **Then** they are taken directly to the tool page without seeing the auth screen
2. **Given** a user's session has expired, **When** they navigate to the application, **Then** they are redirected to the authentication screen
---
### User Story 3 - User Sign Out (Priority: P3)
An authenticated user wants to sign out of the application. They can access a sign-out option which clears their session and returns them to the authentication screen.
**Why this priority**: Essential for security and multi-user scenarios, but secondary to the core sign-in flow.
**Independent Test**: Can be tested by signing in, clicking sign out, and verifying the session is cleared and the user is returned to the auth screen.
**Acceptance Scenarios**:
1. **Given** a user is authenticated, **When** they click the sign-out option, **Then** their session is terminated and they are redirected to the authentication screen
2. **Given** a user has signed out, **When** they try to access the tool page directly, **Then** they are redirected to the authentication screen
---
### Edge Cases
- What happens when Google OAuth is cancelled or fails mid-flow? User should remain on the auth screen with an error message indicating the sign-in was not completed.
- What happens when a user's Google account is deactivated or access is revoked? Session should be invalidated and user redirected to auth screen on next request.
- What happens if the network connection is lost during OAuth? User should see an appropriate error message and be able to retry.
- What happens if user denies Google permissions? User remains on auth screen with a message explaining that permissions are required.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST display an authentication screen to unauthenticated users before allowing access to the tool
- **FR-002**: System MUST provide a "Sign in with Google" button on the authentication screen
- **FR-003**: System MUST initiate Google OAuth 2.0 authorization flow when the sign-in button is clicked
- **FR-004**: System MUST create a user session upon successful Google OAuth callback
- **FR-005**: System MUST redirect authenticated users to their originally requested page after successful sign-in (defaulting to /tool if no prior destination)
- **FR-006**: System MUST persist user sessions to allow returning users to bypass authentication
- **FR-007**: System MUST provide a sign-out mechanism that terminates the user session
- **FR-008**: System MUST protect all tool routes, redirecting unauthenticated requests to the auth screen
- **FR-009**: System MUST display appropriate error messages when authentication fails
- **FR-010**: System MUST store basic user profile information from Google (name, email, profile picture) for display purposes
### Key Entities
- **User**: Represents an authenticated user. Key attributes: unique identifier, Google ID, email address, display name, profile picture URL, created timestamp, last login timestamp.
- **Session**: Represents an active authentication session. Key attributes: session identifier, associated user, creation time, expiration time, validity status.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can complete the full sign-in flow (from auth screen to tool access) in under 30 seconds
- **SC-002**: 95% of authentication attempts complete successfully on first try (excluding user-cancelled flows)
- **SC-003**: Returning users with valid sessions access the tool without seeing the auth screen
- **SC-004**: All tool pages are inaccessible to unauthenticated users
- **SC-005**: Sign-out action fully terminates the session, preventing access without re-authentication
## Assumptions
- Google Cloud OAuth credentials will be configured as part of implementation
- Session validity period follows industry standard (reasonable default: 7 days with refresh capability)
- The landing page (/) remains publicly accessible; only the tool page (/tool) requires authentication
- User data stored is limited to what Google provides in the standard OAuth profile scope
- Email verification is handled by Google (only verified Google accounts can sign in)

View File

@@ -0,0 +1,221 @@
# Tasks: Google OAuth Authentication Screen
**Input**: Design documents from `/specs/001-google-oauth-auth/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅
**Tests**: Not explicitly requested - manual testing via quickstart.md
**Organization**: Tasks grouped by user story to enable independent implementation and testing.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Path Conventions
- **Web app**: `backend/src/`, `frontend/src/`
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Install dependencies and configure project for OAuth authentication
- [x] T001 Install backend auth dependencies: `cd backend && npm install @nestjs/passport @nestjs/jwt passport passport-google-oauth20 bcrypt`
- [x] T002 Install backend auth type definitions: `cd backend && npm install -D @types/passport-google-oauth20 @types/bcrypt`
- [x] T003 [P] Add OAuth environment variables template to backend/.env.example (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_CALLBACK_URL, JWT_SECRET, JWT_ACCESS_EXPIRATION, JWT_REFRESH_EXPIRATION, FRONTEND_URL)
- [x] T004 [P] Update backend/src/app.module.ts to import ConfigModule with .env support
- [x] T005 Configure CORS in backend/src/main.ts with credentials: true and FRONTEND_URL origin
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core entities, auth module structure, and database schema that ALL user stories depend on
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [x] T006 Create User entity in backend/src/entities/user.entity.ts with fields: id (UUID), googleId, email, displayName, avatarUrl, createdAt, lastLoginAt
- [x] T007 [P] Create RefreshToken entity in backend/src/entities/refresh-token.entity.ts with fields: id (UUID), userId (FK), token, expiresAt, createdAt, revokedAt
- [x] T008 Register User and RefreshToken entities in backend/src/app.module.ts TypeORM configuration
- [x] T009 Create auth module scaffold in backend/src/modules/auth/ with auth.module.ts, auth.controller.ts, auth.service.ts
- [x] T010 [P] Create DTOs in backend/src/modules/auth/dto/: auth-response.dto.ts, user-response.dto.ts
- [x] T011 [P] Create auth types in frontend/src/types/auth.ts: User interface, AuthState interface
- [x] T012 Create auth store in frontend/src/store/authStore.ts using Zustand with: user, accessToken, isLoading, setAuth, clearAuth, setLoading
- [x] T013 [P] Create auth API client in frontend/src/api/auth.ts with axios instance and methods: refreshToken, getMe, logout
**Checkpoint**: Foundation ready - user story implementation can now begin
---
## Phase 3: User Story 1 - First-Time User Sign In (Priority: P1) 🎯 MVP
**Goal**: New user can authenticate via Google OAuth and access the tool page
**Independent Test**: Visit /tool as logged-out user → redirected to login → click "Sign in with Google" → complete Google OAuth → redirected to /tool with active session
### Backend Implementation for User Story 1
- [x] T014 [US1] Implement GoogleStrategy in backend/src/modules/auth/google.strategy.ts using passport-google-oauth20
- [x] T015 [US1] Implement JwtStrategy in backend/src/modules/auth/jwt.strategy.ts for access token validation
- [x] T016 [P] [US1] Create GoogleAuthGuard in backend/src/modules/auth/guards/google-auth.guard.ts
- [x] T017 [P] [US1] Create JwtAuthGuard in backend/src/modules/auth/guards/jwt-auth.guard.ts
- [x] T018 [US1] Implement AuthService.validateOAuthUser() in backend/src/modules/auth/auth.service.ts - creates/updates user, issues tokens
- [x] T019 [US1] Implement AuthService.generateTokens() for JWT access token and refresh token generation with bcrypt hashing
- [x] T020 [US1] Add GET /auth/google route in backend/src/modules/auth/auth.controller.ts with GoogleAuthGuard
- [x] T021 [US1] Add GET /auth/google/callback route in backend/src/modules/auth/auth.controller.ts - handle OAuth callback, set HttpOnly cookie, redirect to frontend
- [x] T022 [US1] Add GET /auth/me route in backend/src/modules/auth/auth.controller.ts with JwtAuthGuard
- [x] T023 [US1] Register AuthModule in backend/src/app.module.ts imports
### Frontend Implementation for User Story 1
- [x] T024 [P] [US1] Create LoginPage component in frontend/src/pages/LoginPage.tsx with "Sign in with Google" button using shadcn/ui Button and Card
- [x] T025 [P] [US1] Create AuthCallbackPage component in frontend/src/pages/AuthCallbackPage.tsx to handle OAuth callback token from URL
- [x] T026 [US1] Implement useAuth hook in frontend/src/hooks/useAuth.ts with checkAuth, login redirect, and loading state
- [x] T027 [US1] Create AuthGuard component in frontend/src/components/AuthGuard.tsx - redirects to /login if not authenticated, preserves intended destination in location.state
- [x] T028 [US1] Update frontend/src/App.tsx - add /login route, /auth/callback route, wrap /tool with AuthGuard
- [x] T029 [US1] Handle OAuth errors in LoginPage - display error message from URL params if authentication failed
**Checkpoint**: User Story 1 complete - new users can sign in via Google and access protected routes
---
## Phase 4: User Story 2 - Returning User Session (Priority: P2)
**Goal**: Returning user with valid session bypasses login; expired session redirects to login
**Independent Test**: Sign in → close browser → return within 7 days → automatically access /tool without login prompt
### Backend Implementation for User Story 2
- [x] T030 [US2] Add POST /auth/refresh route in backend/src/modules/auth/auth.controller.ts - validate HttpOnly refresh cookie, rotate token, return new access token
- [x] T031 [US2] Implement AuthService.refreshTokens() in backend/src/modules/auth/auth.service.ts - validate stored token, check expiry, issue new pair
- [x] T032 [US2] Implement AuthService.revokeRefreshToken() for token rotation (mark old token as revoked)
### Frontend Implementation for User Story 2
- [x] T033 [US2] Add silent token refresh on app mount in frontend/src/hooks/useAuth.ts - call /auth/refresh on startup
- [x] T034 [US2] Add Axios response interceptor in frontend/src/api/auth.ts - on 401, attempt refresh, retry original request
- [x] T035 [US2] Update AuthGuard in frontend/src/components/AuthGuard.tsx - show loading state during session check, then redirect or render
**Checkpoint**: User Story 2 complete - returning users have seamless session persistence
---
## Phase 5: User Story 3 - User Sign Out (Priority: P3)
**Goal**: Authenticated user can sign out, which clears session and returns to login
**Independent Test**: Sign in → click sign out in user menu → redirected to login → cannot access /tool
### Backend Implementation for User Story 3
- [x] T036 [US3] Add POST /auth/logout route in backend/src/modules/auth/auth.controller.ts with JwtAuthGuard - revoke refresh token, clear cookie
### Frontend Implementation for User Story 3
- [x] T037 [P] [US3] Create UserMenu component in frontend/src/components/UserMenu.tsx - display user avatar, name, sign out button using shadcn/ui
- [x] T038 [US3] Implement logout function in frontend/src/store/authStore.ts - call /auth/logout, clear local state
- [x] T039 [US3] Add UserMenu to ToolPage header in frontend/src/pages/ToolPage.tsx
**Checkpoint**: User Story 3 complete - users can sign out securely
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Error handling, edge cases, and final validation
- [x] T040 [P] Add error handling for OAuth failures in backend/src/modules/auth/auth.controller.ts - redirect to frontend with error param
- [x] T041 [P] Add error display component for auth errors in frontend/src/pages/LoginPage.tsx - show user-friendly messages
- [x] T042 Update Vite proxy configuration in frontend/vite.config.ts if needed for /api/auth routes
- [x] T043 Validate implementation against quickstart.md test scenarios
- [x] T044 Run ESLint and fix any linting errors in new files
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
- **User Stories (Phase 3-5)**: All depend on Foundational phase completion
- **Polish (Phase 6)**: Depends on all user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - Core sign-in flow
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - Extends US1 with token refresh, but independently testable
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - Adds logout to US1, independently testable
### Within Each User Story
- Backend before frontend (API must exist before UI calls it)
- Entity/strategy before service
- Service before controller
- Controller before frontend integration
### Parallel Opportunities
**Phase 1 (Setup)**:
```
T003 [P] + T004 [P] can run in parallel
```
**Phase 2 (Foundational)**:
```
T006 + T007 [P] can run in parallel (different entity files)
T010 [P] + T011 [P] + T013 [P] can run in parallel (DTOs, types, API client)
```
**Phase 3 (User Story 1)**:
```
T016 [P] + T017 [P] can run in parallel (different guard files)
T024 [P] + T025 [P] can run in parallel (different page files)
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (T001-T005)
2. Complete Phase 2: Foundational (T006-T013)
3. Complete Phase 3: User Story 1 (T014-T029)
4. **STOP and VALIDATE**: Test sign-in flow per quickstart.md
5. Deploy/demo if ready - users can now sign in!
### Incremental Delivery
1. Complete Setup + Foundational → Foundation ready
2. Add User Story 1 → Test independently → Deploy (MVP with sign-in)
3. Add User Story 2 → Test independently → Deploy (session persistence)
4. Add User Story 3 → Test independently → Deploy (full auth with logout)
5. Complete Polish → Final validation → Production ready
### Task Count by Story
| Phase | Task Count |
|-------|------------|
| Setup | 5 |
| Foundational | 8 |
| User Story 1 | 16 |
| User Story 2 | 6 |
| User Story 3 | 4 |
| Polish | 5 |
| **Total** | **44** |
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story is independently completable and testable
- Backend entities use UUID primary keys per constitution
- Frontend uses shadcn/ui components per constitution
- HttpOnly cookies for refresh tokens per research.md security recommendations