From 01f20539e44871d9983caae45119b6d4e8b88285 Mon Sep 17 00:00:00 2001 From: homocodian Date: Sun, 18 Aug 2024 03:32:04 +0530 Subject: [PATCH] feat: google login for native apps --- apps/backend/package.json | 2 +- .../controllers/auth/create-google-oauth.ts | 145 ++++++++++++++++++ .../v1/controllers/user/google-callback.ts | 8 +- apps/backend/src/v1/controllers/user/login.ts | 4 +- .../src/v1/controllers/user/profile.ts | 4 +- .../src/v1/controllers/user/register.ts | 4 +- .../backend/src/v1/controllers/user/update.ts | 4 +- apps/backend/src/v1/routes/auth.ts | 3 + apps/backend/src/v1/validations/auth.ts | 17 ++ apps/backend/src/v1/validations/user.ts | 6 +- 10 files changed, 179 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/v1/controllers/auth/create-google-oauth.ts create mode 100644 apps/backend/src/v1/validations/auth.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 0e16439..fee1083 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,6 +1,6 @@ { "name": "@notes/backend", - "version": "0.0.9", + "version": "0.0.10", "scripts": { "dev": "NODE_DEBUG=* NODE_ENV=development bun --watch src/index.ts", "build": "NODE_ENV=production bun build --target=bun --sourcemap=linked ./src/index.ts --outdir ./dist", diff --git a/apps/backend/src/v1/controllers/auth/create-google-oauth.ts b/apps/backend/src/v1/controllers/auth/create-google-oauth.ts new file mode 100644 index 0000000..d9981e6 --- /dev/null +++ b/apps/backend/src/v1/controllers/auth/create-google-oauth.ts @@ -0,0 +1,145 @@ +import * as Sentry from "@sentry/bun"; +import { and, eq } from "drizzle-orm"; +import { Context } from "elysia"; + +import { db } from "@/db"; +import { UserTable, oauthAccountTable, userTable } from "@/db/schema/user"; +import { lucia } from "@/libs/auth"; +import { BgQueue } from "@/libs/background-worker"; +import { SaveDeviceProps } from "@/libs/background/save-device"; +import { signJwtAsync } from "@/libs/jwt"; +import { Prettify } from "@/types/prettify"; +import { OAuthBodySchema, OAuthUserSchema } from "@/v1/validations/auth"; +import { UserSchema } from "@/v1/validations/user"; + +interface CreateGoogleOAuthProps extends Context { + body: OAuthBodySchema; + ip: string; +} + +export async function createGoogleOAuth({ + ip, + body, + error, + request +}: CreateGoogleOAuthProps) { + try { + const { user, error: dbError } = await db.transaction(async (trx) => { + const [existingUser] = await trx + .select() + .from(oauthAccountTable) + .where( + and( + eq(oauthAccountTable.providerId, "google"), + eq(oauthAccountTable.providerUserId, body.user.id) + ) + ) + .innerJoin(userTable, eq(oauthAccountTable.userId, userTable.id)); + + if (!existingUser) { + const [createdUser] = await trx + .insert(userTable) + .values({ + ...body.user, + id: undefined, + disabled: false + }) + .returning(); + + if (!createdUser) { + trx.rollback(); + return { user: null, error: "Failed to create user" }; + } + + const [createdOauthAccount] = await trx + .insert(oauthAccountTable) + .values({ + providerId: "google", + providerUserId: body.user.id, + userId: createdUser.id + }) + .returning(); + + if (!createdOauthAccount) { + trx.rollback(); + return { user: null, error: "Failed to create oauth account" }; + } + + return { user: createdUser, error: null }; + } else { + const [updatedUser] = await trx + .update(userTable) + .set(getUserPropToUpdate(existingUser.user, body.user)) + .where(eq(userTable.id, existingUser.user.id)) + .returning(); + + if (!updatedUser) { + trx.rollback(); + return { user: null, error: "Failed to update user" }; + } + + return { user: updatedUser, error: null }; + } + }); + + if (!user) { + return error(500, dbError); + } + + const session = await lucia.createSession(user.id, {}); + + const sessionToken = await signJwtAsync(session.id); + + await BgQueue.add("saveDevice", { + ip, + ua: request.headers.get("user-agent") ?? undefined, + userId: user.id, + sessionId: session.id, + device: body.device + } satisfies SaveDeviceProps).catch((err) => { + console.log("🚀 ~ loginUser saveDevice ~ err", err); + Sentry.captureException(err); + }); + + const sessionUser = { + id: user.id, + email: user.email, + emailVerified: user.emailVerified, + photoURL: user.photoURL, + displayName: user.displayName, + sessionToken: sessionToken + } satisfies UserSchema & { sessionToken: string }; + + return sessionUser; + } catch (err) { + if ((err as any)?.code === "23505") { + return error(400, "User already exists"); + } + + return error("Internal Server Error"); + } +} + +type UserPropToUpdate = Partial< + Pick +>; + +function getUserPropToUpdate(user: UserTable, googleUser: OAuthUserSchema) { + const propsToUpdate: Prettify = {}; + + if (!user.emailVerified === false) { + propsToUpdate.emailVerified = googleUser.emailVerified; + } + + if (!user.displayName) { + propsToUpdate.displayName = googleUser.displayName; + } + + if (!user.photoURL) { + propsToUpdate.photoURL = googleUser.photoURL; + } + + propsToUpdate.lastSignInAt = new Date(); + + return propsToUpdate; +} diff --git a/apps/backend/src/v1/controllers/user/google-callback.ts b/apps/backend/src/v1/controllers/user/google-callback.ts index c8b3e14..589e304 100644 --- a/apps/backend/src/v1/controllers/user/google-callback.ts +++ b/apps/backend/src/v1/controllers/user/google-callback.ts @@ -14,11 +14,7 @@ import { SaveDeviceProps } from "@/libs/background/save-device"; import { signJwtAsync } from "@/libs/jwt"; import { parseJson } from "@/libs/parse-json"; import { Prettify } from "@/types/prettify"; -import { - DeviceSchema, - UserResponse, - deviceSchema -} from "@/v1/validations/user"; +import { DeviceSchema, UserSchema, deviceSchema } from "@/v1/validations/user"; interface GoogleCallbackProps extends Context {} @@ -190,7 +186,7 @@ export async function googleCallback({ emailVerified: user.emailVerified, photoURL: user.photoURL, displayName: user.displayName - } satisfies UserResponse; + } satisfies UserSchema; return redirect( `${redirectURL}?sessionToken=${sessionToken}&user=${encodeURIComponent(JSON.stringify(sessionUser))}` diff --git a/apps/backend/src/v1/controllers/user/login.ts b/apps/backend/src/v1/controllers/user/login.ts index e204fbb..ac31ee9 100644 --- a/apps/backend/src/v1/controllers/user/login.ts +++ b/apps/backend/src/v1/controllers/user/login.ts @@ -8,7 +8,7 @@ import { lucia } from "@/libs/auth"; import { BgQueue } from "@/libs/background-worker"; import { SaveDeviceProps } from "@/libs/background/save-device"; import { signJwtAsync } from "@/libs/jwt"; -import { LoginUser, UserResponse } from "@/v1/validations/user"; +import { LoginUser, UserSchema } from "@/v1/validations/user"; interface LoginUserProps extends Context { body: LoginUser; @@ -66,7 +66,7 @@ export async function loginUser({ body, error, request, ip }: LoginUserProps) { photoURL: user.photoURL, displayName: user.displayName, sessionToken - } satisfies UserResponse & { sessionToken: string }; + } satisfies UserSchema & { sessionToken: string }; } catch (err) { console.error("🚀 ~ loginUser ~ err:", err); Sentry.captureException(err); diff --git a/apps/backend/src/v1/controllers/user/profile.ts b/apps/backend/src/v1/controllers/user/profile.ts index 8f6395c..dadd834 100644 --- a/apps/backend/src/v1/controllers/user/profile.ts +++ b/apps/backend/src/v1/controllers/user/profile.ts @@ -5,7 +5,7 @@ import { lucia } from "@/libs/auth"; import { BgQueue } from "@/libs/background-worker"; import { UpdateSessionLastUsedAtProps } from "@/libs/background/update-session-last-used"; import { VerifyJwtAsync } from "@/libs/jwt"; -import { UserResponse } from "@/v1/validations/user"; +import { UserSchema } from "@/v1/validations/user"; interface GetProfileProps extends Context { bearer: string | undefined; @@ -41,7 +41,7 @@ export async function getProfile({ bearer, error }: GetProfileProps) { emailVerified: user.emailVerified, photoURL: user.photoURL, displayName: user.displayName - } satisfies UserResponse; + } satisfies UserSchema; } catch (err) { console.log("🚀 ~ getProfile ~ err:", err); Sentry.captureException(err); diff --git a/apps/backend/src/v1/controllers/user/register.ts b/apps/backend/src/v1/controllers/user/register.ts index aabaed8..aa20cce 100644 --- a/apps/backend/src/v1/controllers/user/register.ts +++ b/apps/backend/src/v1/controllers/user/register.ts @@ -10,7 +10,7 @@ import { type SendVerificationCodeProps } from "@/libs/background/send-verificat import { JwtError, signJwtAsync } from "@/libs/jwt"; import { validatePassword } from "@/libs/password-validation"; import { isValidEmail } from "@/v1/validations/email"; -import { RegisterUser, UserResponse } from "@/v1/validations/user"; +import { RegisterUser, UserSchema } from "@/v1/validations/user"; interface RegisterUserProps extends Context { body: RegisterUser; @@ -87,7 +87,7 @@ export async function registerUser({ photoURL: user.photoURL, displayName: user.displayName, sessionToken - } satisfies UserResponse & { sessionToken: string }; + } satisfies UserSchema & { sessionToken: string }; } catch (err) { console.log("🚀 ~ registerUser ~ err:", err); Sentry.captureException(err); diff --git a/apps/backend/src/v1/controllers/user/update.ts b/apps/backend/src/v1/controllers/user/update.ts index ad628cf..4bbfd78 100644 --- a/apps/backend/src/v1/controllers/user/update.ts +++ b/apps/backend/src/v1/controllers/user/update.ts @@ -5,7 +5,7 @@ import { User } from "lucia"; import { db } from "@/db"; import { userTable } from "@/db/schema/user"; -import type { UserResponse, UserUpdateSchema } from "@/v1/validations/user"; +import type { UserSchema, UserUpdateSchema } from "@/v1/validations/user"; interface UpdateUserProps extends Context { user: User; @@ -30,7 +30,7 @@ export async function updateUser({ user, error, body }: UpdateUserProps) { photoURL: updatedUser.photoURL, displayName: updatedUser.displayName, emailVerified: updatedUser.emailVerified - } satisfies UserResponse; + } satisfies UserSchema; } catch (err) { console.error("🚀 ~ updateUser ~ err:", err); Sentry.captureException(err); diff --git a/apps/backend/src/v1/routes/auth.ts b/apps/backend/src/v1/routes/auth.ts index 89a0f87..d707376 100644 --- a/apps/backend/src/v1/routes/auth.ts +++ b/apps/backend/src/v1/routes/auth.ts @@ -13,6 +13,7 @@ import { registerUserSchema } from "@/v1/validations/user"; +import { createGoogleOAuth } from "../controllers/auth/create-google-oauth"; import { emailVerification } from "../controllers/user/email-verification"; import { googleCallback } from "../controllers/user/google-callback"; import { googleLogin } from "../controllers/user/google-login"; @@ -25,6 +26,7 @@ import { registerUser } from "../controllers/user/register"; import { sendVerificationEmail } from "../controllers/user/send-verification-email"; import { errorHandlerInstance } from "../utils/error-handler"; import { deriveUser } from "../utils/note/derive-user"; +import { oAuthBodySchema } from "../validations/auth"; export const authRoute = new Elysia({ prefix: "/auth" }) .use(errorHandlerInstance) @@ -43,6 +45,7 @@ export const authRoute = new Elysia({ prefix: "/auth" }) body: loginUserSchema }) .get("/google", googleLogin) + .post("/google", createGoogleOAuth, { body: oAuthBodySchema }) .get("/google/callback", googleCallback, { query: oAuthQuerySchema }) .post("/logout", logout, { body: logoutSchema }) .post("/logout-all", logoutAll) diff --git a/apps/backend/src/v1/validations/auth.ts b/apps/backend/src/v1/validations/auth.ts new file mode 100644 index 0000000..a41efd2 --- /dev/null +++ b/apps/backend/src/v1/validations/auth.ts @@ -0,0 +1,17 @@ +import { t } from "elysia"; + +import { deviceSchema, userSchema } from "./user"; + +export const oAuthUserScheme = t.Composite([ + t.Omit(userSchema, ["id"]), + t.Object({ id: t.String() }) +]); + +export type OAuthUserSchema = typeof oAuthUserScheme.static; + +export const oAuthBodySchema = t.Object({ + user: oAuthUserScheme, + device: deviceSchema +}); + +export type OAuthBodySchema = typeof oAuthBodySchema.static; diff --git a/apps/backend/src/v1/validations/user.ts b/apps/backend/src/v1/validations/user.ts index 7dda1bf..8510404 100644 --- a/apps/backend/src/v1/validations/user.ts +++ b/apps/backend/src/v1/validations/user.ts @@ -63,15 +63,15 @@ export const passwordResetSchema = t.Object({ export type PasswordReset = typeof passwordResetSchema.static; -export const userResponseSchema = t.Object({ +export const userSchema = t.Object({ id: t.Number(), - email: t.String(), + email: t.String({ format: "email" }), emailVerified: t.Boolean(), photoURL: t.Nullable(t.String()), displayName: t.Nullable(t.String()) }); -export type UserResponse = typeof userResponseSchema.static; +export type UserSchema = typeof userSchema.static; export const emailVerificationSchema = t.Object({ code: t.String() });