-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
82e6b29
commit f40edf6
Showing
18 changed files
with
575 additions
and
38 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
247
apps/backend/src/v1/controllers/user/google-callback.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.