Skip to content

Commit

Permalink
feat: google login
Browse files Browse the repository at this point in the history
  • Loading branch information
homocodian committed Aug 15, 2024
1 parent 82e6b29 commit f40edf6
Show file tree
Hide file tree
Showing 18 changed files with 575 additions and 38 deletions.
5 changes: 3 additions & 2 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@notes/backend",
"version": "0.0.8",
"version": "0.0.9",
"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",
Expand All @@ -20,6 +20,7 @@
"@lucia-auth/adapter-drizzle": "^1.0.7",
"@react-email/components": "^0.0.21",
"@sentry/bun": "^8.25.0",
"arctic": "^1.9.2",
"bullmq": "^5.12.0",
"country-code-lookup": "^0.1.3",
"drizzle-orm": "^0.31.4",
Expand Down Expand Up @@ -50,4 +51,4 @@
"prettier": "^3.3.3",
"typescript": "^5.5.4"
}
}
}
3 changes: 2 additions & 1 deletion apps/backend/src/db/schema/user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InferSelectModel, relations } from "drizzle-orm";
import { InferInsertModel, InferSelectModel, relations } from "drizzle-orm";
import {
boolean,
index,
Expand Down Expand Up @@ -62,6 +62,7 @@ export const userTable = pgTable(
);

export type UserTable = InferSelectModel<typeof userTable>;
export type CreateUserTable = InferInsertModel<typeof userTable>;

export const sessionTable = pgTable("session", {
id: text("id").notNull().primaryKey(),
Expand Down
10 changes: 8 additions & 2 deletions apps/backend/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ const server = t.Object({
APP_NAME: t.String({ minLength: 1 }),
ADMIN_EMAIL: t.String({ minLength: 1, format: "email" }),
REDIS_URL: t.String({ minLength: 1 }),
IPINFO_TOKEN: t.String({ minLength: 1 })
IPINFO_TOKEN: t.String({ minLength: 1 }),
GOOGLE_CLIENT_ID: t.String({ minLength: 1 }),
GOOGLE_CLIENT_SECRET: t.String({ minLength: 1 }),
GOOGLE_REDIRECT_URI: t.String({ minLength: 1 })
});

type ServerEnv = typeof server.static;
Expand All @@ -28,7 +31,10 @@ const processEnv = {
APP_NAME: process.env.APP_NAME,
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
REDIS_URL: process.env.REDIS_URL,
IPINFO_TOKEN: process.env.IPINFO_TOKEN
IPINFO_TOKEN: process.env.IPINFO_TOKEN,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI
} satisfies Record<keyof ServerEnv, string | undefined>;

// Don't touch the part below
Expand Down
9 changes: 9 additions & 0 deletions apps/backend/src/libs/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { Google } from "arctic";
import { Lucia } from "lucia";

import { env } from "@/env";

import { db } from "../db";
import { sessionTable, userTable } from "../db/schema/user";

Expand All @@ -24,6 +27,12 @@ export const lucia = new Lucia(adapter, {
}
});

export const google = new Google(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
env.GOOGLE_REDIRECT_URI
);

// IMPORTANT!
declare module "lucia" {
interface Register {
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/libs/background/save-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type SaveDeviceProps = {
userId: number;
sessionId: string;
ua?: string;
device: DeviceSchema;
device?: DeviceSchema;
};

export async function saveDevice({
Expand Down
36 changes: 36 additions & 0 deletions apps/backend/src/libs/parse-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
type ParseJsonResponse<T> =
| { data: T; error: null }
| { data: null; error: string };

// Overload for asynchronous parsing (Response)
export async function parseJson<T = Record<string, unknown>>(
toParse: Response
): Promise<ParseJsonResponse<T>>;

// Overload for synchronous parsing (string)
export function parseJson<T = Record<string, unknown>>(
toParse: unknown
): ParseJsonResponse<T>;

// Implementation
export function parseJson<T = Record<string, unknown>>(
toParse: unknown
): ParseJsonResponse<T> | Promise<ParseJsonResponse<T>> {
if (toParse instanceof Response) {
return toParse
.json()
.then((data) => ({ data, error: null }))
.catch(() => ({ data: null, error: "Invalid JSON" }));
}

if (typeof toParse === "string") {
try {
const data = JSON.parse(toParse);
return { data, error: null };
} catch (error) {
return { data: null, error: "Invalid JSON" };
}
}

return { data: null, error: "Invalid input" };
}
12 changes: 7 additions & 5 deletions apps/backend/src/libs/sentry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as Sentry from "@sentry/bun";

Sentry.init({
dsn: "https://efc7013b1ac25cd69a614607b7c17f65@o4507646828609536.ingest.us.sentry.io/4507772148776960",
// Tracing
tracesSampleRate: 1.0 // Capture 100% of the transactions
});
if (process.env.NODE_ENV === "production") {
Sentry.init({
dsn: "https://efc7013b1ac25cd69a614607b7c17f65@o4507646828609536.ingest.us.sentry.io/4507772148776960",
// Tracing
tracesSampleRate: 1.0 // Capture 100% of the transactions
});
}
247 changes: 247 additions & 0 deletions apps/backend/src/v1/controllers/user/google-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import * as Sentry from "@sentry/bun";
import { Value } from "@sinclair/typebox/value";
import { OAuth2RequestError } from "arctic";
import { and, eq } from "drizzle-orm";
import { Context } from "elysia";
import { z } from "zod";

import { db } from "@/db";
import { UserTable, oauthAccountTable, userTable } from "@/db/schema/user";
import { env } from "@/env";
import { google, lucia } from "@/libs/auth";
import { BgQueue } from "@/libs/background-worker";
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";

interface GoogleCallbackProps extends Context {
ip: string;
}

const googleUserSchema = z.object({
id: z.string(),
email: z.string().email(),
verified_email: z.boolean().optional().default(false),
name: z.string().optional(),
picture: z.string().optional()
});

export async function googleCallback({
cookie,
request,
error,
ip,
redirect
}: GoogleCallbackProps) {
const codeVerifierCookie = cookie?.google_oauth_code_verifier?.value ?? null;
const stateCookie = cookie?.google_oauth_state?.value ?? null;
const deviceCookie = cookie?.device?.value ?? null;
// request origin uri
const callbackURL = cookie?.callback?.value ?? env.CLIENT_URL + "/login";
const redirectURL =
cookie?.redirect?.value ?? env.CLIENT_URL + "/login/google/callback";

cookie?.google_oauth_state?.remove();
cookie?.google_oauth_code_verifier?.remove();
cookie?.device?.remove();
cookie?.callback?.remove();
cookie?.redirect?.remove();

const jsonDevice = parseJson(deviceCookie);

const searchParams = new URL(request.url).searchParams;
const state = searchParams.get("state");
const code = searchParams.get("code");

// verify state
if (!state || !codeVerifierCookie || !code || state !== stateCookie) {
return redirect(
`${callbackURL}?error=${encodeURIComponent("Invalid state or codeVerifier")}`
);
}

try {
const tokens = await google.validateAuthorizationCode(
code,
codeVerifierCookie
);
const googleUserResponse = await fetch(
"https://www.googleapis.com/oauth2/v1/userinfo",
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`
}
}
);

if (!googleUserResponse.ok) {
console.log(googleUserResponse?.text);
console.log(
"🚀 ~ googleUserResponse:",
await googleUserResponse.json().catch(console.error)
);
return redirect(
`${callbackURL}?error=${encodeURIComponent("Failed to fetch user info from google")}`
);
}

const { data: googleUserResult, error: googleUserDataError } =
await parseJson(googleUserResponse);

if (googleUserDataError || !googleUserResult) {
return redirect(
`${callbackURL}?error=${encodeURIComponent(googleUserDataError)}`
);
}

const googleUser = googleUserSchema.parse(googleUserResult);

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, googleUser.id)
)
)
.innerJoin(userTable, eq(oauthAccountTable.userId, userTable.id));

if (!existingUser) {
const [createdUser] = await trx
.insert(userTable)
.values({
email: googleUser.email,
emailVerified: googleUser.verified_email,
disabled: false,
displayName: googleUser.name,
photoURL: googleUser.picture
})
.returning();

if (!createdUser) {
trx.rollback();
return { user: null, error: "Failed to create user" };
}

const [createdOauthAccount] = await trx
.insert(oauthAccountTable)
.values({
providerId: "google",
providerUserId: googleUser.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, googleUser))
.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 (dbError || !user) {
return redirect(
`${callbackURL}?error=${encodeURIComponent(dbError ?? "Failed to create user")}`
);
}

const session = await lucia.createSession(user.id, {});

const sessionToken = await signJwtAsync(session.id);

const parsedDevice = Value.Check(deviceSchema, jsonDevice.data);

await BgQueue.add("saveDevice", {
ip,
ua: request.headers.get("user-agent") ?? undefined,
userId: user.id,
sessionId: session.id,
device: parsedDevice ? (deviceCookie as DeviceSchema) : undefined
} 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
} satisfies UserResponse;

return redirect(
`${redirectURL}?sessionToken=${sessionToken}&user=${encodeURIComponent(JSON.stringify(sessionUser))}`
);
} catch (err) {
console.log("🚀 googleCallback ~ err:", err);
Sentry.captureException(err);

if (err instanceof OAuth2RequestError) {
// bad verification code, invalid credentials, etc
return redirect(
`${callbackURL}?error=${encodeURIComponent("invalid verification code")}`
);
}

if ((err as any)?.code === "23505") {
return redirect(
`${callbackURL}?error=${encodeURIComponent("Email already exists, please use different account")}`
);
}

return redirect(
`${callbackURL}?error=${encodeURIComponent("internal server error")}`
);
}
}

type UserPropToUpdate = Partial<
Pick<UserTable, "emailVerified" | "photoURL" | "displayName" | "lastSignInAt">
>;

function getUserPropToUpdate(
user: UserTable,
googleUser: z.infer<typeof googleUserSchema>
) {
const propsToUpdate: Prettify<UserPropToUpdate> = {};

if (!user.emailVerified === false) {
propsToUpdate.emailVerified = googleUser.verified_email;
}

if (!user.displayName) {
propsToUpdate.displayName = googleUser.name;
}

if (!user.photoURL) {
propsToUpdate.photoURL = googleUser.picture;
}

propsToUpdate.lastSignInAt = new Date();

return propsToUpdate;
}
Loading

0 comments on commit f40edf6

Please sign in to comment.