From e00cf4db58923da9f540bb454763f362a25bef0a Mon Sep 17 00:00:00 2001 From: Krishnal Jadav Date: Fri, 4 Jul 2025 22:24:42 +0530 Subject: [PATCH 1/4] refactored google login to use cognito federation --- backend/package-lock.json | 281 ++++++++++- backend/package.json | 4 +- backend/src/app.ts | 48 ++ backend/src/config/index.ts | 4 +- backend/src/handlers/api.ts | 457 +----------------- backend/src/handlers/authorizer.ts | 21 +- backend/src/lambda.ts | 71 +++ backend/src/middleware/auth.ts | 165 +++++++ backend/src/middleware/cors.ts | 14 + backend/src/routes/auth.ts | 230 +++++++++ backend/src/routes/health.ts | 28 ++ backend/src/routes/users.ts | 113 +++++ backend/src/server.ts | 34 ++ backend/src/services/auth-service.ts | 228 ++++----- backend/src/services/authorization-service.ts | 154 ++---- backend/src/services/base-service.ts | 30 +- 16 files changed, 1150 insertions(+), 732 deletions(-) create mode 100644 backend/src/app.ts create mode 100644 backend/src/lambda.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/middleware/cors.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/health.ts create mode 100644 backend/src/routes/users.ts create mode 100644 backend/src/server.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 0b6c7e5..bbc14b5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,8 +10,10 @@ "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.400.0", "@fastify/aws-lambda": "^4.0.0", + "@fastify/cookie": "^9.4.0", "@fastify/cors": "^9.0.0", "aws-jwt-verify": "^4.0.0", + "axios": "^1.10.0", "dotenv": "^17.0.1", "fastify": "^4.21.0", "google-auth-library": "^9.0.0" @@ -1351,6 +1353,16 @@ "integrity": "sha512-293HSdtr4muZZi4UxjrDgddxlLRDbNxT5x/eOX78obMA1Du3tfpuP7WuyfnA4GXaeckj/soJ2jiuD2sM4VIW9Q==", "license": "MIT" }, + "node_modules/@fastify/cookie": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-9.4.0.tgz", + "integrity": "sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg==", + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.1.0", + "fastify-plugin": "^4.0.0" + } + }, "node_modules/@fastify/cors": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz", @@ -3141,6 +3153,12 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -3169,6 +3187,17 @@ "node": ">=14.0.0" } }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3429,6 +3458,19 @@ "dev": true, "license": "MIT" }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3573,6 +3615,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3596,6 +3650,15 @@ "node": ">= 0.6" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -3689,6 +3752,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3757,6 +3829,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3819,6 +3905,51 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4426,6 +4557,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4461,7 +4628,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4517,6 +4683,30 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -4527,6 +4717,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -4662,6 +4865,18 @@ "node": ">=14" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4699,11 +4914,37 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5877,6 +6118,15 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -5908,6 +6158,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -6416,6 +6687,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/backend/package.json b/backend/package.json index 7fb6a07..3a518e4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,7 @@ "build": "tsc", "watch": "tsc -w", "start": "node dist/handlers/api.js", - "dev": "ts-node src/handlers/api.ts", + "dev": "ts-node src/server.ts", "test": "jest", "lint": "eslint src/**/*.ts", "lint:fix": "eslint src/**/*.ts --fix", @@ -19,8 +19,10 @@ "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.400.0", "@fastify/aws-lambda": "^4.0.0", + "@fastify/cookie": "^9.4.0", "@fastify/cors": "^9.0.0", "aws-jwt-verify": "^4.0.0", + "axios": "^1.10.0", "dotenv": "^17.0.1", "fastify": "^4.21.0", "google-auth-library": "^9.0.0" diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..2fa43f3 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,48 @@ +import { FastifyInstance, fastify } from 'fastify'; +import { getConfig } from './config'; +import { Logger } from './utils/logger'; + +// Import middleware +import { registerCors } from './middleware/cors'; +import { registerAuth } from './middleware/auth'; + +// Import route modules +import { registerHealthRoutes } from './routes/health'; +import { registerAuthRoutes } from './routes/auth'; +import { registerUserRoutes } from './routes/users'; + +export interface AppOptions { + environment?: 'development' | 'lambda'; + logger?: boolean; +} + +/** + * Creates and configures the main Fastify application + * This factory can be used by both development server and Lambda handler + */ +export function createApp(options: AppOptions = {}): FastifyInstance { + const config = getConfig(); + const logger = new Logger({ service: 'App' }); + + const app = fastify({ + logger: options.logger ?? false, + }); + + logger.info('Creating Fastify application', { + environment: options.environment || 'unknown', + stage: config.app.stage, + }); + + // Register middleware in order + registerCors(app, config); + registerAuth(app, options.environment); + + // Register route modules + registerHealthRoutes(app); + registerAuthRoutes(app); + registerUserRoutes(app); + + logger.info('Fastify application created successfully'); + + return app; +} \ No newline at end of file diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 1d25c24..7252a46 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -14,6 +14,7 @@ export interface AppConfig { userPoolId: string; clientId: string; clientSecret: string; + domain: string; }; }; @@ -64,7 +65,8 @@ function validateConfiguration(): AppConfig { cognito: { userPoolId: getRequiredEnvVar('COGNITO_USER_POOL_ID'), clientId: getRequiredEnvVar('COGNITO_CLIENT_ID'), - clientSecret: getRequiredEnvVar('COGNITO_CLIENT_SECRET'), + clientSecret: getOptionalEnvVar('COGNITO_CLIENT_SECRET', ''), // Will be retrieved at runtime if empty + domain: getOptionalEnvVar('COGNITO_DOMAIN', ''), }, }, google: { diff --git a/backend/src/handlers/api.ts b/backend/src/handlers/api.ts index 50edd1f..04af54b 100644 --- a/backend/src/handlers/api.ts +++ b/backend/src/handlers/api.ts @@ -1,454 +1,3 @@ -// backend/src/handlers/api.ts -import { fastify, FastifyInstance } from 'fastify'; -import awsLambdaFastify from '@fastify/aws-lambda'; -import cors from '@fastify/cors'; -import { AuthService } from '../services/auth-service'; -import { UserService } from '../services/user-service'; -import { AuthorizationService, AuthContext } from '../services/authorization-service'; -import { getConfig } from '../config'; -import { - getEnvironmentContext, - extractLambdaEvent, - extractAWSEventContext -} from '../utils/environment'; -import { handleError, extractErrorMessage } from '../utils/errors'; -import { Logger } from '../utils/logger'; -import { createErrorResponse, createSuccessResponse } from '../utils/response'; -import { - LoginRequest, - SignupRequest, - GoogleAuthRequest, - RefreshTokenRequest, - ForgotPasswordRequest, - ResetPasswordRequest, -} from '../types/auth'; - -// Store the current Lambda event globally so we can access it in preHandler -let currentLambdaEvent: any = null; - -// Create Fastify instance -const createFastifyApp = (): FastifyInstance => { - const config = getConfig(); - - const app = fastify({ - logger: false, // We'll use our custom logger - }); - - // Register CORS with centralized configuration - app.register(cors, { - origin: config.cors.allowedDomains, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - }); - - // Initialize services with context - const authService = new AuthService(); - const userService = new UserService(); - const authorizationService = new AuthorizationService(); - - // Middleware to extract auth context from API Gateway or handle local authorization - app.addHook('preHandler', async (request: any) => { - const logger = new Logger({ service: 'PreHandler' }); - - // Extract Lambda event using the environment utility - const event = extractLambdaEvent(request, currentLambdaEvent); - const environmentContext = getEnvironmentContext(event); - const awsContext = extractAWSEventContext(event); - - logger.info('Processing request', { - url: request.url, - method: request.method, - isAWS: environmentContext.isAWS, - hasAuthorizer: awsContext?.hasAuthorizer || false, - }); - - if (environmentContext.isAWS && awsContext) { - // AWS Environment: Extract auth context from Lambda authorizer - logger.info('Processing AWS environment request'); - - let authContext: AuthContext | null = null; - - // Try to get from requestContext.authorizer first - if (awsContext.hasAuthorizer && awsContext.authorizer) { - const authorizer = awsContext.authorizer; - authContext = { - userId: authorizer.userId || authorizer.username, - email: authorizer.email, - tokenType: authorizer.tokenType as 'cognito' | 'google', - emailVerified: authorizer.emailVerified === 'true', - ...(authorizer.tokenType === 'cognito' && { - username: authorizer.username, - tokenUse: authorizer.tokenUse, - }), - ...(authorizer.tokenType === 'google' && { - name: authorizer.name, - givenName: authorizer.givenName, - familyName: authorizer.familyName, - picture: authorizer.picture, - }), - }; - } - - // Fallback to headers if not in authorizer context - if (!authContext) { - authContext = authorizationService.createAuthContextFromHeaders(request.headers || {}); - } - - request.authContext = authContext; - } else { - // Local Environment: Handle authorization directly - logger.info('Processing local environment request'); - - const authHeader = request.headers?.authorization || request.headers?.Authorization; - - if (authHeader) { - try { - const authContext = await authorizationService.authorizeRequest(authHeader); - request.authContext = authContext; - - logger.info('Local authorization successful', { - userId: authContext.userId, - email: authContext.email, - tokenType: authContext.tokenType, - }); - } catch (error) { - logger.error('Local authorization failed', error); - request.authContext = null; - } - } else { - request.authContext = null; - } - } - - logger.info('PreHandler completed', { - hasAuthContext: !!request.authContext, - environment: environmentContext.isAWS ? 'aws' : 'local', - requestId: environmentContext.requestId, - }); - }); - - // Health check - app.get('/health', async () => { - return { status: 'healthy', timestamp: new Date().toISOString() }; - }); - - // Debug endpoint to see what headers we receive - app.get('/debug/headers', async (request: any) => { - const event = currentLambdaEvent || request.awsLambda?.event || request.event || (request as any).lambdaEvent; - - return { - status: 'debug', - requestHeaders: request.headers, - eventHeaders: event?.headers, - hasEvent: !!event, - hasRequestContext: !!event?.requestContext, - hasAuthorizer: !!event?.requestContext?.authorizer, - authorizer: event?.requestContext?.authorizer, - timestamp: new Date().toISOString(), - }; - }); - - // Authentication routes - app.register(async (fastify) => { - // Login with email/password - fastify.post<{ Body: LoginRequest }>('/auth/login', async (request, reply) => { - const logger = new Logger({ action: 'login', email: request.body.email }); - - try { - logger.info('Login attempt started'); - - const result = await authService.login(request.body); - - logger.info('Login successful'); - return createSuccessResponse(result); - } catch (error) { - logger.error('Login failed', error); - reply.code(401); - return createErrorResponse(401, 'Invalid credentials'); - } - }); - - // Signup with email/password - fastify.post<{ Body: SignupRequest }>('/auth/signup', async (request, reply) => { - const logger = new Logger({ action: 'signup', email: request.body.email }); - - try { - logger.info('Signup attempt started'); - - const result = await authService.signup(request.body); - - logger.info('Signup successful'); - return createSuccessResponse(result); - } catch (error) { - logger.error('Signup failed', error); - reply.code(400); - return createErrorResponse(400, extractErrorMessage(error, 'Signup failed')); - } - }); - - // Google OAuth - fastify.post<{ Body: GoogleAuthRequest }>('/auth/google', async (request, reply) => { - const logger = new Logger({ action: 'google-auth' }); - - try { - logger.info('Google auth attempt started'); - - const result = await authService.authenticateWithGoogle(request.body); - - logger.info('Google auth successful'); - return createSuccessResponse(result); - } catch (error) { - logger.error('Google auth failed', error); - reply.code(400); - return createErrorResponse(400, extractErrorMessage(error, 'Google authentication failed')); - } - }); - - // Refresh token - fastify.post<{ Body: RefreshTokenRequest }>('/auth/refresh', async (request, reply) => { - const logger = new Logger({ action: 'refresh-token' }); - - try { - logger.info('Token refresh attempt started'); - - const result = await authService.refreshToken(request.body); - - logger.info('Token refresh successful'); - return createSuccessResponse(result); - } catch (error) { - logger.error('Token refresh failed', error); - reply.code(401); - return createErrorResponse(401, 'Invalid refresh token'); - } - }); - - // Forgot password - fastify.post<{ Body: ForgotPasswordRequest }>('/auth/password/forgot', async (request, reply) => { - const logger = new Logger({ action: 'forgot-password', email: request.body.email }); - - try { - logger.info('Forgot password attempt started'); - - await authService.forgotPassword(request.body); - - logger.info('Forgot password email sent'); - return createSuccessResponse({ message: 'Reset code sent to email' }); - } catch (error) { - logger.error('Forgot password failed', error); - reply.code(400); - return createErrorResponse(400, extractErrorMessage(error, 'Failed to send reset code')); - } - }); - - // Reset password - fastify.post<{ Body: ResetPasswordRequest }>('/auth/password/reset', async (request, reply) => { - const logger = new Logger({ action: 'reset-password', email: request.body.email }); - - try { - logger.info('Password reset attempt started'); - - await authService.resetPassword(request.body); - - logger.info('Password reset successful'); - return createSuccessResponse({ message: 'Password reset successful' }); - } catch (error) { - logger.error('Password reset failed', error); - reply.code(400); - return createErrorResponse(400, extractErrorMessage(error, 'Password reset failed')); - } - }); - }); - - // Protected API routes - app.register(async (fastify) => { - // Middleware to ensure authentication - fastify.addHook('preHandler', async (request: any, reply) => { - console.log('Protected route preHandler', { - hasAuthContext: !!request.authContext, - authContext: request.authContext, - url: request.url, - method: request.method, - }); - - if (!request.authContext) { - console.log('Authentication failed: No authContext found'); - reply.code(401); - throw new Error('Unauthorized'); - } - - console.log('Authentication successful for protected route', { - userId: request.authContext.userId, - email: request.authContext.email, - tokenType: request.authContext.tokenType, - }); - }); - - // Get user profile - fastify.get('/api/user', async (request: any) => { - const logger = new Logger({ - action: 'get-user-profile', - userId: request.authContext.userId - }); - - try { - logger.info('Getting user profile'); - - const user = await userService.getUserProfile(request.authContext.userId); - - logger.info('User profile retrieved'); - return createSuccessResponse(user); - } catch (error) { - logger.error('Failed to get user profile', error); - throw error; - } - }); - - // Update user profile - fastify.put<{ Body: any }>('/api/user', async (request: any) => { - const logger = new Logger({ - action: 'update-user-profile', - userId: request.authContext.userId - }); - - try { - logger.info('Updating user profile'); - - const updatedUser = await userService.updateUserProfile( - request.authContext.userId, - request.body - ); - - logger.info('User profile updated'); - return createSuccessResponse(updatedUser); - } catch (error) { - logger.error('Failed to update user profile', error); - throw error; - } - }); - - // Get protected data - fastify.get('/api/data', async (request: any) => { - const logger = new Logger({ - action: 'get-protected-data', - userId: request.authContext.userId - }); - - try { - logger.info('Getting protected data'); - - const data = { - message: 'This is protected data', - user: { - id: request.authContext.userId, - email: request.authContext.email, - tokenType: request.authContext.tokenType, - }, - timestamp: new Date().toISOString(), - requestId: request.id, - }; - - logger.info('Protected data retrieved'); - return createSuccessResponse(data); - } catch (error) { - logger.error('Failed to get protected data', error); - throw error; - } - }); - - // Auth context test endpoint - fastify.get('/api/auth-test', async (request: any) => { - const logger = new Logger({ - action: 'auth-test', - userId: request.authContext.userId - }); - - try { - logger.info('Testing auth context'); - - const authContextData = { - message: 'Auth context test successful', - authContext: request.authContext, - timestamp: new Date().toISOString(), - }; - - logger.info('Auth context test completed'); - return createSuccessResponse(authContextData); - } catch (error) { - logger.error('Auth context test failed', error); - throw error; - } - }); - }); - - return app; -}; - -// Create app instance -const app = createFastifyApp(); - -// Custom Lambda handler that properly exposes event context -export const handler = async (event: any, context: any) => { - const environmentContext = getEnvironmentContext(event); - const logger = new Logger({ - service: 'LambdaHandler', - requestId: environmentContext.requestId - }); - - logger.info('Lambda handler started', { - method: event.httpMethod, - path: event.path, - isAWS: environmentContext.isAWS, - stage: environmentContext.stage, - }); - - // Store the event globally so preHandler can access it - currentLambdaEvent = event; - - try { - // Use the standard awsLambdaFastify handler - const wrappedHandler = awsLambdaFastify(app); - const result = await wrappedHandler(event, context); - - logger.info('Lambda handler completed successfully', { - statusCode: result.statusCode, - hasBody: !!result.body, - }); - - return result; - } catch (error) { - logger.error('Lambda handler failed', error); - - // Use centralized error handling - const errorResponse = handleError(error, { - ...environmentContext, - action: 'lambda-handler', - }); - - return { - statusCode: errorResponse.statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': getConfig().cors.origin, - 'Access-Control-Allow-Credentials': 'true', - }, - body: JSON.stringify(errorResponse.body), - }; - } finally { - // Clear the global event - currentLambdaEvent = null; - } -}; - -// For local development -if (require.main === module) { - const start = async () => { - try { - await app.listen({ port: 3001, host: '0.0.0.0' }); - console.log('Server listening on http://localhost:3001'); - } catch (err) { - app.log.error(err); - process.exit(1); - } - }; - start(); -} \ No newline at end of file +// Re-export the Lambda handler from the modular lambda.ts file +// This maintains compatibility with existing CDK infrastructure +export { handler } from '../lambda'; \ No newline at end of file diff --git a/backend/src/handlers/authorizer.ts b/backend/src/handlers/authorizer.ts index fc1ddd0..5c54c1f 100644 --- a/backend/src/handlers/authorizer.ts +++ b/backend/src/handlers/authorizer.ts @@ -8,8 +8,7 @@ import { Logger } from '../utils/logger'; const authorizationService = new AuthorizationService(); export const verifyCognitoToken = authorizationService.verifyCognitoToken.bind(authorizationService); -export const verifyGoogleToken = authorizationService.verifyGoogleToken.bind(authorizationService); -export const verifyCustomGoogleToken = authorizationService.verifyCustomGoogleToken.bind(authorizationService); +// Google token verification methods removed - now using Cognito federation const generatePolicy = ( principalId: string, @@ -59,21 +58,19 @@ export const handler = async ( }); // Convert AuthContext to string values for API Gateway + // All tokens are now Cognito tokens (including federated Google users) const stringAuthContext = { userId: String(authContext.userId), email: String(authContext.email), tokenType: String(authContext.tokenType), emailVerified: String(authContext.emailVerified), - ...(authContext.tokenType === 'cognito' && { - username: String(authContext.username || authContext.userId), - tokenUse: String(authContext.tokenUse || ''), - }), - ...(authContext.tokenType === 'google' && { - name: String(authContext.name || ''), - givenName: String(authContext.givenName || ''), - familyName: String(authContext.familyName || ''), - picture: String(authContext.picture || ''), - }), + username: String(authContext.username || authContext.userId), + tokenUse: String(authContext.tokenUse || ''), + // Additional attributes for federated users + name: String(authContext.name || ''), + givenName: String(authContext.givenName || ''), + familyName: String(authContext.familyName || ''), + picture: String(authContext.picture || ''), }; return generatePolicy(authContext.userId, 'Allow', event.methodArn, stringAuthContext); diff --git a/backend/src/lambda.ts b/backend/src/lambda.ts new file mode 100644 index 0000000..949e54b --- /dev/null +++ b/backend/src/lambda.ts @@ -0,0 +1,71 @@ +import awsLambdaFastify from '@fastify/aws-lambda'; +import { createApp } from './app'; +import { setLambdaEvent, clearLambdaEvent } from './middleware/auth'; +import { getEnvironmentContext } from './utils/environment'; +import { handleError } from './utils/errors'; +import { getConfig } from './config'; +import { Logger } from './utils/logger'; + +/** + * Create the Fastify app instance for Lambda + */ +const app = createApp({ + environment: 'lambda', + logger: false +}); + +/** + * AWS Lambda handler + * Uses the shared app factory for consistency with development server + */ +export const handler = async (event: any, context: any) => { + const environmentContext = getEnvironmentContext(event); + const logger = new Logger({ + service: 'LambdaHandler', + requestId: environmentContext.requestId + }); + + logger.info('Lambda handler started', { + method: event.httpMethod, + path: event.path, + isAWS: environmentContext.isAWS, + stage: environmentContext.stage, + }); + + // Set the Lambda event for the auth middleware + setLambdaEvent(event); + + try { + // Use the standard awsLambdaFastify handler with our shared app + const wrappedHandler = awsLambdaFastify(app); + const result = await wrappedHandler(event, context); + + logger.info('Lambda handler completed successfully', { + statusCode: result.statusCode, + hasBody: !!result.body, + }); + + return result; + } catch (error) { + logger.error('Lambda handler failed', error); + + // Use centralized error handling + const errorResponse = handleError(error, { + ...environmentContext, + action: 'lambda-handler', + }); + + return { + statusCode: errorResponse.statusCode, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': getConfig().cors.origin, + 'Access-Control-Allow-Credentials': 'true', + }, + body: JSON.stringify(errorResponse.body), + }; + } finally { + // Clear the Lambda event + clearLambdaEvent(); + } +}; \ No newline at end of file diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..12518c0 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,165 @@ +import { FastifyInstance } from 'fastify'; +import cookie from '@fastify/cookie'; +import { AuthorizationService, AuthContext } from '../services/authorization-service'; +import { + getEnvironmentContext, + extractLambdaEvent, + extractAWSEventContext +} from '../utils/environment'; +import { Logger } from '../utils/logger'; + +// Store the current Lambda event globally for AWS environment +let currentLambdaEvent: any = null; + +/** + * Sets the current Lambda event for AWS environment processing + */ +export function setLambdaEvent(event: any): void { + currentLambdaEvent = event; +} + +/** + * Clears the current Lambda event + */ +export function clearLambdaEvent(): void { + currentLambdaEvent = null; +} + +/** + * Registers authentication middleware with the Fastify app + */ +export function registerAuth(app: FastifyInstance, environment?: string): void { + // Register cookie plugin + app.register(cookie); + + // Initialize authorization service + const authorizationService = new AuthorizationService(); + + // Authentication middleware + app.addHook('preHandler', async (request: any) => { + const logger = new Logger({ service: 'AuthMiddleware' }); + + // Extract Lambda event using the environment utility + const event = extractLambdaEvent(request, currentLambdaEvent); + const environmentContext = getEnvironmentContext(event); + const awsContext = extractAWSEventContext(event); + + logger.info('Processing request', { + url: request.url, + method: request.method, + environment: environment || 'unknown', + isAWS: environmentContext.isAWS, + hasAuthorizer: awsContext?.hasAuthorizer || false, + }); + + if (environmentContext.isAWS && awsContext) { + // AWS Environment: Extract auth context from Lambda authorizer + logger.info('Processing AWS environment request'); + + let authContext: AuthContext | null = null; + + // Try to get from requestContext.authorizer first + if (awsContext.hasAuthorizer && awsContext.authorizer) { + const authorizer = awsContext.authorizer; + authContext = { + userId: authorizer.userId || authorizer.username, + email: authorizer.email, + tokenType: authorizer.tokenType as 'cognito' | 'google', + emailVerified: authorizer.emailVerified === 'true', + ...(authorizer.tokenType === 'cognito' && { + username: authorizer.username, + tokenUse: authorizer.tokenUse, + }), + ...(authorizer.tokenType === 'google' && { + name: authorizer.name, + givenName: authorizer.givenName, + familyName: authorizer.familyName, + picture: authorizer.picture, + }), + }; + } + + // Fallback to headers if not in authorizer context + if (!authContext) { + authContext = authorizationService.createAuthContextFromHeaders(request.headers || {}); + } + + request.authContext = authContext; + } else { + // Local/Development Environment: Handle authorization directly + logger.info('Processing local environment request'); + + const authHeader = request.headers?.authorization || request.headers?.Authorization; + let authContext: AuthContext | null = null; + + // First, try Authorization header (token-based auth) + if (authHeader) { + try { + authContext = await authorizationService.authorizeRequest(authHeader); + + logger.info('Local authorization successful via header', { + userId: authContext.userId, + email: authContext.email, + tokenType: authContext.tokenType, + }); + } catch (error) { + logger.error('Local authorization failed via header', error); + authContext = null; + } + } + + // If no header auth, try cookies (cookie-based auth from Google OAuth) + if (!authContext && request.cookies?.access_token) { + try { + // Create Authorization header from cookie + const cookieAuthHeader = `Bearer ${request.cookies.access_token}`; + authContext = await authorizationService.authorizeRequest(cookieAuthHeader); + + logger.info('Local authorization successful via cookie', { + userId: authContext.userId, + email: authContext.email, + tokenType: authContext.tokenType, + }); + } catch (error) { + logger.error('Local authorization failed via cookie', error); + authContext = null; + } + } + + request.authContext = authContext; + } + + logger.info('AuthMiddleware completed', { + hasAuthContext: !!request.authContext, + environment: environmentContext.isAWS ? 'aws' : 'local', + requestId: environmentContext.requestId, + }); + }); +} + +/** + * Middleware to ensure authentication for protected routes + */ +export function requireAuth(app: FastifyInstance): void { + app.addHook('preHandler', async (request: any, reply) => { + const logger = new Logger({ service: 'RequireAuth' }); + + logger.info('Checking authentication', { + url: request.url, + method: request.method, + hasAuthContext: !!request.authContext, + }); + + if (!request.authContext) { + logger.error('Authentication required but not found'); + reply.code(401); + throw new Error('Unauthorized'); + } + + logger.info('Authentication verified', { + userId: request.authContext.userId, + email: request.authContext.email, + tokenType: request.authContext.tokenType, + }); + }); +} \ No newline at end of file diff --git a/backend/src/middleware/cors.ts b/backend/src/middleware/cors.ts new file mode 100644 index 0000000..3e56959 --- /dev/null +++ b/backend/src/middleware/cors.ts @@ -0,0 +1,14 @@ +import { FastifyInstance } from 'fastify'; +import cors from '@fastify/cors'; +import { AppConfig } from '../config'; + +/** + * Registers CORS middleware with the Fastify app + */ +export function registerCors(app: FastifyInstance, config: AppConfig): void { + app.register(cors, { + origin: config.cors.allowedDomains, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + }); +} \ No newline at end of file diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..4c3ef85 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,230 @@ +import { FastifyInstance } from 'fastify'; +import { AuthService } from '../services/auth-service'; +import { Logger } from '../utils/logger'; +import { createErrorResponse, createSuccessResponse } from '../utils/response'; +import { extractErrorMessage } from '../utils/errors'; +import { + LoginRequest, + SignupRequest, + GoogleAuthRequest, + RefreshTokenRequest, + ForgotPasswordRequest, + ResetPasswordRequest, +} from '../types/auth'; + +/** + * Authentication routes + */ +export function registerAuthRoutes(app: FastifyInstance): void { + const authService = new AuthService(); + + // Register all auth routes under /auth prefix + app.register(async (fastify) => { + // Login with email/password + fastify.post<{ Body: LoginRequest }>('/auth/login', async (request, reply) => { + const logger = new Logger({ action: 'login', email: request.body.email }); + + try { + logger.info('Login attempt started'); + + const result = await authService.login(request.body); + + logger.info('Login successful'); + return createSuccessResponse(result); + } catch (error) { + logger.error('Login failed', error); + reply.code(401); + return createErrorResponse(401, 'Invalid credentials'); + } + }); + + // Signup with email/password + fastify.post<{ Body: SignupRequest }>('/auth/signup', async (request, reply) => { + const logger = new Logger({ action: 'signup', email: request.body.email }); + + try { + logger.info('Signup attempt started'); + + const result = await authService.signup(request.body); + + logger.info('Signup successful'); + return createSuccessResponse(result); + } catch (error) { + logger.error('Signup failed', error); + reply.code(400); + return createErrorResponse(400, extractErrorMessage(error, 'Signup failed')); + } + }); + + // Google OAuth redirect (new federated approach) + fastify.get('/auth/google', async (request, reply) => { + const logger = new Logger({ action: 'google-oauth-redirect' }); + + try { + logger.info('Google OAuth redirect started'); + + const redirectUri = `${request.protocol}://${request.hostname}${request.hostname === 'localhost' ? ':3001' : ''}/auth/callback`; + const authUrl = authService.getGoogleAuthUrl(redirectUri); + + logger.info('Redirecting to Cognito OAuth2 authorize URL', { redirectUri }); + return reply.redirect(authUrl); + } catch (error) { + logger.error('Google OAuth redirect failed', error); + reply.code(400); + return createErrorResponse(400, extractErrorMessage(error, 'Google OAuth redirect failed')); + } + }); + + // Google OAuth (legacy POST endpoint for backward compatibility) + fastify.post<{ Body: GoogleAuthRequest }>('/auth/google', async (request, reply) => { + const logger = new Logger({ action: 'google-auth-legacy' }); + + try { + logger.info('Legacy Google auth attempt started'); + + const result = await authService.authenticateWithGoogle(request.body); + + logger.info('Legacy Google auth successful'); + return createSuccessResponse(result); + } catch (error) { + logger.error('Legacy Google auth failed', error); + reply.code(400); + return createErrorResponse(400, extractErrorMessage(error, 'Google authentication failed')); + } + }); + + // OAuth callback endpoint + fastify.get('/auth/callback', async (request, reply) => { + const logger = new Logger({ action: 'oauth-callback' }); + + try { + logger.info('OAuth callback received'); + + const { code, error } = request.query as { code?: string; error?: string }; + + if (error) { + logger.error('OAuth callback error', { error }); + return reply.redirect(`http://localhost:3000/login?error=${encodeURIComponent(error)}`); + } + + if (!code) { + logger.error('OAuth callback missing code'); + return reply.redirect('http://localhost:3000/login?error=missing_code'); + } + + const redirectUri = `${request.protocol}://${request.hostname}${request.hostname === 'localhost' ? ':3001' : ''}/auth/callback`; + const tokens = await authService.exchangeCodeForTokens(code, redirectUri); + + logger.info('OAuth token exchange successful'); + + // Set tokens in secure HTTP-only cookies + reply.setCookie('access_token', tokens.accessToken, { + httpOnly: true, + secure: request.protocol === 'https', + sameSite: 'lax', + maxAge: tokens.expiresIn, + path: '/', + }); + + reply.setCookie('id_token', tokens.idToken, { + httpOnly: true, + secure: request.protocol === 'https', + sameSite: 'lax', + maxAge: tokens.expiresIn, + path: '/', + }); + + if (tokens.refreshToken) { + reply.setCookie('refresh_token', tokens.refreshToken, { + httpOnly: true, + secure: request.protocol === 'https', + sameSite: 'lax', + maxAge: 30 * 24 * 60 * 60, // 30 days + path: '/', + }); + } + + // Redirect to dashboard + return reply.redirect('http://localhost:3000/dashboard'); + } catch (error) { + logger.error('OAuth callback failed', error); + return reply.redirect(`http://localhost:3000/login?error=${encodeURIComponent('oauth_callback_failed')}`); + } + }); + + // Refresh token + fastify.post<{ Body: RefreshTokenRequest }>('/auth/refresh', async (request, reply) => { + const logger = new Logger({ action: 'refresh-token' }); + + try { + logger.info('Token refresh attempt started'); + + const result = await authService.refreshToken(request.body); + + logger.info('Token refresh successful'); + return createSuccessResponse(result); + } catch (error) { + logger.error('Token refresh failed', error); + reply.code(401); + return createErrorResponse(401, 'Invalid refresh token'); + } + }); + + // Forgot password + fastify.post<{ Body: ForgotPasswordRequest }>('/auth/password/forgot', async (request, reply) => { + const logger = new Logger({ action: 'forgot-password', email: request.body.email }); + + try { + logger.info('Forgot password attempt started'); + + await authService.forgotPassword(request.body); + + logger.info('Forgot password email sent'); + return createSuccessResponse({ message: 'Reset code sent to email' }); + } catch (error) { + logger.error('Forgot password failed', error); + reply.code(400); + return createErrorResponse(400, extractErrorMessage(error, 'Failed to send reset code')); + } + }); + + // Reset password + fastify.post<{ Body: ResetPasswordRequest }>('/auth/password/reset', async (request, reply) => { + const logger = new Logger({ action: 'reset-password', email: request.body.email }); + + try { + logger.info('Password reset attempt started'); + + await authService.resetPassword(request.body); + + logger.info('Password reset successful'); + return createSuccessResponse({ message: 'Password reset successful' }); + } catch (error) { + logger.error('Password reset failed', error); + reply.code(400); + return createErrorResponse(400, extractErrorMessage(error, 'Password reset failed')); + } + }); + + // Logout endpoint to clear cookies + fastify.post('/auth/logout', async (request, reply) => { + const logger = new Logger({ action: 'logout' }); + + try { + logger.info('Logout attempt started'); + + // Clear all authentication cookies + reply.clearCookie('access_token', { path: '/' }); + reply.clearCookie('id_token', { path: '/' }); + reply.clearCookie('refresh_token', { path: '/' }); + + logger.info('Logout successful'); + return createSuccessResponse({ message: 'Logout successful' }); + } catch (error) { + logger.error('Logout failed', error); + reply.code(400); + return createErrorResponse(400, 'Logout failed'); + } + }); + }); +} \ No newline at end of file diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts new file mode 100644 index 0000000..99c82c3 --- /dev/null +++ b/backend/src/routes/health.ts @@ -0,0 +1,28 @@ +import { FastifyInstance } from 'fastify'; + +/** + * Health check routes + */ +export function registerHealthRoutes(app: FastifyInstance): void { + // Basic health check + app.get('/health', async () => { + return { + status: 'healthy', + timestamp: new Date().toISOString(), + service: 'auth-poc-backend', + }; + }); + + // Detailed health check with system info + app.get('/health/detailed', async () => { + return { + status: 'healthy', + timestamp: new Date().toISOString(), + service: 'auth-poc-backend', + version: process.env.npm_package_version || '1.0.0', + uptime: process.uptime(), + memory: process.memoryUsage(), + environment: process.env.NODE_ENV || 'development', + }; + }); +} \ No newline at end of file diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..8b1ff32 --- /dev/null +++ b/backend/src/routes/users.ts @@ -0,0 +1,113 @@ +import { FastifyInstance } from 'fastify'; +import { UserService } from '../services/user-service'; +import { Logger } from '../utils/logger'; +import { createSuccessResponse } from '../utils/response'; +import { requireAuth } from '../middleware/auth'; + +/** + * User management routes (protected) + */ +export function registerUserRoutes(app: FastifyInstance): void { + const userService = new UserService(); + + // Register protected API routes under /api prefix + app.register(async (fastify) => { + // Apply authentication middleware to all routes in this context + requireAuth(fastify); + + // Get user profile + fastify.get('/api/user', async (request: any) => { + const logger = new Logger({ + action: 'get-user-profile', + userId: request.authContext.userId + }); + + try { + logger.info('Getting user profile'); + + const user = await userService.getUserProfile(request.authContext.userId); + + logger.info('User profile retrieved'); + return createSuccessResponse(user); + } catch (error) { + logger.error('Failed to get user profile', error); + throw error; + } + }); + + // Update user profile + fastify.put<{ Body: any }>('/api/user', async (request: any) => { + const logger = new Logger({ + action: 'update-user-profile', + userId: request.authContext.userId + }); + + try { + logger.info('Updating user profile'); + + const updatedUser = await userService.updateUserProfile( + request.authContext.userId, + request.body + ); + + logger.info('User profile updated'); + return createSuccessResponse(updatedUser); + } catch (error) { + logger.error('Failed to update user profile', error); + throw error; + } + }); + + // Get protected data + fastify.get('/api/data', async (request: any) => { + const logger = new Logger({ + action: 'get-protected-data', + userId: request.authContext.userId + }); + + try { + logger.info('Getting protected data'); + + const data = { + message: 'This is protected data', + user: { + id: request.authContext.userId, + email: request.authContext.email, + tokenType: request.authContext.tokenType, + }, + timestamp: new Date().toISOString(), + }; + + logger.info('Protected data retrieved'); + return createSuccessResponse(data); + } catch (error) { + logger.error('Failed to get protected data', error); + throw error; + } + }); + + // Auth context test endpoint + fastify.get('/api/auth-test', async (request: any) => { + const logger = new Logger({ + action: 'auth-test', + userId: request.authContext.userId + }); + + try { + logger.info('Testing auth context'); + + const authContextData = { + message: 'Auth context test successful', + authContext: request.authContext, + timestamp: new Date().toISOString(), + }; + + logger.info('Auth context test completed'); + return createSuccessResponse(authContextData); + } catch (error) { + logger.error('Auth context test failed', error); + throw error; + } + }); + }); +} \ No newline at end of file diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..d614a1a --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,34 @@ +import { createApp } from './app'; +import { Logger } from './utils/logger'; + +/** + * Development server entry point + * Uses the shared app factory for consistency with Lambda handler + */ +const start = async () => { + const logger = new Logger({ service: 'DevServer' }); + + try { + // Create app using the shared factory + const app = createApp({ + environment: 'development', + logger: false + }); + + // Start the server + await app.listen({ port: 3001, host: '0.0.0.0' }); + + logger.info('Development server started successfully'); + console.log('πŸš€ Development server listening on http://localhost:3001'); + console.log('πŸ“š API Documentation: http://localhost:3001/health'); + console.log('πŸ” Detailed Health: http://localhost:3001/health/detailed'); + console.log('πŸ” Auth Routes: http://localhost:3001/auth/*'); + console.log('πŸ‘€ Protected API: http://localhost:3001/api/*'); + } catch (err) { + logger.error('Failed to start development server', err); + process.exit(1); + } +}; + +// Start the server +start(); \ No newline at end of file diff --git a/backend/src/services/auth-service.ts b/backend/src/services/auth-service.ts index 48a7799..941e1fc 100644 --- a/backend/src/services/auth-service.ts +++ b/backend/src/services/auth-service.ts @@ -3,9 +3,8 @@ import { SignUpCommand, ForgotPasswordCommand, ConfirmForgotPasswordCommand, - AdminGetUserCommand, - AdminCreateUserCommand, } from '@aws-sdk/client-cognito-identity-provider'; + import axios from 'axios'; import { LoginRequest, SignupRequest, @@ -93,145 +92,26 @@ import { ); } - async authenticateWithGoogle(request: GoogleAuthRequest): Promise { - const logger = this.logger.withContext({ action: 'google-auth' }); + // Legacy Google OAuth method - keeping for backward compatibility during migration + async authenticateWithGoogle(_request: GoogleAuthRequest): Promise { + const logger = this.logger.withContext({ action: 'google-auth-legacy' }); + + try { + logger.info('Legacy Google authentication - this method is deprecated'); + logger.info('Please use the new OAuth2 flow via GET /auth/google instead'); - try { - logger.info('Starting Google authentication with request', { - hasCode: !!request.code, - redirectUri: request.redirectUri - }); - - // Exchange code for tokens with better error handling - logger.info('Attempting token exchange with Google', { - codeLength: request.code?.length, - redirectUri: request.redirectUri, - googleClientId: process.env.GOOGLE_CLIENT_ID?.substring(0, 20) + '...' - }); - - const { tokens } = await this.googleClient.getToken({ - code: request.code, - redirect_uri: request.redirectUri, - }); - - if (!tokens.id_token) { - throw new Error('No ID token received from Google'); - } - - logger.info('Google tokens received successfully'); - - // Verify the Google token - const ticket = await this.googleClient.verifyIdToken({ - idToken: tokens.id_token, - audience: process.env.GOOGLE_CLIENT_ID!, - }); - - const payload = ticket.getPayload(); - if (!payload || !payload.email) { - throw new Error('Invalid Google token payload or missing email'); - } - - logger.info('Google token verified', { email: payload.email }); - - // For Google federated users, return a custom token structure - // that bypasses Cognito password authentication entirely - const federatedResult = await this.handleGoogleFederatedAuth(payload); - - logger.info('Google federated authentication successful'); - return federatedResult; - } catch (error) { - logger.error('Google authentication failed', error); - throw error; - } + // For now, throw an error to encourage migration to new flow + throw new Error('Legacy Google authentication is deprecated. Please use the new OAuth2 flow via GET /auth/google'); + } catch (error) { + logger.error('Legacy Google authentication failed', error); + throw error; } + } - private async handleGoogleFederatedAuth(googleUser: any): Promise { - try { - // Ensure user exists in Cognito - const user = await this.ensureGoogleUserExists(googleUser); - console.log('user in handleGoogleFederatedAuth', user); - // For federated users, we'll create a custom token structure - // that mimics Cognito tokens but works with our authorizer - - const customTokens = this.createFederatedTokens({...googleUser, userId: user.Username}); - - this.logger.info('Created federated tokens for Google user', { email: googleUser.email }); - return customTokens; - } catch (error) { - this.logger.error('Failed to handle Google federated auth', error); - throw error; - } - } - - private async ensureGoogleUserExists(googleUser: any): Promise { - return this.executeOperation( - async () => { - try { - // Try to get existing user - const user = await this.cognitoClient.send(new AdminGetUserCommand({ - UserPoolId: this.config.aws.cognito.userPoolId, - Username: googleUser.email, - })); - - this.createLogger({ email: googleUser.email }).info('Google user already exists in Cognito'); - return user; - } catch (error) { - // User doesn't exist, create them as federated user - const userAttributes = this.createUserAttributes({ - email: googleUser.email, - email_verified: 'true', - given_name: googleUser.given_name || '', - family_name: googleUser.family_name || '', - }); - - const user = await this.cognitoClient.send(new AdminCreateUserCommand({ - UserPoolId: this.config.aws.cognito.userPoolId, - Username: googleUser.email, - UserAttributes: userAttributes, - MessageAction: 'SUPPRESS', - // No password - this is a federated user - })); - - this.createLogger({ email: googleUser.email }).info('Created Google federated user in Cognito'); - return user; - } - }, - 'Ensure Google User Exists', - { email: googleUser.email } - ); - } - - private createFederatedTokens(googleUser: any): CognitoAuthResult { - // Create custom tokens that work with our authorizer - // These will be recognized as Google tokens by the authorizer - const basePayload = { - sub: googleUser.userId, - email: googleUser.email, - email_verified: googleUser.email_verified, - name: googleUser.name, - given_name: googleUser.given_name, - family_name: googleUser.family_name, - picture: googleUser.picture, - aud: process.env.GOOGLE_CLIENT_ID, - iss: 'https://accounts.google.com', - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour expiry - }; - - // Create a simple base64 encoded token that our authorizer can recognize - const tokenPayload = Buffer.from(JSON.stringify(basePayload)).toString('base64'); - const customToken = `google.${tokenPayload}`; - - return { - accessToken: customToken, - idToken: customToken, - refreshToken: '', // No refresh for federated tokens in this simple implementation - expiresIn: 3600, - tokenType: 'Bearer', - }; - } + // Old custom Google OAuth methods removed - now using proper Cognito federation + // These methods have been replaced by getGoogleAuthUrl() and exchangeCodeForTokens() async refreshToken(request: RefreshTokenRequest): Promise { return this.executeOperation( @@ -298,6 +178,82 @@ import { ); } + /** + * Constructs the Cognito OAuth2 authorize URL for Google authentication + * @param redirectUri - The callback URL after authentication + * @returns The OAuth2 authorize URL + */ + getGoogleAuthUrl(redirectUri: string): string { + const cognitoDomain = this.config.aws.cognito.domain; + const clientId = this.config.aws.cognito.clientId; + + if (!cognitoDomain) { + throw new Error('COGNITO_DOMAIN is not configured'); + } + + const params = new URLSearchParams({ + client_id: clientId, + response_type: 'code', + scope: 'openid email profile', + redirect_uri: redirectUri, + identity_provider: 'Google', + }); + + return `https://${cognitoDomain}.auth.${this.config.aws.region}.amazoncognito.com/oauth2/authorize?${params.toString()}`; + } + + /** + * Exchanges the OAuth2 authorization code for tokens via Cognito + * @param code - The authorization code from the callback + * @param redirectUri - The same redirect URI used in the authorize request + * @returns Promise The tokens from Cognito + */ + async exchangeCodeForTokens(code: string, redirectUri: string): Promise { + return this.executeOperation( + async () => { + const cognitoDomain = this.config.aws.cognito.domain; + const clientId = this.config.aws.cognito.clientId; + const clientSecret = await this.getCognitoClientSecret(); + + if (!cognitoDomain) { + throw new Error('COGNITO_DOMAIN is not configured'); + } + + const tokenEndpoint = `${cognitoDomain}/oauth2/token`; + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + code: code, + redirect_uri: redirectUri, + }); + + const response = await axios.post(tokenEndpoint, params, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + const tokens = response.data; + + if (!tokens.access_token || !tokens.id_token) { + throw new Error('Invalid token response from Cognito'); + } + + return { + accessToken: tokens.access_token, + idToken: tokens.id_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + tokenType: tokens.token_type, + }; + }, + 'OAuth Token Exchange', + { code: code.substring(0, 10) + '...' } + ); + } + private generateTemporaryPassword(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; diff --git a/backend/src/services/authorization-service.ts b/backend/src/services/authorization-service.ts index 73465f1..db278ee 100644 --- a/backend/src/services/authorization-service.ts +++ b/backend/src/services/authorization-service.ts @@ -1,5 +1,4 @@ import { CognitoJwtVerifier } from 'aws-jwt-verify'; -import { OAuth2Client } from 'google-auth-library'; import { Logger } from '../utils/logger'; import { getConfig } from '../config'; @@ -28,21 +27,14 @@ export interface DecodedToken { exp: number; iat: number; email_verified?: boolean; + // Additional fields for federated users + name?: string; + given_name?: string; + family_name?: string; + picture?: string; } -export interface GoogleTokenPayload { - sub: string; - email: string; - email_verified: boolean; - name: string; - given_name: string; - family_name: string; - picture: string; - aud: string; - iss: string; - iat: number; - exp: number; -} +// GoogleTokenPayload interface removed - now using Cognito federation export class AuthorizationError extends Error { constructor(message: string, public statusCode: number = 401) { @@ -53,7 +45,6 @@ export class AuthorizationError extends Error { export class AuthorizationService { private cognitoVerifier: CognitoJwtVerifier | null = null; - private googleClient: OAuth2Client | null = null; private logger: Logger; constructor() { @@ -69,11 +60,6 @@ export class AuthorizationService { clientId: config.aws.cognito.clientId, }); } - - if (!this.googleClient) { - const config = getConfig(); - this.googleClient = new OAuth2Client(config.google.clientId); - } } extractTokenFromHeader(authorizationHeader: string): string { @@ -110,100 +96,24 @@ export class AuthorizationService { } } - async verifyGoogleToken(token: string): Promise { - try { - // Check if this is our custom federated token format - if (token.startsWith('google.')) { - return this.verifyCustomGoogleToken(token); - } - - // Otherwise, verify as standard Google ID token - this.initializeVerifiers(); - - if (!this.googleClient) { - throw new AuthorizationError('Google client not initialized'); - } - - const config = getConfig(); - const ticket = await this.googleClient.verifyIdToken({ - idToken: token, - audience: config.google.clientId, - }); - - const payload = ticket.getPayload(); - if (!payload) { - throw new AuthorizationError('Invalid Google token payload'); - } + // Google token verification removed - now using Cognito federation + // All Google-authenticated users will have valid Cognito tokens - this.logger.info('Google token verified successfully', { - sub: payload.sub, - email: payload.email - }); - - return payload as GoogleTokenPayload; - } catch (error) { - this.logger.error('Google token verification failed:', error); - throw new AuthorizationError('Invalid Google token'); - } - } - - verifyCustomGoogleToken(token: string): GoogleTokenPayload { + async verifyToken(token: string): Promise<{ userInfo: DecodedToken; tokenType: 'cognito' }> { try { - // Extract the base64 payload from our custom token - const base64Payload = token.substring('google.'.length); - const payloadString = Buffer.from(base64Payload, 'base64').toString(); - const payload = JSON.parse(payloadString); - - // Verify token hasn't expired - const now = Math.floor(Date.now() / 1000); - if (payload.exp && payload.exp < now) { - throw new AuthorizationError('Token has expired'); - } - - // Verify audience - const config = getConfig(); - if (payload.aud !== config.google.clientId) { - throw new AuthorizationError('Invalid token audience'); - } - - this.logger.info('Custom Google token verified successfully', { - sub: payload.sub, - email: payload.email - }); - - return payload as GoogleTokenPayload; + // All tokens (including federated Google users) are now Cognito tokens + const userInfo = await this.verifyCognitoToken(token); + return { userInfo, tokenType: 'cognito' }; } catch (error) { - this.logger.error('Custom Google token verification failed:', error); - throw new AuthorizationError('Invalid custom Google token'); - } - } - - async verifyToken(token: string): Promise<{ userInfo: DecodedToken | GoogleTokenPayload; tokenType: 'cognito' | 'google' }> { - let userInfo: DecodedToken | GoogleTokenPayload; - let tokenType: 'cognito' | 'google'; - - try { - // Try Cognito first - userInfo = await this.verifyCognitoToken(token); - tokenType = 'cognito'; - } catch (cognitoError) { - try { - // If Cognito fails, try Google - userInfo = await this.verifyGoogleToken(token); - tokenType = 'google'; - } catch (googleError) { - this.logger.error('Both token verifications failed', { - cognitoError: cognitoError instanceof Error ? cognitoError.message : 'Unknown error', - googleError: googleError instanceof Error ? googleError.message : 'Unknown error', - }); - throw new AuthorizationError('Invalid token'); - } + this.logger.error('Token verification failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + throw new AuthorizationError('Invalid token'); } - - return { userInfo, tokenType }; } - createAuthContext(userInfo: DecodedToken | GoogleTokenPayload, tokenType: 'cognito' | 'google'): AuthContext { + createAuthContext(userInfo: DecodedToken, tokenType: 'cognito'): AuthContext { + // All tokens are now Cognito tokens, including federated Google users const baseContext: AuthContext = { userId: userInfo.sub, email: userInfo.email, @@ -211,23 +121,17 @@ export class AuthorizationService { emailVerified: userInfo.email_verified || false, }; - if (tokenType === 'cognito') { - const cognitoUser = userInfo as DecodedToken; - return { - ...baseContext, - username: cognitoUser['cognito:username'] || cognitoUser.sub, - tokenUse: cognitoUser.token_use || '', - }; - } else { - const googleUser = userInfo as GoogleTokenPayload; - return { - ...baseContext, - name: googleUser.name || '', - givenName: googleUser.given_name || '', - familyName: googleUser.family_name || '', - picture: googleUser.picture || '', - }; - } + // For Cognito tokens (including federated users) + return { + ...baseContext, + username: userInfo['cognito:username'] || userInfo.sub, + tokenUse: userInfo.token_use || '', + // For federated users, additional Google info may be in custom attributes + name: userInfo.name || '', + givenName: userInfo.given_name || '', + familyName: userInfo.family_name || '', + picture: userInfo.picture || '', + }; } createAuthContextFromHeaders(headers: Record): AuthContext | null { diff --git a/backend/src/services/base-service.ts b/backend/src/services/base-service.ts index 30630dd..3049dfe 100644 --- a/backend/src/services/base-service.ts +++ b/backend/src/services/base-service.ts @@ -1,4 +1,4 @@ -import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; +import { CognitoIdentityProviderClient, DescribeUserPoolClientCommand } from '@aws-sdk/client-cognito-identity-provider'; import { OAuth2Client } from 'google-auth-library'; import { createHmac } from 'crypto'; import { Logger } from '../utils/logger'; @@ -110,6 +110,34 @@ export abstract class BaseService { .map(([key, value]) => ({ Name: key, Value: String(value) })); } + /** + * Gets the Cognito client secret at runtime if not available in config + * @returns Promise The client secret + */ + protected async getCognitoClientSecret(): Promise { + if (this.config.aws.cognito.clientSecret) { + return this.config.aws.cognito.clientSecret; + } + + try { + const command = new DescribeUserPoolClientCommand({ + UserPoolId: this.config.aws.cognito.userPoolId, + ClientId: this.config.aws.cognito.clientId, + }); + + const response = await this.cognitoClient.send(command); + + if (!response.UserPoolClient?.ClientSecret) { + throw new Error('Client secret not found in User Pool Client'); + } + + return response.UserPoolClient.ClientSecret; + } catch (error) { + this.logger.error('Failed to retrieve Cognito client secret', error); + throw new Error('Failed to retrieve Cognito client secret'); + } + } + /** * Generates a unique identifier for operations * @returns Unique identifier string From 9a72bcb7db28e7db9afafaa1d65796a916208b04 Mon Sep 17 00:00:00 2001 From: Krishnal Jadav Date: Fri, 4 Jul 2025 22:52:08 +0530 Subject: [PATCH 2/4] fixed: updated readme and removed deadcode --- backend/src/routes/auth.ts | 21 ++------------------- backend/src/server.ts | 7 +------ backend/src/services/auth-service.ts | 20 -------------------- 3 files changed, 3 insertions(+), 45 deletions(-) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 4c3ef85..daa6e2e 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -6,7 +6,6 @@ import { extractErrorMessage } from '../utils/errors'; import { LoginRequest, SignupRequest, - GoogleAuthRequest, RefreshTokenRequest, ForgotPasswordRequest, ResetPasswordRequest, @@ -75,23 +74,7 @@ export function registerAuthRoutes(app: FastifyInstance): void { } }); - // Google OAuth (legacy POST endpoint for backward compatibility) - fastify.post<{ Body: GoogleAuthRequest }>('/auth/google', async (request, reply) => { - const logger = new Logger({ action: 'google-auth-legacy' }); - - try { - logger.info('Legacy Google auth attempt started'); - - const result = await authService.authenticateWithGoogle(request.body); - - logger.info('Legacy Google auth successful'); - return createSuccessResponse(result); - } catch (error) { - logger.error('Legacy Google auth failed', error); - reply.code(400); - return createErrorResponse(400, extractErrorMessage(error, 'Google authentication failed')); - } - }); + // OAuth callback endpoint fastify.get('/auth/callback', async (request, reply) => { @@ -207,7 +190,7 @@ export function registerAuthRoutes(app: FastifyInstance): void { }); // Logout endpoint to clear cookies - fastify.post('/auth/logout', async (request, reply) => { + fastify.get('/auth/logout', async (request, reply) => { const logger = new Logger({ action: 'logout' }); try { diff --git a/backend/src/server.ts b/backend/src/server.ts index d614a1a..000e7ac 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -18,12 +18,7 @@ const start = async () => { // Start the server await app.listen({ port: 3001, host: '0.0.0.0' }); - logger.info('Development server started successfully'); - console.log('πŸš€ Development server listening on http://localhost:3001'); - console.log('πŸ“š API Documentation: http://localhost:3001/health'); - console.log('πŸ” Detailed Health: http://localhost:3001/health/detailed'); - console.log('πŸ” Auth Routes: http://localhost:3001/auth/*'); - console.log('πŸ‘€ Protected API: http://localhost:3001/api/*'); + logger.info('Development server started successfully on port 3001'); } catch (err) { logger.error('Failed to start development server', err); process.exit(1); diff --git a/backend/src/services/auth-service.ts b/backend/src/services/auth-service.ts index 941e1fc..0251823 100644 --- a/backend/src/services/auth-service.ts +++ b/backend/src/services/auth-service.ts @@ -8,7 +8,6 @@ import { import { LoginRequest, SignupRequest, - GoogleAuthRequest, RefreshTokenRequest, ForgotPasswordRequest, ResetPasswordRequest, @@ -92,26 +91,7 @@ import { ); } - // Legacy Google OAuth method - keeping for backward compatibility during migration - async authenticateWithGoogle(_request: GoogleAuthRequest): Promise { - const logger = this.logger.withContext({ action: 'google-auth-legacy' }); - - try { - logger.info('Legacy Google authentication - this method is deprecated'); - logger.info('Please use the new OAuth2 flow via GET /auth/google instead'); - - // For now, throw an error to encourage migration to new flow - throw new Error('Legacy Google authentication is deprecated. Please use the new OAuth2 flow via GET /auth/google'); - } catch (error) { - logger.error('Legacy Google authentication failed', error); - throw error; - } - } - - - // Old custom Google OAuth methods removed - now using proper Cognito federation - // These methods have been replaced by getGoogleAuthUrl() and exchangeCodeForTokens() async refreshToken(request: RefreshTokenRequest): Promise { return this.executeOperation( From d643de00e26ce5d576d6a59dc33391058fed0358 Mon Sep 17 00:00:00 2001 From: Krishnal Jadav Date: Fri, 4 Jul 2025 23:03:35 +0530 Subject: [PATCH 3/4] fix: updated README with new refactoring --- README.md | 215 ++++++++++-------- frontend/src/components/AuthCallback.tsx | 50 +--- frontend/src/components/Dashboard.tsx | 4 +- frontend/src/components/GoogleLoginButton.tsx | 23 +- frontend/src/components/LoginForm.tsx | 14 +- frontend/src/contexts/AuthContext.tsx | 107 ++++++--- frontend/src/services/api-client.ts | 74 +++++- frontend/src/services/auth-service.ts | 16 +- infrastructure/lib/auth-poc-stack.ts | 44 +++- 9 files changed, 335 insertions(+), 212 deletions(-) diff --git a/README.md b/README.md index aa60095..2c13cd7 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,35 @@ # AWS Cognito Authentication PoC -A proof-of-concept authentication system demonstrating AWS Cognito integration with React frontend and Node.js backend. This PoC showcases dual authentication patterns: traditional email/password via Cognito and Google OAuth with custom token handling. +A proof-of-concept authentication system demonstrating AWS Cognito integration with React frontend and Node.js backend. This PoC showcases dual authentication patterns: traditional email/password via Cognito and Google OAuth with federated identity using Cognito as the identity provider. ## πŸ—οΈ Architecture Overview ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ React SPA β”‚ β”‚ API Gateway β”‚ β”‚ Lambda Functions β”‚ -β”‚ (Frontend) │◄──►│ + Cognito │◄──►│ (Authorizer + β”‚ -β”‚ β”‚ β”‚ Authorizer β”‚ β”‚ Business Logic) β”‚ +β”‚ React SPA β”‚ β”‚ Node.js/Fastifyβ”‚ β”‚ AWS Cognito β”‚ +β”‚ (Frontend) │◄──►│ Backend Server │◄──►│ User Pool with β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ Google Federation β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ AWS Cognito β”‚ β”‚ Google OAuth β”‚ β”‚ Node.js/Fastify β”‚ -β”‚ User Pool β”‚ β”‚ Provider β”‚ β”‚ Backend Services β”‚ +β”‚ Authentication β”‚ β”‚ Dual Auth Flow β”‚ β”‚ Google OAuth β”‚ +β”‚ State Mgmt β”‚ β”‚ β€’ Email/Passwordβ”‚ β”‚ Identity Provider β”‚ +β”‚ β€’ localStorage β”‚ β”‚ β€’ HTTP Cookies β”‚ β”‚ (Federated) β”‚ +β”‚ β€’ Cookies β”‚ β”‚ β€’ JWT Tokens β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ## πŸš€ Features Demonstrated -- **Dual Authentication Patterns**: Email/password (Cognito) and Google OAuth -- **Custom Token Handling**: Google OAuth with custom token format for Lambda authorizer -- **JWT Token Management**: Token verification and user context extraction -- **Lambda Authorizer**: Unified authorization for both Cognito and Google tokens +- **Dual Authentication Patterns**: Email/password (Cognito) and Google OAuth (federated) +- **Cognito Federation**: Google OAuth through Cognito User Pool with Identity Provider +- **Unified Token Management**: All tokens are Cognito JWT tokens for consistent handling +- **HTTP-Only Cookie Security**: Secure token storage for OAuth flows +- **Dual Auth Methods**: Authorization headers (tokens) and HTTP cookies - **React SPA**: Modern frontend with authentication flows +- **Modular Backend Architecture**: Separated routes, middleware, and business logic - **Infrastructure as Code**: AWS CDK deployment automation - **Local Development**: Docker Compose setup for development - **Environment Agnostic Auth**: Works both locally and on AWS @@ -33,10 +37,11 @@ A proof-of-concept authentication system demonstrating AWS Cognito integration w ## 🎯 PoC Objectives This proof-of-concept demonstrates: -1. **Hybrid Authentication**: Combining AWS Cognito with third-party OAuth providers -2. **Custom Token Strategy**: Handling non-Cognito tokens in AWS Lambda authorizers +1. **Federated Authentication**: Using AWS Cognito with Google as a federated identity provider +2. **Dual Authentication Methods**: Supporting both localStorage tokens and HTTP-only cookies 3. **Unified User Experience**: Seamless auth flow regardless of provider -4. **Development-to-AWS Pipeline**: Consistent behavior across environments +4. **Proper Security Patterns**: Server-side OAuth flow with secure cookie handling +5. **Development-to-AWS Pipeline**: Consistent behavior across environments ## πŸ“ Project Structure @@ -48,9 +53,18 @@ auth-poc/ β”‚ β”œβ”€β”€ bin/ β”‚ β”‚ └── auth-poc.ts β”‚ └── package.json -β”œβ”€β”€ backend/ # Node.js Backend +β”œβ”€β”€ backend/ # Node.js Backend (Modular Architecture) β”‚ β”œβ”€β”€ src/ -β”‚ β”‚ β”œβ”€β”€ handlers/ # Lambda functions +β”‚ β”‚ β”œβ”€β”€ app.ts # Shared app factory +β”‚ β”‚ β”œβ”€β”€ server.ts # Development server +β”‚ β”‚ β”œβ”€β”€ lambda.ts # Lambda handler +β”‚ β”‚ β”œβ”€β”€ routes/ # API routes +β”‚ β”‚ β”‚ β”œβ”€β”€ auth.ts # Authentication routes +β”‚ β”‚ β”‚ β”œβ”€β”€ health.ts # Health check routes +β”‚ β”‚ β”‚ └── users.ts # User routes +β”‚ β”‚ β”œβ”€β”€ middleware/ # Middleware functions +β”‚ β”‚ β”‚ β”œβ”€β”€ auth.ts # Auth middleware +β”‚ β”‚ β”‚ └── cors.ts # CORS middleware β”‚ β”‚ β”œβ”€β”€ services/ # Business logic β”‚ β”‚ β”œβ”€β”€ types/ # TypeScript types β”‚ β”‚ └── utils/ # Utility functions @@ -73,96 +87,81 @@ sequenceDiagram participant User as User participant Frontend as React SPA
(Frontend) participant Browser as Browser - participant Google as Google OAuth - participant APIGW as API Gateway - participant Auth as Lambda Authorizer - participant Backend as Backend Lambda
(Fastify) + participant Backend as Backend Server
(Fastify) participant Cognito as AWS Cognito
User Pool + participant Google as Google OAuth Note over User, Cognito: Email/Password Authentication Flow User->>Frontend: Enter email/password - Frontend->>APIGW: POST /auth/login
{email, password} - APIGW->>Backend: Forward request + Frontend->>Backend: POST /auth/login
{email, password} Backend->>Backend: AuthService.login() Backend->>Cognito: InitiateAuthCommand
USER_PASSWORD_AUTH Cognito->>Backend: AccessToken, IdToken, RefreshToken - Backend->>APIGW: 200 OK + tokens - APIGW->>Frontend: Return tokens + Backend->>Frontend: 200 OK + tokens Frontend->>Frontend: Store tokens in localStorage - Note over Frontend, Cognito: Protected Resource Access + Note over User, Cognito: Google OAuth Authentication Flow (Cognito Federation) - Frontend->>APIGW: GET /api/user
Authorization: Bearer {token} - APIGW->>Auth: Validate token - Auth->>Auth: AuthorizationService.authorizeRequest() - Auth->>Cognito: Verify JWT token - Cognito->>Auth: Token valid + user info - Auth->>Auth: Create AuthContext - Auth->>APIGW: Allow policy + context - APIGW->>Backend: Forward with auth context - Backend->>APIGW: User profile data - APIGW->>Frontend: Return user data - Frontend->>User: Display dashboard - - Note over User, Cognito: Google OAuth Authentication Flow + User->>Frontend: Click "Continue with Google" + Frontend->>Browser: Redirect to backend + Browser->>Backend: GET /auth/google + Backend->>Backend: AuthService.getGoogleAuthUrl() + Backend->>Browser: Redirect to Cognito OAuth2 + Browser->>Cognito: OAuth2 authorize request
with identity_provider=Google + Cognito->>Browser: Redirect to Google OAuth + Browser->>Google: Authorization request + Google->>User: Login & consent screen + User->>Google: Authorize application + Google->>Browser: Redirect with code + Browser->>Backend: GET /auth/callback?code=xyz + Backend->>Backend: AuthService.exchangeCodeForTokens() + Backend->>Cognito: POST /oauth2/token
Exchange code for tokens + Cognito->>Backend: AccessToken, IdToken, RefreshToken
(Cognito tokens with Google user data) + Backend->>Browser: Set HTTP-only cookies
(access_token, id_token, refresh_token) + Browser->>Frontend: Redirect to /dashboard + Frontend->>Frontend: Detect authentication via protected endpoint - User->>Frontend: Click "Sign in with Google" - Frontend->>Browser: Redirect to Google OAuth - Browser->>Google: Authorization request
response_type=code - Google->>Browser: User consent & authorization - Browser->>Google: User authorizes app - Google->>Browser: Redirect to /auth/callback?code=xyz - Browser->>Frontend: Load AuthCallback component - Frontend->>Frontend: Extract code from URL - Frontend->>APIGW: POST /auth/google
{code, redirectUri} - APIGW->>Backend: Forward request - Backend->>Backend: AuthService.authenticateWithGoogle() - Backend->>Google: Exchange code for tokens - Google->>Backend: ID token + access token - Backend->>Google: Verify ID token - Google->>Backend: Token payload validated - Backend->>Backend: handleGoogleFederatedAuth() - Backend->>Cognito: AdminGetUser (check if exists) - alt User doesn't exist - Cognito->>Backend: User not found - Backend->>Cognito: AdminCreateUser
(federated user) - Cognito->>Backend: User created - else User exists - Cognito->>Backend: User details - end - Backend->>Backend: createFederatedTokens()
Custom google.{base64} format - Backend->>APIGW: 200 OK + custom tokens - APIGW->>Frontend: Return custom tokens - Frontend->>Frontend: Store tokens in localStorage + Note over Frontend, Cognito: Protected Resource Access (Token-based Auth) + + Frontend->>Backend: GET /api/user
Authorization: Bearer {token} + Backend->>Backend: Auth middleware checks header + Backend->>Backend: AuthorizationService.verifyToken() + Backend->>Cognito: Verify JWT token + Cognito->>Backend: Token valid + user info + Backend->>Backend: Create AuthContext + Backend->>Frontend: User profile data + Frontend->>User: Display dashboard - Note over Frontend, Cognito: Protected Resource Access (Google Token) + Note over Frontend, Cognito: Protected Resource Access (Cookie-based Auth) - Frontend->>APIGW: GET /api/user
Authorization: Bearer google.{base64} - APIGW->>Auth: Validate custom token - Auth->>Auth: AuthorizationService.authorizeRequest() - Auth->>Auth: Try Cognito verification (fails) - Auth->>Auth: Try Google verification - Auth->>Auth: Detect custom token format - Auth->>Auth: verifyCustomGoogleToken() - Auth->>Auth: Decode base64 payload
Verify expiry & audience - Auth->>Auth: Create AuthContext - Auth->>APIGW: Allow policy + context - APIGW->>Backend: Forward with auth context - Backend->>APIGW: User profile data - APIGW->>Frontend: Return user data + Frontend->>Backend: GET /api/user
Cookies: access_token=... + Backend->>Backend: Auth middleware checks cookies + Backend->>Backend: Extract token from cookie + Backend->>Backend: AuthorizationService.verifyToken() + Backend->>Cognito: Verify JWT token + Cognito->>Backend: Token valid + user info + Backend->>Backend: Create AuthContext + Backend->>Frontend: User profile data Frontend->>User: Display dashboard Note over Frontend, Cognito: Token Refresh Flow - Frontend->>APIGW: POST /auth/refresh
{refreshToken} - APIGW->>Backend: Forward request + Frontend->>Backend: POST /auth/refresh
{refreshToken} Backend->>Backend: AuthService.refreshToken() Backend->>Cognito: InitiateAuthCommand
REFRESH_TOKEN_AUTH Cognito->>Backend: New AccessToken + IdToken - Backend->>APIGW: 200 OK + new tokens - APIGW->>Frontend: Return new tokens + Backend->>Frontend: 200 OK + new tokens Frontend->>Frontend: Update stored tokens + + Note over Frontend, Backend: Logout Flow + + User->>Frontend: Click logout + Frontend->>Backend: GET /auth/logout + Backend->>Backend: Clear HTTP-only cookies + Backend->>Frontend: 200 OK + Frontend->>Frontend: Clear localStorage tokens + Frontend->>Frontend: Redirect to login ``` ## πŸ› οΈ Prerequisites @@ -179,7 +178,7 @@ sequenceDiagram ### 1. Clone the Repository ```bash -git clone +git clone https://github.com/yourusername/auth-poc.git cd auth-poc ``` @@ -204,6 +203,7 @@ GOOGLE_CLIENT_SECRET=your-google-client-secret COGNITO_USER_POOL_ID=us-west-2_XXXXXXXXX COGNITO_CLIENT_ID=your-cognito-client-id COGNITO_CLIENT_SECRET=your-cognito-client-secret +COGNITO_DOMAIN=auth-poc-dev-domain # Development Stage STAGE=dev @@ -242,6 +242,7 @@ After deployment, update your `.env` file with the Cognito values from the CDK o - `COGNITO_USER_POOL_ID` - `COGNITO_CLIENT_ID` - `COGNITO_CLIENT_SECRET` +- `COGNITO_DOMAIN` Then restart your local development environment to use the deployed Cognito resources. @@ -307,8 +308,9 @@ The CDK stack (`AuthPocStack`) creates: - **Cognito User Pool**: User management with email/password authentication - **Cognito User Pool Client**: App client with OAuth settings for Google integration -- **Google Identity Provider**: Federated identity configuration -- **Lambda Authorizer**: Custom authorizer for dual token validation +- **Google Identity Provider**: Federated identity configuration for Google OAuth +- **Cognito Domain**: OAuth2 endpoints for authorization and token exchange +- **Lambda Authorizer**: Custom authorizer for Cognito token validation - **Lambda Backend**: Fastify API handlers for authentication endpoints - **API Gateway**: REST API with custom authorizer integration - **IAM Roles**: Lambda execution roles with minimal permissions @@ -319,8 +321,11 @@ The CDK stack (`AuthPocStack`) creates: ## πŸ” Security Features Implemented ### Authentication -- **JWT Token Validation**: Cognito and custom Google token verification -- **Dual Token Support**: Handles both Cognito JWTs and custom Google tokens +- **Unified JWT Token Validation**: All tokens are Cognito JWTs for consistent verification +- **Dual Authentication Methods**: Support for both Authorization header and HTTP-only cookies +- **Federated Identity**: Google OAuth through Cognito User Pool with proper federation +- **Server-Side OAuth Flow**: Secure authorization code exchange on the backend +- **HTTP-Only Cookie Security**: Secure token storage for OAuth flows preventing XSS attacks - **Token Expiry**: Automatic token expiration checking - **Input Validation**: TypeScript interfaces for request validation @@ -400,15 +405,21 @@ GOOGLE_CLIENT_SECRET_PROD ### Public Endpoints - `POST /auth/login` - Email/password authentication - `POST /auth/signup` - User registration -- `POST /auth/google` - Google OAuth authentication +- `GET /auth/google` - Google OAuth authentication (redirects to Cognito) +- `GET /auth/callback` - OAuth callback handler (exchanges code for tokens) - `POST /auth/refresh` - Token refresh - `POST /auth/password/forgot` - Password reset request - `POST /auth/password/reset` - Password reset confirmation +- `GET /auth/logout` - Logout (clears HTTP-only cookies) ### Protected Endpoints (require authentication) - `GET /api/user` - Get user profile - `PUT /api/user` - Update user profile - `GET /api/data` - Get protected data +- `GET /api/auth-test` - Test authentication context + +### Health Check +- `GET /health` - Health check endpoint ## πŸ”§ Configuration @@ -476,21 +487,37 @@ aws cognito-idp describe-user-pool --user-pool-id # Verify Google OAuth settings # Check redirect URIs in Google Cloud Console +# For development: http://localhost:3001/auth/callback +# For production: https://your-api-domain.com/auth/callback + +# Test authentication endpoints +curl -X POST http://localhost:3001/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "test@example.com", "password": "password"}' + +# Test protected endpoints with cookies +curl -b cookies.txt http://localhost:3001/api/user + +# Check backend logs for authentication issues +docker-compose logs backend ``` ## πŸŽ“ Learning Outcomes -This PoC demonstrates key concepts for building hybrid authentication systems: +This PoC demonstrates key concepts for building federated authentication systems: ### Technical Learnings -- **Custom Lambda Authorizer**: How to validate multiple token types in one authorizer -- **Token Strategy**: Handling OAuth providers that don't integrate natively with Cognito +- **Cognito Federation**: How to properly integrate Google OAuth with Cognito User Pool +- **Server-Side OAuth Flow**: Implementing secure authorization code exchange on the backend +- **Dual Authentication Methods**: Supporting both token-based and cookie-based authentication +- **HTTP-Only Cookie Security**: Preventing XSS attacks with secure cookie handling - **Environment Consistency**: Making auth work both locally and in AWS - **CDK Infrastructure**: Deploying authentication infrastructure as code ### Architecture Patterns -- **Unified User Experience**: Single auth flow for multiple providers -- **Token Abstraction**: Consistent user context regardless of auth method +- **Unified Token Management**: All authentication tokens are Cognito JWTs +- **Modular Backend Design**: Separating routes, middleware, and business logic +- **Secure Authentication Flow**: Server-side OAuth with proper token handling - **Development Workflow**: Local development with production parity ## πŸ“š Additional Resources diff --git a/frontend/src/components/AuthCallback.tsx b/frontend/src/components/AuthCallback.tsx index 704a454..9c519cc 100644 --- a/frontend/src/components/AuthCallback.tsx +++ b/frontend/src/components/AuthCallback.tsx @@ -1,53 +1,25 @@ import React, { useEffect } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; export const AuthCallback: React.FC = () => { - const { loginWithGoogle } = useAuth(); - const location = useLocation(); const navigate = useNavigate(); useEffect(() => { - const urlParams = new URLSearchParams(location.search); - const code = urlParams.get('code'); - const error = urlParams.get('error'); - - if (error) { - console.error('OAuth error:', error); - navigate('/login?error=oauth_failed'); - return; - } - - if (code) { - // Prevent double-processing in React.StrictMode by marking the code as used - const storageKey = `oauth_code_${code}`; - if (sessionStorage.getItem(storageKey)) { - console.log('OAuth code already processed, skipping'); - return; - } - - sessionStorage.setItem(storageKey, 'used'); - - loginWithGoogle(code) - .then(() => { - navigate('/dashboard'); - }) - .catch((error) => { - console.error('Google login failed:', error); - // Remove the storage marker if login failed so it can be retried - sessionStorage.removeItem(storageKey); - navigate('/login?error=oauth_failed'); - }); - } else { - navigate('/login'); - } - }, [location, loginWithGoogle, navigate]); + // OAuth callback is now handled entirely by the backend + // If users land here, redirect them to login + navigate('/login'); + }, [navigate]); return (
-

