diff --git a/.env.development b/.env.development index 2c2fc36..c01088e 100644 --- a/.env.development +++ b/.env.development @@ -20,8 +20,8 @@ SET_PASSWORD_TOKEN_EXPIRES_IN=86400 SET_SESSION=1 # DATABSES -REDIS_URL="redis://localhost:6380" -MONGO_DATABASE_URL="mongodb://root:example@localhost:27017/typescript-backend-toolkit?authSource=admin" +REDIS_URL="redis://localhost:6388" +MONGO_DATABASE_URL="mongodb://root:example@localhost:27028/typescript-backend-toolkit?authSource=admin" # Mailgun Configuration (dummy values for development) MAILGUN_API_KEY="dummy-key" diff --git a/.env.sample b/.env.sample index e35a7ad..3417369 100644 --- a/.env.sample +++ b/.env.sample @@ -33,8 +33,7 @@ SET_SESSION=0 # GOOGLE AUTH GOOGLE_CLIENT_ID="" -GOOGLE_CLIENT_SECRET='' -GOOGLE_REDIRECT_URI = '' + # DATABSES REDIS_URL="" diff --git a/GoogleAuth.md b/GoogleAuth.md new file mode 100644 index 0000000..61300ba --- /dev/null +++ b/GoogleAuth.md @@ -0,0 +1,104 @@ +# Google Authentication Implementation Guide + +This document explains how to implement Google authentication in the Typescript Backend Toolkit using Google Identity Services. + +## Overview + +The implementation uses Google's newer Identity Services API, which provides a more secure and user-friendly authentication experience compared to the older OAuth 2.0 redirect flow. The key differences are: + +| Feature | Google Identity Services (New) | OAuth 2.0 Redirect Flow (Old) | +| -------------------- | --------------------------------- | ------------------------------ | +| User Experience | One-tap sign-in, popup-based flow | Full page redirects | +| Security | JWT-based ID tokens | Authorization code flow | +| Implementation | Client-side token generation | Server-side code exchange | +| Session Disruption | Minimal - stays on same page | High - navigates away from app | +| Cross-device Support | Better mobile/desktop integration | Requires more custom handling | + +## Prerequisites + +1. A Google Cloud Platform account +2. A configured OAuth 2.0 Client ID +3. Proper configuration of authorized JavaScript origins and redirect URIs + +## Environment Variables + +Add these variables to your `.env` file: + +``` +GOOGLE_CLIENT_ID=your-client-id-here +``` + +## Implementation Steps + +### 1. Frontend Implementation + +The frontend implementation uses Google's Identity Services library to handle the authentication flow: + +```html + + + + +
+ + +
+ + +``` + +## Security Considerations + +1. **Token Validation**: Always verify the ID token on your server using Google's libraries. +2. **Email Verification**: Check that the email is verified (`email_verified` claim). +3. **Expiration Time**: Verify the token hasn't expired (`exp` claim). +4. **HTTPS**: Always use HTTPS for token transmission. + +## Troubleshooting + +### Common Issues + +1. **Invalid Client ID**: Ensure your client ID is correctly configured in both frontend and backend. +2. **CORS Issues**: Make sure your domain is listed in the authorized JavaScript origins. +3. **Token Verification Failures**: Check that your server time is synchronized. +4. **Missing Scopes**: Ensure you're requesting the necessary scopes (email, profile). + +## References + +- [Google Identity Services Documentation](https://developers.google.com/identity/gsi/web/guides/overview) +- [Google OAuth 2.0 for Client-side Web Applications](https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow) +- [Verifying Google ID Tokens](https://developers.google.com/identity/sign-in/web/backend-auth) diff --git a/docker-compose.yml b/docker-compose.yml index e725df1..e9ce60e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: image: mongo:5.0.2 restart: 'unless-stopped' ports: - - '27017:27017' + - '27028:27017' environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: example @@ -13,7 +13,7 @@ services: redis: image: redis:latest ports: - - 6380:6379 + - 6388:6379 volumes: - redis_ts_toolkit:/data diff --git a/package.json b/package.json index 35b0732..34dbdd6 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "express": "^4.19.2", "express-async-handler": "^1.2.0", "express-session": "^1.18.0", + "google-auth-library": "^9.15.1", "helmet": "^6.0.1", "http-status-codes": "^2.3.0", "ioredis": "^5.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 383fe70..9060df8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: express-session: specifier: ^1.18.0 version: 1.18.1 + google-auth-library: + specifier: ^9.15.1 + version: 9.15.1 helmet: specifier: ^6.0.1 version: 6.2.0 @@ -2090,6 +2093,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -2199,6 +2206,9 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + bignumber.js@9.3.0: + resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -2806,6 +2816,9 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} @@ -2936,6 +2949,14 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + generic-pool@3.9.0: resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} engines: {node: '>= 4'} @@ -3004,6 +3025,14 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3011,6 +3040,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3069,6 +3102,10 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -3197,6 +3234,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -3271,6 +3312,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -3296,9 +3340,15 @@ packages: jwa@1.4.1: resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + kareem@2.6.3: resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==} engines: {node: '>=12.0.0'} @@ -6532,6 +6582,8 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.3: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -6654,6 +6706,8 @@ snapshots: dependencies: safe-buffer: 5.1.2 + bignumber.js@9.3.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -7469,6 +7523,8 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + fast-copy@3.0.2: {} fast-deep-equal@2.0.1: {} @@ -7599,6 +7655,26 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + generic-pool@3.9.0: {} gensync@1.0.0-beta.2: {} @@ -7687,10 +7763,32 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + gopd@1.2.0: {} graphemer@1.4.0: {} + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -7753,6 +7851,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -7887,6 +7992,8 @@ snapshots: dependencies: call-bound: 1.0.3 + is-stream@2.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.3 @@ -7960,6 +8067,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.0 + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -7991,11 +8102,22 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + jws@3.2.2: dependencies: jwa: 1.4.1 safe-buffer: 5.2.1 + jws@4.0.0: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + kareem@2.6.3: {} keyv@4.5.4: diff --git a/public/google-auth-test.html b/public/google-auth-test.html new file mode 100644 index 0000000..9f08d44 --- /dev/null +++ b/public/google-auth-test.html @@ -0,0 +1,202 @@ + + + + + + Typescript Backend Toolkit - Google Auth Test + + + + + +
+

Typescript Backend Toolkit Google Auth Test

+ +
+
+

Google Identity Services (New)

+ Active +

Uses the newer Google Identity Services API with improved security and UX:

+ +
+ +
+
+ + + +
+ +
+
+

Processing authentication...

+
+
+ + +
+ +
+

Response:

+
Sign in to see the response...
+
+
+ + + + diff --git a/src/config/config.service.ts b/src/config/config.service.ts index 8c52967..4ca83a1 100644 --- a/src/config/config.service.ts +++ b/src/config/config.service.ts @@ -1,47 +1,48 @@ -import dotenv from "dotenv"; -import { z } from "zod"; +import dotenv from 'dotenv'; +import { z } from 'zod'; dotenv.config(); // Remove .optional() from requried schema properties const configSchema = z.object({ - REDIS_URL: z.string().url(), - PORT: z.string().regex(/^\d+$/).transform(Number), - MONGO_DATABASE_URL: z.string().url(), - SMTP_HOST: z.string().min(1).optional(), - SMTP_PORT: z.string().regex(/^\d+$/).transform(Number).optional(), - SMTP_USERNAME: z.string().email().optional(), - EMAIL_FROM: z.string().email().optional(), - SMTP_FROM: z.string().min(1).optional(), - SMTP_PASSWORD: z.string().min(1).optional(), - CLIENT_SIDE_URL: z.string().url(), - JWT_SECRET: z.string().min(1), - JWT_EXPIRES_IN: z.string().default("86400").transform(Number), - SESSION_EXPIRES_IN: z.string().default("86400").transform(Number), - PASSWORD_RESET_TOKEN_EXPIRES_IN: z.string().default("86400").transform(Number), - SET_PASSWORD_TOKEN_EXPIRES_IN: z.string().default("86400").transform(Number), - STATIC_OTP: z.enum(["1", "0"]).transform(Number).optional(), - NODE_ENV: z - .union([z.literal("production"), z.literal("development")]) - .default("development") - .optional(), - SET_SESSION: z - .string() - .transform((value) => !!Number(value)) - .optional(), - GOOGLE_CLIENT_ID: z.string().optional(), - GOOGLE_CLIENT_SECRET: z.string().optional(), - GOOGLE_REDIRECT_URI: z.string().optional(), - APP_NAME: z.string().default("API V1"), - APP_VERSION: z.string().default("1.0.0"), - // Mailgun configuration - MAILGUN_API_KEY: z.string().min(1), - MAILGUN_DOMAIN: z.string().min(1), - MAILGUN_FROM_EMAIL: z.string().email(), - ADMIN_EMAIL: z.string().email(), - ADMIN_PASSWORD: z.string().min(1), - OTP_VERIFICATION_ENABLED: z.string().transform((value) => !!Number(value)), + REDIS_URL: z.string().url(), + PORT: z.string().regex(/^\d+$/).transform(Number), + MONGO_DATABASE_URL: z.string().url(), + SMTP_HOST: z.string().min(1).optional(), + SMTP_PORT: z.string().regex(/^\d+$/).transform(Number).optional(), + SMTP_USERNAME: z.string().email().optional(), + EMAIL_FROM: z.string().email().optional(), + SMTP_FROM: z.string().min(1).optional(), + SMTP_PASSWORD: z.string().min(1).optional(), + CLIENT_SIDE_URL: z.string().url(), + JWT_SECRET: z.string().min(1), + JWT_EXPIRES_IN: z.string().default('86400').transform(Number), + SESSION_EXPIRES_IN: z.string().default('86400').transform(Number), + PASSWORD_RESET_TOKEN_EXPIRES_IN: z + .string() + .default('86400') + .transform(Number), + SET_PASSWORD_TOKEN_EXPIRES_IN: z.string().default('86400').transform(Number), + STATIC_OTP: z.enum(['1', '0']).transform(Number).optional(), + NODE_ENV: z + .union([z.literal('production'), z.literal('development')]) + .default('development') + .optional(), + SET_SESSION: z + .string() + .transform((value) => !!Number(value)) + .optional(), + GOOGLE_CLIENT_ID: z.string().optional(), + APP_NAME: z.string().default('API V1'), + APP_VERSION: z.string().default('1.0.0'), + // Mailgun configuration + MAILGUN_API_KEY: z.string().min(1), + MAILGUN_DOMAIN: z.string().min(1), + MAILGUN_FROM_EMAIL: z.string().email(), + ADMIN_EMAIL: z.string().email(), + ADMIN_PASSWORD: z.string().min(1), + OTP_VERIFICATION_ENABLED: z.string().transform((value) => !!Number(value)), }); export type Config = z.infer; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index d0e1f31..78e1ae0 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,109 +1,106 @@ -import type { Request, Response } from "express"; -import config from "../../config/config.service"; -import type { GoogleCallbackQuery } from "../../types"; -import { successResponse } from "../../utils/api.utils"; -import type { JwtPayload } from "../../utils/auth.utils"; -import { AUTH_COOKIE_KEY, COOKIE_CONFIG } from "./auth.constants"; +import type { Request, Response } from 'express'; +import config from '../../config/config.service'; +import { successResponse } from '../../utils/api.utils'; +import type { JwtPayload } from '../../utils/auth.utils'; +import { AUTH_COOKIE_KEY, COOKIE_CONFIG } from './auth.constants'; import type { - ChangePasswordSchemaType, - ForgetPasswordSchemaType, - LoginUserByEmailSchemaType, - RegisterUserByEmailSchemaType, - ResetPasswordSchemaType, -} from "./auth.schema"; + ChangePasswordSchemaType, + ForgetPasswordSchemaType, + GoogleTokenVerificationSchemaType, + LoginUserByEmailSchemaType, + RegisterUserByEmailSchemaType, + ResetPasswordSchemaType, +} from './auth.schema'; import { - changePassword, - forgetPassword, - googleLogin, - loginUserByEmail, - registerUserByEmail, - resetPassword, -} from "./auth.service"; + changePassword, + forgetPassword, + loginUserByEmail, + registerUserByEmail, + resetPassword, + verifyGoogleToken, +} from './auth.service'; export const handleResetPassword = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - await resetPassword(req.body); + await resetPassword(req.body); - return successResponse(res, "Password successfully reset"); + return successResponse(res, 'Password successfully reset'); }; export const handleForgetPassword = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - const user = await forgetPassword(req.body); + const user = await forgetPassword(req.body); - return successResponse(res, "Code has been sent", { userId: user._id }); + return successResponse(res, 'Code has been sent', { userId: user._id }); }; export const handleChangePassword = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - await changePassword((req.user as JwtPayload).sub, req.body); + await changePassword((req.user as JwtPayload).sub, req.body); - return successResponse(res, "Password successfully changed"); + return successResponse(res, 'Password successfully changed'); }; export const handleRegisterUser = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - const user = await registerUserByEmail(req.body); + const user = await registerUserByEmail(req.body); - if (config.OTP_VERIFICATION_ENABLED) { - return successResponse(res, "Please check your email for OTP", user); - } + if (config.OTP_VERIFICATION_ENABLED) { + return successResponse(res, 'Please check your email for OTP', user); + } - return successResponse(res, "User has been reigstered", user); + return successResponse(res, 'User has been reigstered', user); }; export const handleLogout = async (_: Request, res: Response) => { - res.cookie(AUTH_COOKIE_KEY, undefined, COOKIE_CONFIG); + res.cookie(AUTH_COOKIE_KEY, undefined, COOKIE_CONFIG); - return successResponse(res, "Logout successful"); + return successResponse(res, 'Logout successful'); }; export const handleLoginByEmail = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - const token = await loginUserByEmail(req.body); - if (config.SET_SESSION) { - res.cookie(AUTH_COOKIE_KEY, token, COOKIE_CONFIG); - } - return successResponse(res, "Login successful", { token: token }); + const token = await loginUserByEmail(req.body); + if (config.SET_SESSION) { + res.cookie(AUTH_COOKIE_KEY, token, COOKIE_CONFIG); + } + return successResponse(res, 'Login successful', { token: token }); }; export const handleGetCurrentUser = async (req: Request, res: Response) => { - const user = req.user; + const user = req.user; - return successResponse(res, undefined, user); + return successResponse(res, undefined, user); }; -export const handleGoogleLogin = async (_: Request, res: Response) => { - if (!config.GOOGLE_CLIENT_ID || !config.GOOGLE_REDIRECT_URI) { - throw new Error("Google credentials are not set"); - } - const googleAuthURL = `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${config.GOOGLE_CLIENT_ID}&redirect_uri=${config.GOOGLE_REDIRECT_URI}&scope=email profile`; - - res.redirect(googleAuthURL); -}; -export const handleGoogleCallback = async ( - req: Request, - res: Response, +export const handleGoogleTokenVerification = async ( + req: Request, + res: Response, ) => { - const user = await googleLogin(req.query); - if (!user) throw new Error("Failed to login"); - res.cookie( - AUTH_COOKIE_KEY, - user.socialAccount?.[0]?.accessToken, - COOKIE_CONFIG, - ); - - return successResponse(res, "Logged in successfully", { - token: user.socialAccount?.[0]?.accessToken, - }); + const { user, token } = await verifyGoogleToken(req.body); + + if (config.SET_SESSION) { + res.cookie(AUTH_COOKIE_KEY, token, COOKIE_CONFIG); + } + + return successResponse(res, 'Google authentication successful', { + user: { + id: user._id, + email: user.email, + username: user.username, + avatar: user.avatar, + role: user.role, + }, + token, + }); }; diff --git a/src/modules/auth/auth.router.ts b/src/modules/auth/auth.router.ts index 5cf02d5..d983970 100644 --- a/src/modules/auth/auth.router.ts +++ b/src/modules/auth/auth.router.ts @@ -1,64 +1,66 @@ -import { canAccess } from "../../middlewares/can-access.middleware"; -import MagicRouter from "../../openapi/magic-router"; +import { canAccess } from '../../middlewares/can-access.middleware'; +import MagicRouter from '../../openapi/magic-router'; import { - handleChangePassword, - handleForgetPassword, - handleGetCurrentUser, - handleGoogleCallback, - handleGoogleLogin, - handleLoginByEmail, - handleLogout, - handleRegisterUser, - handleResetPassword, -} from "./auth.controller"; + handleChangePassword, + handleForgetPassword, + handleGetCurrentUser, + handleGoogleTokenVerification, + handleLoginByEmail, + handleLogout, + handleRegisterUser, + handleResetPassword, +} from './auth.controller'; import { - changePasswordSchema, - forgetPasswordSchema, - loginUserByEmailSchema, - registerUserByEmailSchema, - resetPasswordSchema, -} from "./auth.schema"; + changePasswordSchema, + forgetPasswordSchema, + googleTokenVerificationSchema, + loginUserByEmailSchema, + registerUserByEmailSchema, + resetPasswordSchema, +} from './auth.schema'; -export const AUTH_ROUTER_ROOT = "/auth"; +export const AUTH_ROUTER_ROOT = '/auth'; const authRouter = new MagicRouter(AUTH_ROUTER_ROOT); authRouter.post( - "/login/email", - { requestType: { body: loginUserByEmailSchema } }, - handleLoginByEmail, + '/login/email', + { requestType: { body: loginUserByEmailSchema } }, + handleLoginByEmail, ); authRouter.post( - "/register/email", - { requestType: { body: registerUserByEmailSchema } }, - handleRegisterUser, + '/register/email', + { requestType: { body: registerUserByEmailSchema } }, + handleRegisterUser, ); -authRouter.post("/logout", {}, handleLogout); +authRouter.post('/logout', {}, handleLogout); -authRouter.get("/me", {}, canAccess(), handleGetCurrentUser); +authRouter.get('/me', {}, canAccess(), handleGetCurrentUser); authRouter.post( - "/forget-password", - { requestType: { body: forgetPasswordSchema } }, - handleForgetPassword, + '/forget-password', + { requestType: { body: forgetPasswordSchema } }, + handleForgetPassword, ); authRouter.post( - "/change-password", - { requestType: { body: changePasswordSchema } }, - canAccess(), - handleChangePassword, + '/change-password', + { requestType: { body: changePasswordSchema } }, + canAccess(), + handleChangePassword, ); authRouter.post( - "/reset-password", - { requestType: { body: resetPasswordSchema } }, - handleResetPassword, + '/reset-password', + { requestType: { body: resetPasswordSchema } }, + handleResetPassword, ); -authRouter.get("/google", {}, handleGoogleLogin); -authRouter.get("/google/callback", {}, handleGoogleCallback); - +authRouter.post( + '/google/token', + { requestType: { body: googleTokenVerificationSchema } }, + handleGoogleTokenVerification, +); export default authRouter.getRouter(); diff --git a/src/modules/auth/auth.schema.ts b/src/modules/auth/auth.schema.ts index dc74e17..c10a6e8 100644 --- a/src/modules/auth/auth.schema.ts +++ b/src/modules/auth/auth.schema.ts @@ -1,57 +1,65 @@ -import validator from "validator"; -import z from "zod"; -import { passwordValidationSchema } from "../../common/common.schema"; -import { baseCreateUser } from "../user/user.schema"; +import validator from 'validator'; +import z from 'zod'; +import { passwordValidationSchema } from '../../common/common.schema'; +import { baseCreateUser } from '../user/user.schema'; export const resetPasswordSchema = z.object({ - userId: z - .string({ required_error: "userId is required" }) - .min(1) - .refine((value) => validator.isMongoId(value), "userId must be valid"), - code: z - .string({ required_error: "code is required" }) - .min(4) - .max(4) - .refine((value) => validator.isAlphanumeric(value), "code must be valid"), - password: passwordValidationSchema("Password"), - confirmPassword: passwordValidationSchema("Confirm password"), + userId: z + .string({ required_error: 'userId is required' }) + .min(1) + .refine((value) => validator.isMongoId(value), 'userId must be valid'), + code: z + .string({ required_error: 'code is required' }) + .min(4) + .max(4) + .refine((value) => validator.isAlphanumeric(value), 'code must be valid'), + password: passwordValidationSchema('Password'), + confirmPassword: passwordValidationSchema('Confirm password'), }); export const changePasswordSchema = z.object({ - currentPassword: passwordValidationSchema("Current password"), - newPassword: passwordValidationSchema("New password"), + currentPassword: passwordValidationSchema('Current password'), + newPassword: passwordValidationSchema('New password'), }); export const forgetPasswordSchema = z.object({ - email: z - .string({ required_error: "Email is required" }) - .email("Email must be valid"), + email: z + .string({ required_error: 'Email is required' }) + .email('Email must be valid'), }); export const registerUserByEmailSchema = z - .object({ - name: z.string({ required_error: "Name is required" }).min(1), - confirmPassword: passwordValidationSchema("Confirm Password"), - }) - .merge(baseCreateUser) - .strict() - .refine(({ password, confirmPassword }) => { - if (password !== confirmPassword) { - return false; - } - - return true; - }, "Password and confirm password must be same"); + .object({ + name: z.string({ required_error: 'Name is required' }).min(1), + confirmPassword: passwordValidationSchema('Confirm Password'), + }) + .merge(baseCreateUser) + .strict() + .refine(({ password, confirmPassword }) => { + if (password !== confirmPassword) { + return false; + } + + return true; + }, 'Password and confirm password must be same'); export const loginUserByEmailSchema = z.object({ - email: z - .string({ required_error: "Email is required" }) - .email({ message: "Email is not valid" }), - password: z.string().min(1, "Password is required"), + email: z + .string({ required_error: 'Email is required' }) + .email({ message: 'Email is not valid' }), + password: z.string().min(1, 'Password is required'), +}); +export const googleTokenVerificationSchema = z.object({ + idToken: z + .string({ required_error: 'Google ID token is required' }) + .min(1, 'Google ID token cannot be empty'), }); +export type GoogleTokenVerificationSchemaType = z.infer< + typeof googleTokenVerificationSchema +>; export type RegisterUserByEmailSchemaType = z.infer< - typeof registerUserByEmailSchema + typeof registerUserByEmailSchema >; export type LoginUserByEmailSchemaType = z.infer; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 99ca864..90b2e0f 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,187 +1,186 @@ -import config from "../../config/config.service"; -import { ROLE_ENUM, type RoleType, SOCIAL_ACCOUNT_ENUM } from "../../enums"; -import type { GoogleCallbackQuery } from "../../types"; +import config from '../../config/config.service'; +import { ROLE_ENUM, type RoleType, SOCIAL_ACCOUNT_ENUM } from '../../enums'; import { - type JwtPayload, - compareHash, - fetchGoogleTokens, - generateOTP, - getUserInfo, - hashPassword, - signToken, -} from "../../utils/auth.utils"; -import { generateRandomNumbers } from "../../utils/common.utils"; -import type { UserType } from "../user/user.dto"; + type JwtPayload, + compareHash, + generateOTP, + hashPassword, + signToken, + verifyGoogleIdToken, +} from '../../utils/auth.utils'; +import { generateRandomNumbers } from '../../utils/common.utils'; +import type { UserType } from '../user/user.dto'; import { - createUser, - getUserByEmail, - getUserById, - updateUser, -} from "../user/user.services"; + createUser, + getUserByEmail, + getUserById, + updateUser, +} from '../user/user.services'; import type { - ChangePasswordSchemaType, - ForgetPasswordSchemaType, - LoginUserByEmailSchemaType, - RegisterUserByEmailSchemaType, - ResetPasswordSchemaType, -} from "./auth.schema"; + ChangePasswordSchemaType, + ForgetPasswordSchemaType, + GoogleTokenVerificationSchemaType, + LoginUserByEmailSchemaType, + RegisterUserByEmailSchemaType, + ResetPasswordSchemaType, +} from './auth.schema'; export const resetPassword = async (payload: ResetPasswordSchemaType) => { - const user = await getUserById(payload.userId); + const user = await getUserById(payload.userId); - if (!user || user.passwordResetCode !== payload.code) { - throw new Error("token is not valid or expired, please try again"); - } + if (!user || user.passwordResetCode !== payload.code) { + throw new Error('token is not valid or expired, please try again'); + } - if (payload.confirmPassword !== payload.password) { - throw new Error("Password and confirm password must be same"); - } + if (payload.confirmPassword !== payload.password) { + throw new Error('Password and confirm password must be same'); + } - const hashedPassword = await hashPassword(payload.password); + const hashedPassword = await hashPassword(payload.password); - await updateUser(payload.userId, { - password: hashedPassword, - passwordResetCode: null, - }); + await updateUser(payload.userId, { + password: hashedPassword, + passwordResetCode: null, + }); }; export const forgetPassword = async ( - payload: ForgetPasswordSchemaType, + payload: ForgetPasswordSchemaType, ): Promise => { - const user = await getUserByEmail(payload.email); + const user = await getUserByEmail(payload.email); - if (!user) { - throw new Error("user doesn't exists"); - } + if (!user) { + throw new Error("user doesn't exists"); + } - const code = generateRandomNumbers(4); + const code = generateRandomNumbers(4); - await updateUser(user._id, { passwordResetCode: code }); + await updateUser(user._id, { passwordResetCode: code }); - return user; + return user; }; export const changePassword = async ( - userId: string, - payload: ChangePasswordSchemaType, + userId: string, + payload: ChangePasswordSchemaType, ): Promise => { - const user = await getUserById(userId, "+password"); + const user = await getUserById(userId, '+password'); - if (!user || !user.password) { - throw new Error("User is not found"); - } + if (!user || !user.password) { + throw new Error('User is not found'); + } - const isCurrentPassowordCorrect = await compareHash( - user.password, - payload.currentPassword, - ); + const isCurrentPassowordCorrect = await compareHash( + user.password, + payload.currentPassword, + ); - if (!isCurrentPassowordCorrect) { - throw new Error("current password is not valid"); - } + if (!isCurrentPassowordCorrect) { + throw new Error('current password is not valid'); + } - const hashedPassword = await hashPassword(payload.newPassword); + const hashedPassword = await hashPassword(payload.newPassword); - await updateUser(userId, { password: hashedPassword }); + await updateUser(userId, { password: hashedPassword }); }; export const registerUserByEmail = async ( - payload: RegisterUserByEmailSchemaType, + payload: RegisterUserByEmailSchemaType, ): Promise => { - const userExistByEmail = await getUserByEmail(payload.email); + const userExistByEmail = await getUserByEmail(payload.email); - if (userExistByEmail) { - throw new Error("Account already exist with same email address"); - } + if (userExistByEmail) { + throw new Error('Account already exist with same email address'); + } - const { confirmPassword, ...rest } = payload; + const { confirmPassword, ...rest } = payload; - const otp = config.OTP_VERIFICATION_ENABLED ? generateOTP() : null; + const otp = config.OTP_VERIFICATION_ENABLED ? generateOTP() : null; - const user = await createUser( - { ...rest, role: "DEFAULT_USER", otp }, - false, - ); + const user = await createUser({ ...rest, role: 'DEFAULT_USER', otp }, false); - return user; + return user; }; export const loginUserByEmail = async ( - payload: LoginUserByEmailSchemaType, + payload: LoginUserByEmailSchemaType, ): Promise => { - const user = await getUserByEmail(payload.email, "+password"); + const user = await getUserByEmail(payload.email, '+password'); - if (!user || !(await compareHash(String(user.password), payload.password))) { - throw new Error("Invalid email or password"); - } + if (!user || !(await compareHash(String(user.password), payload.password))) { + throw new Error('Invalid email or password'); + } - const jwtPayload: JwtPayload = { - sub: String(user._id), - email: user?.email, - phoneNo: user?.phoneNo, - role: String(user.role) as RoleType, - username: user.username, - }; + const jwtPayload: JwtPayload = { + sub: String(user._id), + email: user?.email, + phoneNo: user?.phoneNo, + role: String(user.role) as RoleType, + username: user.username, + }; - const token = await signToken(jwtPayload); + const token = await signToken(jwtPayload); - return token; + return token; }; -export const googleLogin = async ( - payload: GoogleCallbackQuery, -): Promise => { - const { code, error } = payload; - - if (error) { - throw new Error(error); - } - - if (!code) { - throw new Error("Code Not Provided"); - } - const tokenResponse = await fetchGoogleTokens({ code }); - - const { access_token, refresh_token, expires_in } = tokenResponse; - - const userInfoResponse = await getUserInfo(access_token); - - const { id, email, name, picture } = userInfoResponse; - - const user = await getUserByEmail(email); - - if (!user) { - const newUser = await createUser({ - email, - username: name, - avatar: picture, - role: ROLE_ENUM.DEFAULT_USER, - password: generateRandomNumbers(4), - socialAccount: [ - { - refreshToken: refresh_token, - tokenExpiry: new Date(Date.now() + expires_in * 1000), - accountType: SOCIAL_ACCOUNT_ENUM.GOOGLE, - accessToken: access_token, - accountID: id, - }, - ], - }); - - return newUser; - } - - const updatedUser = await updateUser(user._id, { - socialAccount: [ - { - refreshToken: refresh_token, - tokenExpiry: new Date(Date.now() + expires_in * 1000), - accountType: SOCIAL_ACCOUNT_ENUM.GOOGLE, - accessToken: access_token, - accountID: id, - }, - ], - }); - - return updatedUser; +export const verifyGoogleToken = async ( + payload: GoogleTokenVerificationSchemaType, +): Promise<{ user: UserType; token: string }> => { + const tokenPayload = await verifyGoogleIdToken(payload.idToken); + + const { googleId, email, name, picture, emailVerified, tokenExpiry } = + tokenPayload; + + if (!emailVerified) { + throw new Error('Google account email is not verified'); + } + + const existingUser = await getUserByEmail(email); + + let user: UserType; + + if (!existingUser) { + user = await createUser({ + email, + username: name || email.split('@')[0], + avatar: picture, + role: ROLE_ENUM.DEFAULT_USER, + password: generateRandomNumbers(8), + socialAccount: [ + { + refreshToken: '', + tokenExpiry: new Date(tokenExpiry * 1000), + accountType: SOCIAL_ACCOUNT_ENUM.GOOGLE, + accessToken: payload.idToken, + accountID: googleId, + }, + ], + }); + } else { + user = await updateUser(existingUser._id, { + ...(picture && { avatar: picture }), + socialAccount: [ + { + refreshToken: '', + tokenExpiry: new Date(tokenExpiry * 1000), + accountType: SOCIAL_ACCOUNT_ENUM.GOOGLE, + accessToken: payload.idToken, + accountID: googleId, + }, + ], + }); + } + + const jwtPayload: JwtPayload = { + sub: String(user._id), + email: user.email, + phoneNo: user.phoneNo, + role: String(user.role) as RoleType, + username: user.username, + }; + + const token = await signToken(jwtPayload); + + return { user, token }; }; diff --git a/src/utils/auth.utils.ts b/src/utils/auth.utils.ts index f1e09be..fab9a1d 100644 --- a/src/utils/auth.utils.ts +++ b/src/utils/auth.utils.ts @@ -1,152 +1,133 @@ -import crypto from "node:crypto"; -import argon2 from "argon2"; -import { JsonWebTokenError, sign, verify } from "jsonwebtoken"; -import config from "../config/config.service"; -import type { RoleType } from "../enums"; -import logger from "../lib/logger.service"; +import crypto from 'node:crypto'; +import argon2 from 'argon2'; +import { OAuth2Client } from 'google-auth-library'; +import { JsonWebTokenError, sign, verify } from 'jsonwebtoken'; +import config from '../config/config.service'; +import type { RoleType } from '../enums'; +import logger from '../lib/logger.service'; export interface GoogleTokenResponse { - access_token: string; - expires_in: number; - id_token: string; - refresh_token?: string; - scope: string; - token_type: string; + access_token: string; + expires_in: number; + id_token: string; + refresh_token?: string; + scope: string; + token_type: string; } export interface GoogleTokensRequestParams { - code: string; + code: string; } export type JwtPayload = { - sub: string; - email?: string | null; - phoneNo?: string | null; - username: string; - role: RoleType; + sub: string; + email?: string | null; + phoneNo?: string | null; + username: string; + role: RoleType; }; export type PasswordResetTokenPayload = { - email: string; - userId: string; + email: string; + userId: string; }; export type SetPasswordTokenPayload = { - email: string; - userId: string; + email: string; + userId: string; }; +const client = new OAuth2Client(config.GOOGLE_CLIENT_ID); export const hashPassword = async (password: string): Promise => { - return argon2.hash(password); + return argon2.hash(password); }; export const compareHash = async ( - hashed: string, - plainPassword: string, + hashed: string, + plainPassword: string, ): Promise => { - return argon2.verify(hashed, plainPassword); + return argon2.verify(hashed, plainPassword); }; export const signToken = async (payload: JwtPayload): Promise => { - return sign(payload, String(config.JWT_SECRET), { - expiresIn: Number(config.JWT_EXPIRES_IN) * 1000, - }); + return sign(payload, String(config.JWT_SECRET), { + expiresIn: Number(config.JWT_EXPIRES_IN) * 1000, + }); }; export const signPasswordResetToken = async ( - payload: PasswordResetTokenPayload, + payload: PasswordResetTokenPayload, ) => { - return sign(payload, String(config.JWT_SECRET), { - expiresIn: config.PASSWORD_RESET_TOKEN_EXPIRES_IN * 1000, - }); + return sign(payload, String(config.JWT_SECRET), { + expiresIn: config.PASSWORD_RESET_TOKEN_EXPIRES_IN * 1000, + }); }; export const signSetPasswordToken = async ( - payload: SetPasswordTokenPayload, + payload: SetPasswordTokenPayload, ) => { - return sign(payload, String(config.JWT_SECRET), { - expiresIn: config.SET_PASSWORD_TOKEN_EXPIRES_IN, - }); + return sign(payload, String(config.JWT_SECRET), { + expiresIn: config.SET_PASSWORD_TOKEN_EXPIRES_IN, + }); }; export const verifyToken = async < - T extends JwtPayload | PasswordResetTokenPayload | SetPasswordTokenPayload, + T extends JwtPayload | PasswordResetTokenPayload | SetPasswordTokenPayload, >( - token: string, + token: string, ): Promise => { - try { - return verify(token, String(config.JWT_SECRET)) as T; - } catch (err) { - if (err instanceof Error) { - throw new Error(err.message); - } - - if (err instanceof JsonWebTokenError) { - throw new Error(err.message); - } - - logger.error("verifyToken", { err }); - throw err; - } + try { + return verify(token, String(config.JWT_SECRET)) as T; + } catch (err) { + if (err instanceof Error) { + throw new Error(err.message); + } + + if (err instanceof JsonWebTokenError) { + throw new Error(err.message); + } + + logger.error('verifyToken', { err }); + throw err; + } }; export const generateRandomPassword = (length = 16): string => { - return crypto.randomBytes(length).toString("hex"); + return crypto.randomBytes(length).toString('hex'); }; -export const fetchGoogleTokens = async ( - params: GoogleTokensRequestParams, -): Promise => { - if ( - !config.GOOGLE_CLIENT_ID || - !config.GOOGLE_CLIENT_SECRET || - !config.GOOGLE_REDIRECT_URI - ) { - throw new Error("Google credentials are not set"); - } - - const url = "https://oauth2.googleapis.com/token"; - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - code: params.code, - client_id: config.GOOGLE_CLIENT_ID, - client_secret: config.GOOGLE_CLIENT_SECRET, - redirect_uri: config.GOOGLE_REDIRECT_URI, - grant_type: "authorization_code", - }), - }); - - if (!response.ok) { - throw new Error("Failed to exchange code for tokens"); - } - - const data: GoogleTokenResponse = await response.json(); - return data; -}; -export interface GoogleUserInfo { - id: string; - email: string; - verified_email: boolean; - name: string; - given_name: string; - family_name: string; - picture: string; - locale: string; -} -export const getUserInfo = async (accessToken: string) => { - const userInfoResponse = await fetch( - "https://www.googleapis.com/oauth2/v2/userinfo", - { - headers: { Authorization: `Bearer ${accessToken}` }, - }, - ); - if (!userInfoResponse.ok) { - throw new Error("Error fetching user info"); - } - return userInfoResponse.json(); +export const generateOTP = (length = 6): string => { + return crypto.randomBytes(length).toString('hex').slice(0, length); }; -export const generateOTP = (length = 6): string => { - return crypto.randomBytes(length).toString("hex").slice(0, length); +export const verifyGoogleIdToken = async (idToken: string) => { + try { + const ticket = await client.verifyIdToken({ + idToken, + audience: config.GOOGLE_CLIENT_ID, + }); + + const payload = ticket.getPayload(); + + if (!payload) { + throw new Error('Invalid token payload'); + } + + const { sub, email, name, picture, email_verified, exp } = payload; + + if (!email) { + throw new Error('Email not found in token payload'); + } + + return { + googleId: sub, + email, + name: name || email.split('@')[0], + picture, + emailVerified: email_verified, + tokenExpiry: exp, + }; + } catch (error) { + logger.error('Error verifying Google ID token', { error }); + throw new Error('Failed to verify Google ID token'); + } };