oauth-manager.ts•6.59 kB
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import fs from 'fs/promises';
import path from 'path';
import { AuthManager, AuthCredentials, TokenInfo } from '../types/auth.js';
/**
* OAuth 2.0 Authentication Manager for Google Drive API
* Handles authentication flow, token storage, and refresh logic
*/
export class OAuthManager implements AuthManager {
private oauth2Client: OAuth2Client;
private tokenInfo: TokenInfo | null = null;
private tokenFilePath: string;
constructor(credentials: AuthCredentials, tokenStoragePath: string = './tokens') {
this.tokenFilePath = path.join(tokenStoragePath, 'google-drive-tokens.json');
this.oauth2Client = new google.auth.OAuth2(
credentials.clientId,
credentials.clientSecret,
credentials.redirectUri
);
// Set up token refresh callback
this.oauth2Client.on('tokens', (tokens: any) => {
if (tokens.refresh_token) {
this.tokenInfo = {
accessToken: tokens.access_token!,
refreshToken: tokens.refresh_token,
expiryDate: tokens.expiry_date || Date.now() + 3600000, // 1 hour default
scope: tokens.scope ? tokens.scope.split(' ') : []
};
this.saveTokens();
}
});
}
/**
* Authenticate with Google Drive API using OAuth 2.0
* Returns true if authentication successful, false otherwise
*/
async authenticate(): Promise<boolean> {
try {
// Try to load existing tokens first
await this.loadTokens();
if (this.tokenInfo && this.isTokenValid()) {
this.oauth2Client.setCredentials({
access_token: this.tokenInfo.accessToken,
refresh_token: this.tokenInfo.refreshToken,
expiry_date: this.tokenInfo.expiryDate
});
return true;
}
// If no valid tokens, need to go through OAuth flow
if (this.tokenInfo?.refreshToken) {
// Try to refresh existing token
return await this.refreshToken();
}
// No tokens available, need initial authentication
throw new Error('No valid tokens found. Initial OAuth flow required.');
} catch (error) {
console.error('Authentication failed:', error);
return false;
}
}
/**
* Refresh the access token using refresh token
*/
async refreshToken(): Promise<boolean> {
try {
if (!this.tokenInfo?.refreshToken) {
throw new Error('No refresh token available');
}
this.oauth2Client.setCredentials({
refresh_token: this.tokenInfo.refreshToken
});
const { credentials } = await this.oauth2Client.refreshAccessToken();
if (credentials.access_token) {
this.tokenInfo = {
accessToken: credentials.access_token,
refreshToken: credentials.refresh_token || this.tokenInfo.refreshToken,
expiryDate: credentials.expiry_date || Date.now() + 3600000,
scope: this.tokenInfo.scope
};
this.oauth2Client.setCredentials({
access_token: this.tokenInfo.accessToken,
refresh_token: this.tokenInfo.refreshToken,
expiry_date: this.tokenInfo.expiryDate
});
await this.saveTokens();
return true;
}
return false;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}
/**
* Get current access token
*/
getAccessToken(): string {
if (!this.tokenInfo?.accessToken) {
throw new Error('No access token available. Authentication required.');
}
return this.tokenInfo.accessToken;
}
/**
* Check if currently authenticated with valid token
*/
isAuthenticated(): boolean {
return this.tokenInfo !== null && this.isTokenValid();
}
/**
* Get OAuth2 client instance for Google APIs
*/
getOAuth2Client(): OAuth2Client {
return this.oauth2Client;
}
/**
* Generate authorization URL for initial OAuth flow
*/
getAuthUrl(): string {
const scopes = [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/documents.readonly'
];
return this.oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: scopes,
prompt: 'consent' // Force consent to get refresh token
});
}
/**
* Exchange authorization code for tokens
*/
async exchangeCodeForTokens(code: string): Promise<boolean> {
try {
const { tokens } = await this.oauth2Client.getToken(code);
if (tokens.access_token && tokens.refresh_token) {
this.tokenInfo = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiryDate: tokens.expiry_date || Date.now() + 3600000,
scope: tokens.scope ? tokens.scope.split(' ') : []
};
this.oauth2Client.setCredentials(tokens);
await this.saveTokens();
return true;
}
return false;
} catch (error) {
console.error('Code exchange failed:', error);
return false;
}
}
/**
* Check if current token is valid (not expired)
*/
private isTokenValid(): boolean {
if (!this.tokenInfo) return false;
// Add 5 minute buffer before expiry
const bufferTime = 5 * 60 * 1000; // 5 minutes in milliseconds
return Date.now() < (this.tokenInfo.expiryDate - bufferTime);
}
/**
* Load tokens from storage
*/
private async loadTokens(): Promise<void> {
try {
const tokenData = await fs.readFile(this.tokenFilePath, 'utf-8');
this.tokenInfo = JSON.parse(tokenData);
} catch (error) {
// File doesn't exist or is invalid, that's okay
this.tokenInfo = null;
}
}
/**
* Save tokens to storage
*/
private async saveTokens(): Promise<void> {
try {
if (!this.tokenInfo) return;
// Ensure directory exists
const tokenDir = path.dirname(this.tokenFilePath);
await fs.mkdir(tokenDir, { recursive: true });
await fs.writeFile(
this.tokenFilePath,
JSON.stringify(this.tokenInfo, null, 2),
{ mode: 0o600 } // Restrict file permissions for security
);
} catch (error) {
console.error('Failed to save tokens:', error);
}
}
/**
* Clear stored tokens (for logout)
*/
async clearTokens(): Promise<void> {
try {
await fs.unlink(this.tokenFilePath);
this.tokenInfo = null;
this.oauth2Client.setCredentials({});
} catch (error) {
// File might not exist, that's okay
}
}
}