Completing authentication...

+

+ OAuth authentication is now handled by the backend. +

+

+ Redirecting to login... +

); diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index d657dc6..d2f5f19 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -22,7 +22,7 @@ export const Dashboard: React.FC = () => { const data = await authService.getProtectedData(); setProtectedData(data); } catch (error) { - console.error('Failed to load protected data:', error); + // Error is handled by API client } finally { setLoading(false); } @@ -35,7 +35,7 @@ export const Dashboard: React.FC = () => { await updateUser(editForm); setIsEditing(false); } catch (error) { - console.error('Failed to update profile:', error); + // Error is handled by auth context } finally { setLoading(false); } diff --git a/frontend/src/components/GoogleLoginButton.tsx b/frontend/src/components/GoogleLoginButton.tsx index 143a0fd..1445f4f 100644 --- a/frontend/src/components/GoogleLoginButton.tsx +++ b/frontend/src/components/GoogleLoginButton.tsx @@ -4,22 +4,22 @@ import { useAuth } from '../contexts/AuthContext'; export const GoogleLoginButton: React.FC = () => { const { isLoading } = useAuth(); - const handleGoogleLogin = () => { - const googleClientId = process.env.REACT_APP_GOOGLE_CLIENT_ID; - const redirectUri = `${window.location.origin}/auth/callback`; - const scope = 'openid email profile'; - const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + - `client_id=${googleClientId}&` + - `redirect_uri=${encodeURIComponent(redirectUri)}&` + - `response_type=code&` + - `scope=${encodeURIComponent(scope)}&` + - `access_type=offline&` + - `prompt=consent`; + const authDomain = process.env.REACT_APP_AUTH_DOMAIN; + + const authorizeParams = new URLSearchParams() + const handleGoogleLogin = () => { + authorizeParams.append('response_type', 'code') + authorizeParams.append('client_id', process.env.REACT_APP_COGNITO_CLIENT_ID as string) + authorizeParams.append('redirect_uri', `http://localhost:3001/auth/callback`) + authorizeParams.append('identity_provider', 'Google') + authorizeParams.append('scope', 'profile email openid') + const googleAuthUrl = `${authDomain}/oauth2/authorize?${authorizeParams.toString()}`; window.location.href = googleAuthUrl; }; return ( + <> + ); }; \ No newline at end of file diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx index 8b1fdb0..e4db8df 100644 --- a/frontend/src/components/LoginForm.tsx +++ b/frontend/src/components/LoginForm.tsx @@ -15,20 +15,10 @@ export const LoginForm: React.FC = () => { clearError(); try { - // await login({ email, password }); - // sessionStorage.setItem(storageKey, 'used'); - - login({email, password}) - .then(() => { - navigate('/dashboard'); - }) - .catch((error) => { - console.error('Cognito login failed:', error); - navigate('/login?error=cognito_login_failed'); - }); + await login({ email, password }); + navigate('/dashboard'); } catch (error) { // Error is handled by the auth context - console.error('Error in handleSubmit:', error); navigate('/login?error=cognito_login_failed'); } }; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index e74ee5f..be37e31 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -9,17 +9,19 @@ interface AuthState { isLoading: boolean; isAuthenticated: boolean; error: string | null; + authMethod: 'token' | 'cookie' | null; // Track authentication method } type AuthAction = | { type: 'LOGIN_START' } - | { type: 'LOGIN_SUCCESS'; payload: { tokens: AuthTokens; user: User } } + | { type: 'LOGIN_SUCCESS'; payload: { tokens?: AuthTokens; user: User; authMethod: 'token' | 'cookie' } } | { type: 'LOGIN_FAILURE'; payload: string } | { type: 'LOGOUT' } | { type: 'SET_LOADING'; payload: boolean } | { type: 'CLEAR_ERROR' } | { type: 'TOKEN_REFRESHED'; payload: AuthTokens } - | { type: 'USER_UPDATED'; payload: User }; + | { type: 'USER_UPDATED'; payload: User } + | { type: 'AUTH_CHECKED'; payload: { isAuthenticated: boolean; user?: User; authMethod?: 'token' | 'cookie' } }; const initialState: AuthState = { user: null, @@ -27,6 +29,7 @@ const initialState: AuthState = { isLoading: true, isAuthenticated: false, error: null, + authMethod: null, }; const authReducer = (state: AuthState, action: AuthAction): AuthState => { @@ -37,10 +40,11 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { return { ...state, user: action.payload.user, - tokens: action.payload.tokens, + tokens: action.payload.tokens || null, isLoading: false, isAuthenticated: true, error: null, + authMethod: action.payload.authMethod, }; case 'LOGIN_FAILURE': return { @@ -50,6 +54,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { isLoading: false, isAuthenticated: false, error: action.payload, + authMethod: null, }; case 'LOGOUT': return { @@ -58,6 +63,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { tokens: null, isAuthenticated: false, error: null, + authMethod: null, }; case 'SET_LOADING': return { ...state, isLoading: action.payload }; @@ -67,6 +73,14 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { return { ...state, tokens: action.payload }; case 'USER_UPDATED': return { ...state, user: action.payload }; + case 'AUTH_CHECKED': + return { + ...state, + isAuthenticated: action.payload.isAuthenticated, + user: action.payload.user || null, + authMethod: action.payload.authMethod || null, + isLoading: false, + }; default: return state; } @@ -75,11 +89,11 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { interface AuthContextType extends AuthState { login: (credentials: LoginCredentials) => Promise; signup: (data: SignupData) => Promise; - loginWithGoogle: (code: string) => Promise; - logout: () => void; + logout: () => Promise; refreshTokens: () => Promise; updateUser: (userData: Partial) => Promise; clearError: () => void; + checkAuthStatus: () => Promise; } const AuthContext = createContext(undefined); @@ -102,7 +116,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children tokenStorage.setTokens(tokens); const user = await authService.getCurrentUser(); - dispatch({ type: 'LOGIN_SUCCESS', payload: { tokens, user } }); + dispatch({ type: 'LOGIN_SUCCESS', payload: { tokens, user, authMethod: 'token' } }); } catch (error) { const message = error instanceof Error ? error.message : 'Login failed'; dispatch({ type: 'LOGIN_FAILURE', payload: message }); @@ -123,31 +137,22 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }, []); - const loginWithGoogle = useCallback(async (code: string) => { - dispatch({ type: 'LOGIN_START' }); + const logout = useCallback(async () => { try { - const tokens = await authService.authenticateWithGoogle({ - code, - redirectUri: `${window.location.origin}/auth/callback`, - }); - console.log('tokens in loginWithGoogle', tokens); - tokenStorage.setTokens(tokens); - const user = await authService.getCurrentUser(); - + // Call backend logout endpoint to clear HTTP-only cookies + await authService.logout(); - dispatch({ type: 'LOGIN_SUCCESS', payload: { tokens, user } }); + // Clear local tokens and update state + tokenStorage.clearTokens(); + dispatch({ type: 'LOGOUT' }); } catch (error) { - const message = error instanceof Error ? error.message : 'Google login failed'; - dispatch({ type: 'LOGIN_FAILURE', payload: message }); - throw error; + console.error('Logout failed:', error); + // Still clear local state even if server logout fails + tokenStorage.clearTokens(); + dispatch({ type: 'LOGOUT' }); } }, []); - const logout = useCallback(() => { - tokenStorage.clearTokens(); - dispatch({ type: 'LOGOUT' }); - }, []); - const refreshTokens = useCallback(async () => { const currentTokens = tokenStorage.getTokens(); if (!currentTokens?.refreshToken) { @@ -172,7 +177,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const updatedUser = await authService.updateUserProfile(userData); dispatch({ type: 'USER_UPDATED', payload: updatedUser }); } catch (error) { - console.error('Failed to update user:', error); throw error; } }, []); @@ -181,30 +185,63 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children dispatch({ type: 'CLEAR_ERROR' }); }, []); + // Check authentication status by calling a protected endpoint + const checkAuthStatus = useCallback(async () => { + try { + // Try to get user profile from a protected endpoint + // This works for both token-based and cookie-based auth + const user = await authService.getCurrentUser(); + + // Check if we have tokens in localStorage (token-based auth) + const tokens = tokenStorage.getTokens(); + const authMethod = tokens ? 'token' : 'cookie'; + + dispatch({ + type: 'AUTH_CHECKED', + payload: { + isAuthenticated: true, + user, + authMethod + } + }); + } catch (error) { + // If the protected endpoint fails, user is not authenticated + dispatch({ + type: 'AUTH_CHECKED', + payload: { + isAuthenticated: false + } + }); + } + }, []); + // Initialize auth state on app load useEffect(() => { const initAuth = async () => { + // Check for existing tokens first (email/password auth) const tokens = tokenStorage.getTokens(); if (tokens) { try { const user = await authService.getCurrentUser(); - dispatch({ type: 'LOGIN_SUCCESS', payload: { tokens, user } }); + dispatch({ type: 'LOGIN_SUCCESS', payload: { tokens, user, authMethod: 'token' } }); } catch (error) { - console.error('Failed to restore auth session:', error); + console.error('Failed to restore token-based auth session:', error); tokenStorage.clearTokens(); - dispatch({ type: 'SET_LOADING', payload: false }); + // Still check for cookie-based auth + await checkAuthStatus(); } } else { - dispatch({ type: 'SET_LOADING', payload: false }); + // No tokens, check for cookie-based auth (Google OAuth) + await checkAuthStatus(); } }; initAuth(); - }, []); + }, [checkAuthStatus]); - // Auto token refresh + // Auto token refresh (only for token-based auth) useEffect(() => { - if (!state.tokens || !state.isAuthenticated) return; + if (!state.tokens || !state.isAuthenticated || state.authMethod !== 'token') return; const tokenRefreshInterval = setInterval(() => { const tokens = tokenStorage.getTokens(); @@ -218,17 +255,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }, 60000); // Check every minute return () => clearInterval(tokenRefreshInterval); - }, [state.tokens, state.isAuthenticated, refreshTokens]); + }, [state.tokens, state.isAuthenticated, state.authMethod, refreshTokens]); const value: AuthContextType = { ...state, login, signup, - loginWithGoogle, logout, refreshTokens, updateUser, clearError, + checkAuthStatus, }; return {children}; diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 5ded731..1011fe1 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -24,12 +24,12 @@ class ApiClient { const url = `${this.baseURL}${endpoint}`; const tokens = tokenStorage.getTokens(); - console.log('tokens in request', tokens); const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record), }; + // Add Authorization header if we have tokens (token-based auth) if (tokens?.accessToken) { headers.Authorization = `Bearer ${tokens.accessToken}`; } @@ -37,17 +37,66 @@ class ApiClient { const config: RequestInit = { ...options, headers, + credentials: 'include', // Include cookies for cookie-based auth }; try { const response = await fetch(url, config); - const data = await response.json(); + + // Handle different response types + let data; + const contentType = response.headers.get('content-type'); + + if (contentType && contentType.includes('application/json')) { + data = await response.json(); + } else { + // For non-JSON responses (like redirects), create a simple response + data = { success: response.ok, data: null }; + } if (!response.ok) { - throw new Error(data.error?.message || `HTTP ${response.status}`); + // Handle error responses + if (data.error) { + throw new Error(data.error.message || `HTTP ${response.status}`); + } else if (data.body && typeof data.body === 'string') { + // Handle wrapped error responses + try { + const parsedBody = JSON.parse(data.body); + if (parsedBody.error) { + throw new Error(parsedBody.error.message || `HTTP ${response.status}`); + } + } catch (parseError) { + // If parsing fails, use the original error + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } + + // Handle successful responses + // Check if response is already in the correct format + if (data.success !== undefined && data.data !== undefined) { + return data; } - console.log('data in request1', JSON.parse(data.body)); - return JSON.parse(data.body); + + // Handle wrapped responses (from Lambda) + if (data.body && typeof data.body === 'string') { + try { + const parsedBody = JSON.parse(data.body); + if (parsedBody.success !== undefined && parsedBody.data !== undefined) { + return parsedBody; + } + // If parsed body doesn't have expected structure, wrap it + return { success: true, data: parsedBody }; + } catch (parseError) { + // If parsing fails, wrap the original response + return { success: true, data: data.body }; + } + } + + // For direct responses without wrapper + return { success: true, data }; } catch (error) { console.error('API request failed:', error); throw error; @@ -59,10 +108,19 @@ class ApiClient { } async post(endpoint: string, data?: any): Promise> { - return this.request(endpoint, { + const options: RequestInit = { method: 'POST', - body: data ? JSON.stringify(data) : undefined, - }); + }; + + // If we have data, set body and let default Content-Type apply + if (data) { + options.body = JSON.stringify(data); + } else { + // If no data, don't set Content-Type to avoid Fastify error + options.headers = {}; + } + + return this.request(endpoint, options); } async put(endpoint: string, data?: any): Promise> { diff --git a/frontend/src/services/auth-service.ts b/frontend/src/services/auth-service.ts index e964b0a..4897bf0 100644 --- a/frontend/src/services/auth-service.ts +++ b/frontend/src/services/auth-service.ts @@ -1,4 +1,4 @@ -import { User, AuthTokens, LoginCredentials, SignupData, GoogleAuthData } from '../types/auth'; +import { User, AuthTokens, LoginCredentials, SignupData } from '../types/auth'; import { apiClient } from './api-client'; class AuthService { @@ -12,13 +12,9 @@ class AuthService { return response.data; } - async authenticateWithGoogle(data: GoogleAuthData): Promise { - console.log('data in authenticateWithGoogle', data); - const response = await apiClient.post('/auth/google', data); - console.log('response in authenticateWithGoogle', response); - console.log('response in authenticateWithGoogle', response.data); - return response.data; - } + // Google authentication is now handled via server-side OAuth redirect + // Users click "Continue with Google" button which redirects to /api/auth/google + // Backend handles the OAuth flow and redirects back to /dashboard async refreshToken(data: { refreshToken: string }): Promise { const response = await apiClient.post('/auth/refresh', data); @@ -47,6 +43,10 @@ class AuthService { return response.data; } + async logout(): Promise { + await apiClient.get('/auth/logout'); + } + async getProtectedData(): Promise { const response = await apiClient.get('/api/data'); return response.data; diff --git a/infrastructure/lib/auth-poc-stack.ts b/infrastructure/lib/auth-poc-stack.ts index 86cc3ed..11b9a20 100644 --- a/infrastructure/lib/auth-poc-stack.ts +++ b/infrastructure/lib/auth-poc-stack.ts @@ -92,8 +92,8 @@ export class AuthPocStack extends cdk.Stack { cognito.OAuthScope.PROFILE, ], callbackUrls: [ - 'http://localhost:3000/auth/callback', - `https://auth-${props.stage}.demo.krishnal.com/auth/callback`, + 'http://localhost:3001/api/auth/callback', + `https://auth-${props.stage}.demo.krishnal.com/api/auth/callback`, ], logoutUrls: [ 'http://localhost:3000/', @@ -111,6 +111,14 @@ export class AuthPocStack extends cdk.Stack { this.userPoolClient.node.addDependency(googleProvider); + // Create Cognito Domain for OAuth2 endpoints + const cognitoDomain = new cognito.UserPoolDomain(this, 'CognitoDomain', { + userPool: this.userPool, + cognitoDomain: { + domainPrefix: `auth-poc-${props.stage}`, + }, + }); + // Create Lambda Authorizer Function this.authorizerFunction = new nodejs.NodejsFunction(this, 'AuthorizerFunction', { runtime: lambda.Runtime.NODEJS_18_X, // Note: NODEJS_20_X not yet available in this CDK version @@ -162,7 +170,8 @@ export class AuthPocStack extends cdk.Stack { environment: { COGNITO_USER_POOL_ID: this.userPool.userPoolId, COGNITO_CLIENT_ID: this.userPoolClient.userPoolClientId, - COGNITO_CLIENT_SECRET: '', // Will be set via parameter store + // COGNITO_CLIENT_SECRET is retrieved at runtime from the User Pool Client + COGNITO_DOMAIN: cognitoDomain.domainName, STAGE: props.stage, GOOGLE_CLIENT_ID: props.googleClientId, GOOGLE_CLIENT_SECRET: props.googleClientSecret, @@ -188,6 +197,15 @@ export class AuthPocStack extends cdk.Stack { resources: [this.userPool.userPoolArn], })); + // Grant permissions to describe User Pool Client (needed to get client secret) + backendFunction.addToRolePolicy(new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'cognito-idp:DescribeUserPoolClient', + ], + resources: [this.userPool.userPoolArn], + })); + // Create API Gateway with Lambda Authorizer const authorizer = new apigateway.TokenAuthorizer(this, 'LambdaAuthorizer', { handler: this.authorizerFunction, @@ -236,11 +254,26 @@ export class AuthPocStack extends cdk.Stack { // Google OAuth endpoints const googleResource = authResource.addResource('google'); + // Keep the old POST endpoint for backward compatibility during migration googleResource.addMethod('POST', new apigateway.LambdaIntegration(backendFunction, { proxy: true, }) ); + // Add new GET endpoint for OAuth redirect + googleResource.addMethod('GET', + new apigateway.LambdaIntegration(backendFunction, { + proxy: true, + }) + ); + + // OAuth callback endpoint + const callbackResource = authResource.addResource('callback'); + callbackResource.addMethod('GET', + new apigateway.LambdaIntegration(backendFunction, { + proxy: true, + }) + ); // Signup endpoint authResource.addResource('signup').addMethod('POST', @@ -333,6 +366,11 @@ export class AuthPocStack extends cdk.Stack { description: 'Cognito User Pool Client ID', }); + new cdk.CfnOutput(this, 'CognitoDomainUrl', { + value: cognitoDomain.domainName, + description: 'Cognito Domain for OAuth2 endpoints', + }); + new cdk.CfnOutput(this, 'ApiUrl', { value: this.api.url, description: 'API Gateway URL', From ec19ba0feb154e2e4d8211a258c1975c46745f9e Mon Sep 17 00:00:00 2001 From: Krishnal Jadav Date: Fri, 4 Jul 2025 23:12:57 +0530 Subject: [PATCH 4/4] fix: unit tests fixed for frontend --- frontend/src/__tests__/AuthContext.test.tsx | 34 ++++++++++++-- frontend/src/__tests__/LoginForm.test.tsx | 51 ++++++++++++++++++++- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/frontend/src/__tests__/AuthContext.test.tsx b/frontend/src/__tests__/AuthContext.test.tsx index 5b395d4..67117d4 100644 --- a/frontend/src/__tests__/AuthContext.test.tsx +++ b/frontend/src/__tests__/AuthContext.test.tsx @@ -14,7 +14,7 @@ jest.mock('../services/auth-service', () => ({ authService: { login: jest.fn(), signup: jest.fn(), - authenticateWithGoogle: jest.fn(), + logout: jest.fn(), refreshToken: jest.fn(), forgotPassword: jest.fn(), resetPassword: jest.fn(), @@ -73,15 +73,23 @@ describe('AuthContext', () => { mockedAuthService.login.mockClear(); mockedAuthService.getCurrentUser.mockClear(); mockedTokenStorage.getTokens.mockReturnValue(null); + + // Mock the initialization API call to fail (no existing auth) + mockedAuthService.getCurrentUser.mockRejectedValue(new Error('Not authenticated')); }); - it('should provide initial state', () => { + it('should provide initial state', async () => { render( ); + // Wait for initialization to complete + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('Not Loading'); + }); + expect(screen.getByTestId('user')).toHaveTextContent('No User'); expect(screen.getByTestId('error')).toHaveTextContent('No Error'); }); @@ -105,7 +113,10 @@ describe('AuthContext', () => { // Mock the auth service methods mockedAuthService.login.mockResolvedValueOnce(mockTokens); - mockedAuthService.getCurrentUser.mockResolvedValueOnce(mockUser); + // First call during initialization fails, second call during login succeeds + mockedAuthService.getCurrentUser + .mockRejectedValueOnce(new Error('Not authenticated')) // initialization + .mockResolvedValueOnce(mockUser); // login render( @@ -113,6 +124,11 @@ describe('AuthContext', () => { ); + // Wait for initialization to complete + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('Not Loading'); + }); + const loginButton = screen.getByText('Login'); await userEvent.click(loginButton); @@ -126,7 +142,7 @@ describe('AuthContext', () => { mockedAuthService.login.mockRejectedValue('Invalid credentials'); const TestLoginComponent = () => { - const { error, login } = useAuth(); + const { error, login, isLoading } = useAuth(); React.useEffect(() => { // Call login directly and catch the error @@ -140,7 +156,12 @@ describe('AuthContext', () => { performLogin(); }, [login]); - return
{error || 'No Error'}
; + return ( +
+
{error || 'No Error'}
+
{isLoading ? 'Loading' : 'Not Loading'}
+
+ ); }; render( @@ -153,5 +174,8 @@ describe('AuthContext', () => { // When error is not an Error instance, AuthContext shows 'Login failed' expect(screen.getByTestId('error')).toHaveTextContent('Login failed'); }); + + // Should not be loading after error + expect(screen.getByTestId('loading')).toHaveTextContent('Not Loading'); }); }); \ No newline at end of file diff --git a/frontend/src/__tests__/LoginForm.test.tsx b/frontend/src/__tests__/LoginForm.test.tsx index 753481e..23824bd 100644 --- a/frontend/src/__tests__/LoginForm.test.tsx +++ b/frontend/src/__tests__/LoginForm.test.tsx @@ -3,7 +3,39 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { BrowserRouter } from 'react-router-dom'; import { LoginForm } from '../components/LoginForm'; -import { AuthProvider } from '../contexts/AuthContext'; +import { AuthProvider, useAuth } from '../contexts/AuthContext'; + +// Mock the auth service to prevent network calls +jest.mock('../services/auth-service', () => ({ + authService: { + login: jest.fn(), + signup: jest.fn(), + logout: jest.fn(), + refreshToken: jest.fn(), + forgotPassword: jest.fn(), + resetPassword: jest.fn(), + getCurrentUser: jest.fn().mockRejectedValue(new Error('Not authenticated')), + updateUserProfile: jest.fn(), + getProtectedData: jest.fn(), + }, +})); + +jest.mock('../services/api-client', () => ({ + apiClient: { + post: jest.fn(), + get: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }, +})); + +jest.mock('../utils/token-storage', () => ({ + tokenStorage: { + setTokens: jest.fn(), + getTokens: jest.fn().mockReturnValue(null), + clearTokens: jest.fn(), + }, +})); const renderWithProviders = (component: React.ReactElement) => { return render( @@ -16,9 +48,14 @@ const renderWithProviders = (component: React.ReactElement) => { }; describe('LoginForm', () => { - it('should render login form', () => { + it('should render login form', async () => { renderWithProviders(); + // Wait for auth initialization to complete + await waitFor(() => { + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); + expect(screen.getByLabelText(/email address/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); @@ -27,6 +64,11 @@ describe('LoginForm', () => { it('should show validation errors for empty fields', async () => { renderWithProviders(); + // Wait for auth initialization to complete + await waitFor(() => { + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); + const submitButton = screen.getByRole('button', { name: /sign in/i }); await userEvent.click(submitButton); @@ -37,6 +79,11 @@ describe('LoginForm', () => { it('should toggle password visibility', async () => { renderWithProviders(); + // Wait for auth initialization to complete + await waitFor(() => { + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); + const passwordInput = screen.getByLabelText(/password/i); const toggleButton = screen.getByRole('button', { name: /πŸ‘οΈβ€πŸ—¨οΈ/ });