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); | |
} | |
} |