Implement automatic silent OAuth token refresh and expiration tracking for Google Workspace API calls

This commit is contained in:
2026-07-23 12:07:27 -04:00
parent 65ec2ab580
commit 186779fbbd
2 changed files with 37 additions and 8 deletions
+1 -1
View File
@@ -1 +1 @@
249378 267794
+36 -7
View File
@@ -1,9 +1,33 @@
import { JobApplication } from '../types'; import { JobApplication } from '../types';
let cachedAccessToken: string | null = localStorage.getItem('ai_job_portal_google_token'); let cachedAccessToken: string | null = localStorage.getItem('ai_job_portal_google_token');
let tokenExpiresAt: number = Number(localStorage.getItem('ai_job_portal_google_token_exp') || '0');
export const getCachedAccessToken = () => cachedAccessToken; export const getCachedAccessToken = () => cachedAccessToken;
/**
* Silent / Interactive Token Refresh Mechanism
* Automatically checks token age and triggers silent OAuth popup or re-auth if token is expired.
*/
export const ensureValidAccessToken = async (): Promise<string | null> => {
const now = Date.now();
// If token exists and is valid for at least another 3 minutes, reuse it
if (cachedAccessToken && tokenExpiresAt > now + 3 * 60 * 1000) {
return cachedAccessToken;
}
// Token is expired or expiring soon -> Trigger silent refresh / prompt-less re-auth
console.log('[Google Auth] OAuth Token expired or expiring soon. Triggering automatic token refresh...');
try {
const authResult = await googleWorkspaceSignIn(true); // silent = true
return authResult.accessToken;
} catch (err: any) {
console.warn('[Google Auth] Silent token refresh notice:', err.message);
return cachedAccessToken;
}
};
const getEnvVar = (key: string) => { const getEnvVar = (key: string) => {
try { try {
return (import.meta as any).env?.[key]; return (import.meta as any).env?.[key];
@@ -18,12 +42,13 @@ const OAUTH_CLIENT_ID = getEnvVar('VITE_GOOGLE_OAUTH_CLIENT_ID') || '48854802845
* Direct Google OAuth 2.0 Flow via Google Identity Services * Direct Google OAuth 2.0 Flow via Google Identity Services
* Obtains real access token directly from accounts.google.com for Google Drive/Sheets API calls. * Obtains real access token directly from accounts.google.com for Google Drive/Sheets API calls.
*/ */
export const googleWorkspaceSignIn = async (): Promise<{ email: string; name: string; accessToken: string }> => { export const googleWorkspaceSignIn = async (silentMode: boolean = false): Promise<{ email: string; name: string; accessToken: string }> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const scope = encodeURIComponent('https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/documents'); const scope = encodeURIComponent('https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/documents');
const redirectUri = encodeURIComponent(`${window.location.origin}`); const redirectUri = encodeURIComponent(`${window.location.origin}`);
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${OAUTH_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=token&scope=${scope}&prompt=select_account`; const promptParam = silentMode ? 'none' : 'select_account';
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${OAUTH_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=token&scope=${scope}&prompt=${promptParam}`;
const width = 500; const width = 500;
const height = 650; const height = 650;
@@ -59,10 +84,14 @@ export const googleWorkspaceSignIn = async (): Promise<{ email: string; name: st
const params = new URLSearchParams(hash.substring(1)); const params = new URLSearchParams(hash.substring(1));
const token = params.get('access_token'); const token = params.get('access_token');
const expiresInSeconds = Number(params.get('expires_in') || '3600');
const expiresAt = Date.now() + expiresInSeconds * 1000;
if (token) { if (token) {
cachedAccessToken = token; cachedAccessToken = token;
tokenExpiresAt = expiresAt;
localStorage.setItem('ai_job_portal_google_token', token); localStorage.setItem('ai_job_portal_google_token', token);
localStorage.setItem('ai_job_portal_google_token_exp', String(expiresAt));
localStorage.setItem('ai_job_portal_google_connected', 'true'); localStorage.setItem('ai_job_portal_google_connected', 'true');
try { try {
@@ -99,20 +128,20 @@ export const googleWorkspaceSignIn = async (): Promise<{ email: string; name: st
export const googleSignOut = async () => { export const googleSignOut = async () => {
cachedAccessToken = null; cachedAccessToken = null;
tokenExpiresAt = 0;
localStorage.removeItem('ai_job_portal_google_token'); localStorage.removeItem('ai_job_portal_google_token');
localStorage.removeItem('ai_job_portal_google_token_exp');
localStorage.removeItem('ai_job_portal_google_connected'); localStorage.removeItem('ai_job_portal_google_connected');
localStorage.removeItem('ai_job_portal_google_user'); localStorage.removeItem('ai_job_portal_google_user');
}; };
// Workspace Action Handlers // Workspace Action Handlers
export async function exportApplicationsToGoogleSheets( export async function exportApplicationsToGoogleSheets(
applications: JobApplication[], applications: JobApplication[],
accessToken?: string accessToken?: string
): Promise<{ spreadsheetId: string; spreadsheetUrl: string; isLocalCsv?: boolean; message?: string }> { ): Promise<{ spreadsheetId: string; spreadsheetUrl: string; isLocalCsv?: boolean; message?: string }> {
const token = accessToken || cachedAccessToken; const token = accessToken || (await ensureValidAccessToken()) || cachedAccessToken;
const res = await fetch('/api/workspace/sheets/export', { const res = await fetch('/api/workspace/sheets/export', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -134,7 +163,7 @@ export async function createTailoredGoogleDoc(
docType: 'resume' | 'coverLetter' | 'note', docType: 'resume' | 'coverLetter' | 'note',
accessToken?: string accessToken?: string
): Promise<{ documentId: string; documentUrl: string; isLocalDoc?: boolean; message?: string }> { ): Promise<{ documentId: string; documentUrl: string; isLocalDoc?: boolean; message?: string }> {
const token = accessToken || cachedAccessToken; const token = accessToken || (await ensureValidAccessToken()) || cachedAccessToken;
const res = await fetch('/api/workspace/docs/create', { const res = await fetch('/api/workspace/docs/create', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -156,7 +185,7 @@ export async function uploadFileToGoogleDrive(
mimeType: string = 'text/plain', mimeType: string = 'text/plain',
accessToken?: string accessToken?: string
): Promise<{ fileId: string; driveUrl: string; isLocalFile?: boolean; message?: string }> { ): Promise<{ fileId: string; driveUrl: string; isLocalFile?: boolean; message?: string }> {
const token = accessToken || cachedAccessToken; const token = accessToken || (await ensureValidAccessToken()) || cachedAccessToken;
const res = await fetch('/api/workspace/drive/upload', { const res = await fetch('/api/workspace/drive/upload', {
method: 'POST', method: 'POST',
headers: { headers: {