Skip to content

Instantly share code, notes, and snippets.

@vighnesh153
Last active November 27, 2024 00:33
Show Gist options
  • Save vighnesh153/ecdc67ef21f6b4a905f6f2e77404cf68 to your computer and use it in GitHub Desktop.
Save vighnesh153/ecdc67ef21f6b4a905f6f2e77404cf68 to your computer and use it in GitHub Desktop.
Google Login
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,
};
}
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);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment