Authorized UI origins:
Authorized redirect URIs:
| import crypto from "node:crypto"; | |
| import { config } from "@/config.ts"; | |
| export interface AuthTokenGeneratorParams { | |
| userId: string; | |
| } | |
| export interface AuthTokenGenerator { | |
| generate(params: AuthTokenGeneratorParams): string; | |
| } | |
| export class AuthTokenGeneratorImpl implements AuthTokenGenerator { | |
| generate({ userId }: AuthTokenGeneratorParams): string { | |
| const data = `${userId}-${config.cookieSecret}`; | |
| return crypto.createHash("sha256").update(data, "binary").digest("hex"); | |
| } | |
| } | |
| export class FakeAuthTokenGenerator implements AuthTokenGenerator { | |
| authToken: string | null = null; | |
| generate({ userId }: AuthTokenGeneratorParams): string { | |
| return this.authToken ?? `hashed(${userId})`; | |
| } | |
| } |
| import type { JsonHttpPostRequest } from "@vighnesh153/tools"; | |
| import { config } from "@/config.ts"; | |
| export function buildTokenFetchRequest( | |
| { authCallbackCode }: { authCallbackCode: string }, | |
| ): JsonHttpPostRequest<FormData> { | |
| const formData = new FormData(); | |
| formData.append("code", authCallbackCode); | |
| formData.append("client_id", "GOOGLE_CLIENT_ID"); | |
| formData.append("client_secret", "GOOGLE_CLIENT_SECRET"); | |
| formData.append("grant_type", "authorization_code"); | |
| formData.append("redirect_uri", "https://vighnesh153.dev/googleAuthCallback"); | |
| return { | |
| path: "https://oauth2.googleapis.com/token", | |
| data: formData, | |
| }; | |
| } |
Authorized UI origins:
Authorized redirect URIs:
| import { type JsonHttpClient, not } from "@vighnesh153/tools"; | |
| import { decodeUserInfo } from "./decode_user_info.ts"; | |
| import type { UserRepository } from "@/repositories/mod.ts"; | |
| import { | |
| authTokenGeneratorFactory, | |
| jsonHttpClientFactory, | |
| userRepositoryFactory, | |
| } from "@/factories/mod.ts"; | |
| import type { AuthTokenGenerator } from "@/utils/auth_token_generator.ts"; | |
| import type { CompleteUserInfo } from "@/models/user_info.ts"; | |
| import { buildTokenFetchRequest } from "./build_token_fetch_request.ts"; | |
| type ControllerResponse = { success: false } | { | |
| success: true; | |
| user: CompleteUserInfo; | |
| authToken: string; | |
| }; | |
| authRouter.all("/googleAuthCallback", async (c) => { | |
| const code = c.req.query("code"); | |
| if (isStringEmpty(code)) { | |
| return c.text("Request is invalid...", 400); | |
| } | |
| const result = await googleAuthCallbackController({ authCallbackCode: code }); | |
| if (not(result.success)) { | |
| return c.text("Failed to log in user.", 500); | |
| } | |
| assert(result.success); | |
| const cookieOpts: CookieOptions = { | |
| ...commonCookieOpts, | |
| maxAge: milliseconds({ years: 1 }) / 1000, | |
| }; | |
| setCookie( | |
| c, | |
| cookieKeys.userInfo, | |
| btoa(JSON.stringify(result.user)), | |
| cookieOpts, | |
| ); | |
| setCookie( | |
| c, | |
| cookieKeys.authToken, | |
| result.authToken, | |
| { ...cookieOpts, ...secureCookieOpts }, | |
| ); | |
| return c.redirect(config.uiAuthCompleteUrl); | |
| }); | |
| export async function googleAuthCallbackController( | |
| { | |
| authCallbackCode = "", | |
| // Import from @vighnesh153/tools | |
| httpClient = jsonHttpClientFactory(), | |
| userRepository = userRepositoryFactory(), | |
| authTokenGenerator = authTokenGeneratorFactory(), | |
| }: { | |
| authCallbackCode?: string; | |
| httpClient?: JsonHttpClient; | |
| userRepository?: UserRepository; | |
| authTokenGenerator?: AuthTokenGenerator; | |
| } = {}, | |
| ): Promise<ControllerResponse> { | |
| const tokenFetchRequest = buildTokenFetchRequest({ authCallbackCode }); | |
| const tokenFetcher = httpClient.post<unknown, { id_token: string }>( | |
| tokenFetchRequest, | |
| ); | |
| console.log("Fetching google auth token..."); | |
| const tokenResponse = await tokenFetcher.execute(); | |
| if (tokenResponse.isError()) { | |
| console.log("Some error occurred while fetching google auth token"); | |
| console.log(tokenResponse.getErrorResponse()); | |
| return { | |
| success: false, | |
| }; | |
| } | |
| console.log("Google auth token fetch is successful"); | |
| const tokenData = tokenResponse.getSuccessResponse(); | |
| // extract user info from token | |
| console.log("Extracting user info from token"); | |
| const oauthUserInfo = decodeUserInfo(tokenData.data.id_token); | |
| if (oauthUserInfo === null) { | |
| console.log("Failed to extract user info from token"); | |
| console.log(`token=${tokenData.data.id_token}`); | |
| return { success: false }; | |
| } | |
| console.log("Successfully extracted user info from token"); | |
| // user's email is not verified. deny signing in | |
| if (not(oauthUserInfo.email_verified)) { | |
| console.log(`User's email address is not verified`); | |
| console.log(oauthUserInfo); | |
| return { success: false }; | |
| } | |
| console.log(`User's email address is verified`); | |
| console.log("Attempting to creating or getting user..."); | |
| const loggedInUser = await userRepository.createOrGetUser(oauthUserInfo); | |
| if (loggedInUser == null) { | |
| console.log( | |
| "Failed to create or get user... Failing sign up or sign in...", | |
| ); | |
| return { success: false }; | |
| } | |
| console.log("Generating auth token..."); | |
| const authToken = authTokenGenerator.generate({ | |
| userId: loggedInUser.userId, | |
| }); | |
| console.log("Generated auth token and login user complete..."); | |
| return { | |
| success: true, | |
| user: loggedInUser, | |
| authToken, | |
| }; | |
| } |
| import { isStringEmpty } from "@vighnesh153/tools"; | |
| const authScopes = [ | |
| "https://www.googleapis.com/auth/userinfo.profile", | |
| "https://www.googleapis.com/auth/userinfo.email", | |
| ]; | |
| export function initiateGoogleLoginController(): string | null { | |
| return constructInitiateGoogleAuthUrl({ | |
| authRedirectUri: "https://vighnesh153.dev/googleAuthCallback", | |
| }); | |
| } | |
| authRouter.all("/initiateGoogleLogin", (c) => { | |
| const initiateGoogleLoginUrl = initiateGoogleLoginController(); | |
| if (initiateGoogleLoginUrl == null) { | |
| return c.text( | |
| "Initiate google login url is empty. Please inform Vighnesh about this issue.", | |
| 500, | |
| ); | |
| } | |
| return c.redirect(initiateGoogleLoginUrl); | |
| }); | |
| function constructInitiateGoogleAuthUrl({ | |
| authRedirectUri, | |
| googleClientId = "GOOGLE_CLIENT_ID", | |
| }: { | |
| authRedirectUri: string; | |
| googleClientId?: string; | |
| }): string | null { | |
| if (isStringEmpty(authRedirectUri) || isStringEmpty(googleClientId)) { | |
| console.log( | |
| `Expected 'authRedirectUri' and 'googleClientId' to not be empty, ` + | |
| `found authRedirectUri='${authRedirectUri}', googleClientId='${googleClientId}'`, | |
| ); | |
| return null; | |
| } | |
| const initiateGoogleAuthUrl = new URL( | |
| "https://accounts.google.com/o/oauth2/v2/auth", | |
| ); | |
| const queryParams = new URLSearchParams(); | |
| queryParams.append("redirect_uri", `${authRedirectUri}`); | |
| queryParams.append("client_id", googleClientId!); | |
| queryParams.append("access_type", "offline"); | |
| queryParams.append("response_type", "code"); | |
| queryParams.append("prompt", "consent"); | |
| queryParams.append("scope", authScopes.join(" ")); | |
| initiateGoogleAuthUrl.search = queryParams.toString(); | |
| console.log( | |
| `Sending initiate-google-auth-url='${initiateGoogleAuthUrl.toString()}'`, | |
| ); | |
| return initiateGoogleAuthUrl.toString(); | |
| } |
| authRouter.all("/initiateLogout", (c) => { | |
| const cookieOpts: CookieOptions = { | |
| ...commonCookieOpts, | |
| maxAge: 0, | |
| }; | |
| setCookie(c, cookieKeys.userInfo, "", cookieOpts); | |
| setCookie(c, cookieKeys.authToken, "", { | |
| ...cookieOpts, | |
| ...secureCookieOpts, | |
| }); | |
| return c.redirect(config.uiAuthCompleteUrl); | |
| }); |
| export interface SimpleRandomStringGenerator { | |
| generate(): string; | |
| } | |
| export class SimpleRandomStringGeneratorImpl | |
| implements SimpleRandomStringGenerator { | |
| generate(): string { | |
| return Math.random().toString(16).slice(2); | |
| } | |
| } |
| import { slugify } from "@std/text/unstable-slugify"; | |
| import { firestoreInstance } from "@/firebase.ts"; | |
| import { | |
| CompleteUserInfo, | |
| type GoogleOAuthUserInfo, | |
| } from "@/models/user_info.ts"; | |
| import type { SimpleRandomStringGenerator } from "@/utils/simple_random_string_generator.ts"; | |
| import { SimpleRandomStringGeneratorImpl } from "@/utils/simple_random_string_generator.ts"; | |
| export interface UserRepository { | |
| createOrGetUser( | |
| oauthUser: GoogleOAuthUserInfo, | |
| ): Promise<CompleteUserInfo | null>; | |
| } | |
| export class FirebaseUserRepository implements UserRepository { | |
| private collections = { | |
| userByUserId: "user_by_user_id", | |
| userIdByEmail: "user_id_by_email", | |
| userIdByUsername: "user_id_by_username", | |
| }; | |
| constructor( | |
| private readonly firestore: FirebaseFirestore.Firestore = firestoreInstance, | |
| private readonly simpleRandomStringGenerator: SimpleRandomStringGenerator = | |
| new SimpleRandomStringGeneratorImpl(), | |
| ) {} | |
| async createOrGetUser( | |
| oauthUser: GoogleOAuthUserInfo, | |
| ): Promise<CompleteUserInfo | null> { | |
| const { | |
| firestore, | |
| simpleRandomStringGenerator, | |
| } = this; | |
| const txRes = await firestore.runTransaction(async (tx) => { | |
| console.log("Fetching user having email:", oauthUser.email); | |
| const maybeUser = await tx.get( | |
| this.getUserIdByEmailCollection().doc(oauthUser.email), | |
| ); | |
| if (maybeUser.exists) { | |
| console.log("User already exists. Logging them in.."); | |
| return; | |
| } | |
| const userId = `${ | |
| slugify(oauthUser.name) | |
| }-${simpleRandomStringGenerator.generate()}`.toLowerCase(); | |
| const user: CompleteUserInfo = { | |
| userId, | |
| username: userId, | |
| name: oauthUser.name, | |
| email: oauthUser.email, | |
| profilePictureUrl: oauthUser.picture, | |
| createdAtMillis: Date.now(), | |
| }; | |
| console.log("Attempting to create user records..."); | |
| await tx | |
| .create( | |
| this.getUserByUserIdCollection().doc(user.userId), | |
| user, | |
| ) | |
| .create( | |
| this.getUserIdByEmailCollection().doc(user.email), | |
| { userId: user.userId }, | |
| ) | |
| .create(this.getUserIdByUsernameCollection().doc(user.username), { | |
| userId: user.userId, | |
| }); | |
| console.log("Created user records successfully."); | |
| }).catch((e) => e); | |
| if (txRes instanceof Error) { | |
| console.log("Some error occurred while creating user:", txRes); | |
| return null; | |
| } | |
| try { | |
| console.log("Fetching user id..."); | |
| const userId = (await this.getUserIdByEmailCollection().doc( | |
| oauthUser.email, | |
| ) | |
| .get()).data()?.userId; | |
| console.log("Fetching user for userId:", userId); | |
| const user = await this.getUserByUserIdCollection().doc( | |
| userId ?? "", | |
| ).get(); | |
| const parsedUser = CompleteUserInfo.safeParse(user.data()); | |
| if (parsedUser.success) { | |
| console.log("Successfully parsed user:", parsedUser.data); | |
| return parsedUser.data; | |
| } | |
| console.log("Failed to parse completeUserInfo:", parsedUser.error.errors); | |
| return null; | |
| } catch (e) { | |
| console.log( | |
| "Some error occurred while getting user info for logging in:", | |
| e, | |
| ); | |
| return null; | |
| } | |
| } | |
| private getUserByUserIdCollection(): FirebaseFirestore.CollectionReference { | |
| return this.firestore.collection(this.collections.userByUserId); | |
| } | |
| private getUserIdByEmailCollection(): FirebaseFirestore.CollectionReference { | |
| return this.firestore.collection(this.collections.userIdByEmail); | |
| } | |
| private getUserIdByUsernameCollection(): FirebaseFirestore.CollectionReference { | |
| return this.firestore.collection(this.collections.userIdByUsername); | |
| } | |
| } |