gemini/autocal/frontend/libs/auth/auth.ts (150 lines of code) (raw):
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
"use server";
import { OAuth2Client } from "google-auth-library";
import crypto from "crypto";
import { Firestore, Timestamp } from "firebase-admin/firestore";
import { cookies } from "next/headers";
import { GetAccessTokenResponse } from "google-auth-library/build/src/auth/oauth2client";
// Ensure we have appropriate environment variables for OAuth2
if (!process.env.NEXT_PUBLIC_CLIENT_ID) {
throw new Error("Expected NEXT_PUBLIC_CLIENT_ID");
}
if (!process.env.CLIENT_SECRET) {
throw new Error("Expected CLIENT_SECRET");
}
if (!process.env.ENCRYPTION_KEY) {
throw new Error("Expected ENCRYPTION_KEY");
}
// Init Firestore
const db = new Firestore({
databaseId: "(default)",
});
interface UserStore {
refresh_token: string;
access_token: string;
expires: Date;
}
const oAuth2Client = new OAuth2Client(
process.env.NEXT_PUBLIC_CLIENT_ID,
process.env.CLIENT_SECRET,
"postmessage",
);
/**
* Handles user sign-in and sets a http only cookie for the user
*
* @param payload The token to be encrypted
* @param encryptionKey A 32 character string used for encryption
* @returns An encrypted string for safe storage in Firestore
*/
export async function processSignin(code: string): Promise<string> {
const cookieStore = cookies();
const { tokens } = await oAuth2Client.getToken(code);
if (!tokens.id_token || !tokens.refresh_token || !tokens.access_token) {
throw new Error("Invalid ID Token");
}
// Get the user details
const ticket = await oAuth2Client.verifyIdToken({
idToken: tokens.id_token,
});
const payload = ticket.getPayload();
if (!payload || !payload.sub) {
throw new Error("Unable to validate login token");
}
// Encrypt and store tokens in Firestore
const [refresh, access] = await Promise.all([
encrypt(tokens.refresh_token, process.env.ENCRYPTION_KEY!),
encrypt(tokens.access_token, process.env.ENCRYPTION_KEY!),
]);
const userRef = db.collection("users").doc(payload.sub);
try {
userRef.set(
{
refresh_token: refresh,
access_token: access,
expires: new Date(tokens.expiry_date || 0),
},
{ merge: true },
);
} catch (e) {
console.error(e);
throw e;
}
// Store the ID token as a http only cookie (cannot be read by client-side JavaScript)
(await cookieStore).set({
name: "id_token",
value: tokens.id_token,
httpOnly: true,
path: "/",
secure: true,
expires: new Date().setSeconds(payload.exp),
});
return tokens.id_token;
}
export async function getSession() {
const cookieStore = await cookies();
const idToken = cookieStore.get("id_token")?.value;
if (!idToken) {
return null;
}
return idToken;
}
export async function removeSession() {
const cookieStore = await cookies();
const idToken = cookieStore.get("id_token")?.value;
// Delete login cookie
cookieStore.delete("id_token");
// Clean up Firestore
if (idToken) {
const ticket = await oAuth2Client.verifyIdToken({
idToken: idToken,
});
const payload = ticket.getPayload();
// No payload, user is already logged out
if (!payload || !payload.sub) {
return;
}
const userRef = db.collection("users").doc(payload.sub);
try {
await userRef.delete();
} catch (e) {
console.error(e);
}
}
}
export async function getAccessToken(): Promise<GetAccessTokenResponse | null> {
const cookieStore = await cookies();
const idToken = cookieStore.get("id_token")?.value;
if (!idToken) {
return null;
}
const ticket = await oAuth2Client.verifyIdToken({
idToken: idToken,
});
const payload = ticket.getPayload();
// No payload, user is already logged out
if (!payload || !payload.sub) {
return null;
}
const userRef = db.collection("users").doc(payload.sub);
const user = await userRef.get();
if (!user.exists) {
return null;
}
const data = user.data() as UserStore;
// Hydrate OAuth2 credentials
oAuth2Client.setCredentials({
refresh_token: decrypt(data.refresh_token, process.env.ENCRYPTION_KEY!),
access_token: decrypt(data.access_token, process.env.ENCRYPTION_KEY!),
expiry_date: (data.expires as unknown as Timestamp).toMillis(),
id_token: idToken,
});
return oAuth2Client.getAccessToken();
}
/**
* Handles encryption of tokens so they can be safely stored in Firestore
*
* @param payload The token to be encrypted
* @param encryptionKey A 32 character string used for encryption
* @returns An encrypted string for safe storage in Firestore
*/
async function encrypt(
payload: string,
encryptionKey: string,
): Promise<string> {
// Create an initialization vector
const iv = crypto.randomBytes(16);
// Create a cipher object using AES-256-CBC algorithm
const cipher = crypto.createCipheriv(
"aes-256-cbc",
Buffer.from(encryptionKey),
iv,
);
// Encrypt the payload
let encrypted = cipher.update(payload, "utf8", "hex");
encrypted += cipher.final("hex");
// Combine the IV and encrypted data
return `${iv.toString("hex")}:${encrypted}`;
}
/**
* Handles the decryption of tokens that has been stored in Firestore.
*
* @param encryptedData The data to be decrypted
* @param encryptionKey A 32 character string used for encryption
* @returns The unencrypted data
*/
function decrypt(encryptedData: string, encryptionKey: string): string {
// Split the encrypted data into IV and ciphertext
const [ivHex, ciphertext] = encryptedData.split(":");
// Convert the IV from hexadecimal to Buffer
const iv = Buffer.from(ivHex, "hex");
// Create a decipher object
const decipher = crypto.createDecipheriv(
"aes-256-cbc",
Buffer.from(encryptionKey),
iv,
);
// Decrypt the ciphertext
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}