From c88cda89002729b06c8d3a50cc0aadd0f8e86639 Mon Sep 17 00:00:00 2001 From: Rohit Sah Date: Wed, 6 Sep 2023 01:16:26 +0545 Subject: [PATCH] build(root): :sparkles: added google oauth --- apps/api/.env.example | 5 +- apps/api/package.json | 2 + apps/api/src/constants.ts | 2 + apps/api/src/data-source.ts | 2 + apps/api/src/entities/Account.ts | 77 +++++++++++++ apps/api/src/env.d.ts | 3 + apps/api/src/index.ts | 3 + apps/api/src/routers/auth.ts | 108 ++++++++++++++++++ apps/api/src/utils/oauth.ts | 38 ++++++ apps/storefront/.env.example | 1 + apps/storefront/constants.ts | 2 + apps/storefront/env.d.ts | 1 + .../src/components/auth/AuthProvider.tsx | 21 +++- apps/storefront/src/pages/_app.tsx | 2 +- pnpm-lock.yaml | 84 +++++++++++++- turbo.json | 3 +- 16 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/entities/Account.ts create mode 100644 apps/api/src/routers/auth.ts create mode 100644 apps/api/src/utils/oauth.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 2e60695..0a7d23b 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -8,4 +8,7 @@ KHALTI_SECRET_KEY= RESEND_HOST= RESENT_PORT= RESEND_AUTH_USER= -RESEND_AUTH_PASS= \ No newline at end of file +RESEND_AUTH_PASS= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +API_URL= \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index cba10a1..ea8c6aa 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,7 @@ "@types/express": "^4.17.17", "@types/express-session": "^1.17.7", "@types/ioredis": "^4.28.10", + "@types/jsonwebtoken": "^9.0.2", "@types/node": "^17.0.45", "@types/nodemailer": "^6.4.9", "@types/uuid": "^8.3.4", @@ -54,6 +55,7 @@ "helmet": "^7.0.0", "ioredis": "^4.28.5", "joi": "^17.9.2", + "jsonwebtoken": "^9.0.2", "nodemailer": "^6.9.4", "pg": "^8.11.2", "reflect-metadata": "^0.1.13", diff --git a/apps/api/src/constants.ts b/apps/api/src/constants.ts index 7a2b742..44e8ded 100644 --- a/apps/api/src/constants.ts +++ b/apps/api/src/constants.ts @@ -2,6 +2,7 @@ export const __prod__ = process.env.NODE_ENV === "production"; export const COOKIE_NAME = "qid"; export const FORGOT_PASSWORD_PREFIX = "forgot-password:"; export const VERIFY_EMAIL_PREFIX = "verify-email:"; +export const USER_VALIDATION_PREFIX = "validate-user:"; export const COMPANY = { name: "Hamropasal", logo: `https://hamropasal.vercel.app/logo.png`, @@ -10,3 +11,4 @@ export const COMPANY = { city: "Kathmandu", country: "Nepal", }; +export const GOOGLE_OAUTH_REDIRECT_URL = `${process.env.API_URL}/auth/google/callback`; diff --git a/apps/api/src/data-source.ts b/apps/api/src/data-source.ts index b0ad208..a380827 100644 --- a/apps/api/src/data-source.ts +++ b/apps/api/src/data-source.ts @@ -20,6 +20,7 @@ import { ProductVariant } from "./entities/ProductVariant"; import { Promo } from "./entities/Promo"; import { Favourite } from "./entities/Favourite"; import { ProductReview } from "./entities/ProductReview"; +import { Account } from "./entities/Account"; export const AppDataSource = new DataSource({ type: "postgres", @@ -46,6 +47,7 @@ export const AppDataSource = new DataSource({ Promo, Favourite, ProductReview, + Account, ], migrations: ["dist/migration/**/*.js"], subscribers: [], diff --git a/apps/api/src/entities/Account.ts b/apps/api/src/entities/Account.ts new file mode 100644 index 0000000..c20a033 --- /dev/null +++ b/apps/api/src/entities/Account.ts @@ -0,0 +1,77 @@ +import { Field, Int, ObjectType } from "type-graphql"; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + BaseEntity, + ManyToOne, +} from "typeorm"; +import { User } from "./User"; + +@ObjectType() +@Entity() +export class Account extends BaseEntity { + @Field(() => Int) + @PrimaryGeneratedColumn() + id!: number; + + @Field() + @Column() + userId!: number; + + @ManyToOne(() => User, (user) => user.addresses) + user!: User; + + @Field(() => String) + @Column({ + type: "enum", + enum: ["OAUTH", "PASSWORD"], + }) + type!: "OAUTH" | "PASSWORD"; + + @Field() + @Column() + provider!: string; + + @Field() + @Column() + providerAccountId!: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + refresh_token!: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + access_token!: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + expires_at!: number; + + @Field({ nullable: true }) + @Column({ nullable: true }) + token_type!: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + scope!: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + id_token!: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + session_state!: string; + + @Field(() => String) + @CreateDateColumn() + created_at = new Date(); + + @Field(() => String) + @UpdateDateColumn() + updated_at = new Date(); +} diff --git a/apps/api/src/env.d.ts b/apps/api/src/env.d.ts index 092634b..c4d49c1 100644 --- a/apps/api/src/env.d.ts +++ b/apps/api/src/env.d.ts @@ -12,6 +12,9 @@ declare global { RESENT_PORT: string; RESEND_AUTH_USER: string; RESEND_AUTH_PASS: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + API_URL: string; } } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8a93610..f56785c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -28,6 +28,7 @@ import { ReviewResolver } from "./resolvers/review"; import Redis from "ioredis"; import { Product } from "./entities/Product"; import { InvoiceResolver } from "./resolvers/invoice"; +import authRouter from "./routers/auth"; const Server = async () => { AppDataSource.initialize() @@ -79,6 +80,8 @@ const Server = async () => { res.json({ status: "ok" }); }); + app.use("/auth", authRouter); + app.get("/products", async (_req, res) => { res.json( await Product.find({ diff --git a/apps/api/src/routers/auth.ts b/apps/api/src/routers/auth.ts new file mode 100644 index 0000000..0b8d8c0 --- /dev/null +++ b/apps/api/src/routers/auth.ts @@ -0,0 +1,108 @@ +import { Router } from "express"; +import jwt from "jsonwebtoken"; +import { Account } from "../entities/Account"; +import { User } from "../entities/User"; +import { MyContext } from "../types"; +import { getGoogleOAuthToken } from "../utils/oauth"; + +const router: Router = Router(); + +interface GoogleUserResponse { + iss: string; + azp: string; + aud: string; + sub: string; + email: string; + email_verified: boolean; + at_hash: string; + name: string; + picture: string; + given_name: string; + family_name: string; + locale: string; + iat: number; + exp: number; +} + +router.get( + "/google/callback", + async (req: MyContext["req"], res: MyContext["res"]) => { + // 1. Get the code from query string + + const code = req.query.code as string; + + // 2. Get the id and access token + + const { id_token, access_token, refresh_token, expires_in } = + await getGoogleOAuthToken({ code }); + + // 3. Get user with token + + const profile = jwt.decode(id_token) as GoogleUserResponse; + + // 4. Upsert the User + + const exisitingUser = await Account.findOne({ + where: { + providerAccountId: profile.sub, + }, + relations: { + user: true, + }, + }); + + if (exisitingUser) { + req.session.userId = exisitingUser.userId; + res.redirect(`${process.env.CLIENT_URL}`); + return; + } + + const existingUserByEmail = await User.findOne({ + where: { + email: profile.email, + }, + }); + + if (existingUserByEmail) { + await Account.create({ + userId: existingUserByEmail.id, + type: "OAUTH", + provider: "google", + providerAccountId: profile.sub, + access_token: access_token, + refresh_token: refresh_token, + expires_at: expires_in, + }).save(); + + req.session.userId = existingUserByEmail.id; + res.redirect(`${process.env.CLIENT_URL}`); + return; + } + + const userRes = await User.create({ + password: "unset", + first_name: profile.given_name, + last_name: profile.family_name, + email: profile.email, + email_verified: profile.email_verified, + imageUrl: + profile.picture ?? + `https://api.dicebear.com/6.x/micah/svg?size=256&seed=${profile.name}`, + }).save(); + + await Account.create({ + userId: userRes.id, + type: "OAUTH", + provider: "google", + providerAccountId: profile.sub, + access_token: access_token, + refresh_token: refresh_token, + expires_at: expires_in, + }).save(); + + req.session.userId = userRes.id; + res.redirect(`${process.env.CLIENT_URL}`); + } +); + +export default router; diff --git a/apps/api/src/utils/oauth.ts b/apps/api/src/utils/oauth.ts new file mode 100644 index 0000000..bad54dc --- /dev/null +++ b/apps/api/src/utils/oauth.ts @@ -0,0 +1,38 @@ +import { GOOGLE_OAUTH_REDIRECT_URL } from "../constants"; + +interface GoogleTokenResult { + access_token: string; + expires_in: number; + refresh_token: string; + scope: string; + id_token: string; +} + +export const getGoogleOAuthToken = async ({ + code, +}: { + code: string; +}): Promise => { + const url = "https://oauth2.googleapis.com/token"; + const values = new URLSearchParams({ + code, + client_id: process.env.GOOGLE_CLIENT_ID, + client_secret: process.env.GOOGLE_CLIENT_SECRET, + redirect_uri: GOOGLE_OAUTH_REDIRECT_URL, + grant_type: "authorization_code", + }); + try { + const res = await fetch(url, { + method: "POST", + body: values, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + return res.json(); + } catch (error: any) { + console.error(error); + throw new Error(error.message); + } +}; diff --git a/apps/storefront/.env.example b/apps/storefront/.env.example index e073b15..a227524 100644 --- a/apps/storefront/.env.example +++ b/apps/storefront/.env.example @@ -1,2 +1,3 @@ NEXT_PUBLIC_API_URL= +NEXT_PUBLIC_GOOGLE_CLIENT_ID= KHALTI_SECRET_KEY= \ No newline at end of file diff --git a/apps/storefront/constants.ts b/apps/storefront/constants.ts index 5fd3b30..5b5ac8b 100644 --- a/apps/storefront/constants.ts +++ b/apps/storefront/constants.ts @@ -6,3 +6,5 @@ export const COLOR = "Ecommerce Storefront"; export const APP_URL = !PROD ? "http://localhost:3000" : "https://www.rudejellyfish.live"; + +export const GOOGLE_OAUTH_REDIRECT_URL = `${process.env.NEXT_PUBLIC_API_URL}/auth/google/callback`; diff --git a/apps/storefront/env.d.ts b/apps/storefront/env.d.ts index e970ac3..f4e2b41 100644 --- a/apps/storefront/env.d.ts +++ b/apps/storefront/env.d.ts @@ -2,6 +2,7 @@ declare global { namespace NodeJS { interface ProcessEnv { NEXT_PUBLIC_API_URL: string; + NEXT_PUBLIC_GOOGLE_CLIENT_ID: string; KHALTI_SECRET_KEY: string; } } diff --git a/apps/storefront/src/components/auth/AuthProvider.tsx b/apps/storefront/src/components/auth/AuthProvider.tsx index 0ee0634..39106d3 100644 --- a/apps/storefront/src/components/auth/AuthProvider.tsx +++ b/apps/storefront/src/components/auth/AuthProvider.tsx @@ -1,8 +1,9 @@ import { Stack, Button, Box } from "@chakra-ui/react"; import { FcGoogle } from "react-icons/fc"; +import { GOOGLE_OAUTH_REDIRECT_URL } from "../../../constants"; const SignInWithGoogle = () => ( - + @@ -10,3 +11,21 @@ const SignInWithGoogle = () => ( ); export default SignInWithGoogle; + +export const getGoogleOAuthUrl = () => { + const rootUrl = "https://accounts.google.com/o/oauth2/v2/auth"; + + const qs = new URLSearchParams({ + redirect_uri: GOOGLE_OAUTH_REDIRECT_URL, + client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, + access_type: "offline", + response_type: "code", + prompt: "consent", + scope: [ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", + ].join(" "), + }); + + return `${rootUrl}?${qs.toString()}`; +}; diff --git a/apps/storefront/src/pages/_app.tsx b/apps/storefront/src/pages/_app.tsx index 9531b0b..2d8653a 100644 --- a/apps/storefront/src/pages/_app.tsx +++ b/apps/storefront/src/pages/_app.tsx @@ -11,7 +11,7 @@ const App = ({ Component, pageProps }: AppProps) => { const router = useRouter(); const client = new ApolloClient({ - uri: process.env.NEXT_PUBLIC_API_URL, + uri: `${process.env.NEXT_PUBLIC_API_URL}/graphql`, cache: new InMemoryCache({ // ! Pagination is working, but filtering stops working after this // typePolicies: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77f07f6..002e600 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: joi: specifier: ^17.9.2 version: 17.9.2 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 nodemailer: specifier: ^6.9.4 version: 6.9.4 @@ -178,6 +181,9 @@ importers: '@types/ioredis': specifier: ^4.28.10 version: 4.28.10 + '@types/jsonwebtoken': + specifier: ^9.0.2 + version: 9.0.2 '@types/node': specifier: ^17.0.45 version: 17.0.45 @@ -4398,10 +4404,16 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/jsonwebtoken@9.0.2: + resolution: {integrity: sha512-drE6uz7QBKq1fYqqoFKTDRdFCPHd5TCub75BM+D+cMx7NU9hUz7SESLfC2fSCXVFMO5Yj8sOWHuGqPgjc+fz0Q==} + dependencies: + '@types/node': 17.0.45 + dev: true + /@types/keyv@3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: - '@types/node': 18.15.11 + '@types/node': 17.0.45 dev: true /@types/lodash.debounce@4.0.7: @@ -4501,7 +4513,7 @@ packages: /@types/responselike@1.0.0: resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} dependencies: - '@types/node': 18.15.11 + '@types/node': 17.0.45 dev: true /@types/scheduler@0.16.3: @@ -4530,7 +4542,7 @@ packages: /@types/ws@8.5.5: resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} dependencies: - '@types/node': 18.15.11 + '@types/node': 17.0.45 dev: true /@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.46.0)(typescript@4.7.3): @@ -5342,6 +5354,10 @@ packages: node-int64: 0.4.0 dev: true + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-writer@2.0.0: resolution: {integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==} engines: {node: '>=4'} @@ -6246,6 +6262,12 @@ packages: - debug dev: false + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /editorconfig@1.0.4: resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} engines: {node: '>=14'} @@ -8208,6 +8230,22 @@ packages: engines: {'0': node >= 0.2.0} dev: true + /jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.5.4 + dev: false + /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -8218,6 +8256,21 @@ packages: object.values: 1.1.6 dev: true + /jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + dev: false + /keyv@3.1.0: resolution: {integrity: sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==} dependencies: @@ -8420,17 +8473,36 @@ packages: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} dev: false + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: false + /lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} dev: false + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: false + /lodash.isfunction@3.0.9: resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} dev: true + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: false + + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: false + /lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - dev: true + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: false /lodash.kebabcase@4.1.1: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} @@ -8442,6 +8514,10 @@ packages: /lodash.mergewith@4.6.2: resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: false + /lodash.snakecase@4.1.1: resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} dev: true diff --git a/turbo.json b/turbo.json index 955bada..d34a3cc 100644 --- a/turbo.json +++ b/turbo.json @@ -22,7 +22,8 @@ "PORT", "CLIENT_URL", "NEXT_PUBLIC_API_URL", - "KHALTI_SECRET_KEY" + "KHALTI_SECRET_KEY", + "NEXT_PUBLIC_GOOGLE_CLIENT_ID" ] }, "test": {