@remcostoeten/fync
Version:
Unified TypeScript library for 9 popular APIs with consistent functional architecture
365 lines • 12 kB
JavaScript
import { createFyncApi } from "../core/api-factory";
const GOOGLE_OAUTH_BASE_URL = "https://accounts.google.com";
const GOOGLE_API_BASE_URL = "https://www.googleapis.com";
/**
* Google OAuth2 Authentication Client
*
* Provides a chainable API for Google OAuth2 authentication flow.
* Supports both standard OAuth2 and PKCE (Proof Key for Code Exchange) flows.
* Works with all Google APIs including Calendar, Drive, Gmail, etc.
*
* @example
* ```typescript
* const googleAuth = GoogleOAuth({
* clientId: 'your-client-id.googleusercontent.com',
* clientSecret: 'your-client-secret',
* redirectUri: 'http://localhost:3000/auth/google/callback'
* });
*
* // Generate authorization URL
* const authUrl = googleAuth.getAuthorizationUrl({
* scope: ['email', 'profile', 'https://www.googleapis.com/auth/calendar'],
* state: 'random-state-string',
* accessType: 'offline' // for refresh tokens
* });
*
* // Exchange code for token
* const tokens = await googleAuth.exchangeCodeForToken(code);
*
* // Get user information
* const user = await googleAuth.withToken(tokens.access_token).getUser();
* ```
*/
export class GoogleOAuth {
constructor(config) {
this.config = config;
}
/**
* Set the access token for authenticated requests
*/
withToken(token) {
const instance = new GoogleOAuth(this.config);
instance.token = token;
return instance;
}
/**
* Generate PKCE code verifier and challenge
*/
generatePKCE() {
// Generate a random 43-128 character string for code_verifier
const array = new Uint8Array(32);
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(array);
}
else {
// Fallback for Node.js environments
const cryptoNode = require('crypto');
const bytes = cryptoNode.randomBytes(32);
for (let i = 0; i < 32; i++) {
array[i] = bytes[i];
}
}
const codeVerifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
// Create code_challenge using SHA256
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
let codeChallenge;
if (typeof crypto !== 'undefined' && crypto.subtle) {
// Browser environment
crypto.subtle.digest('SHA-256', data).then(hash => {
const hashArray = new Uint8Array(hash);
codeChallenge = btoa(String.fromCharCode(...hashArray))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
});
}
else {
// Node.js environment
const cryptoNode = require('crypto');
const hash = cryptoNode.createHash('sha256').update(codeVerifier).digest();
codeChallenge = hash.toString('base64url');
}
return { codeVerifier, codeChallenge: codeChallenge };
}
/**
* Generate the OAuth2 authorization URL
*
* @param options Configuration options for the authorization request
* @param options.scope Array of scopes to request
* @param options.state Random state parameter for CSRF protection
* @param options.accessType 'offline' for refresh tokens, 'online' for access tokens only
* @param options.includeGrantedScopes Include previously granted scopes
* @param options.loginHint Email hint for the login screen
* @param options.prompt Control the OAuth flow prompts
* @param options.pkce Whether to use PKCE flow (returns code_verifier if true)
*/
getAuthorizationUrl(options = {}) {
const params = {
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
response_type: 'code',
};
if (options.scope && options.scope.length > 0) {
params.scope = options.scope.join(' ');
}
else if (this.config.scope && this.config.scope.length > 0) {
params.scope = this.config.scope.join(' ');
}
else {
// Default to basic profile and email scopes
params.scope = 'openid email profile';
}
if (options.state) {
params.state = options.state;
}
if (options.accessType) {
params.access_type = options.accessType;
}
if (options.includeGrantedScopes) {
params.include_granted_scopes = options.includeGrantedScopes;
}
if (options.loginHint) {
params.login_hint = options.loginHint;
}
if (options.prompt) {
params.prompt = options.prompt;
}
let codeVerifier;
if (options.pkce) {
const pkce = this.generatePKCE();
params.code_challenge = pkce.codeChallenge;
params.code_challenge_method = 'S256';
codeVerifier = pkce.codeVerifier;
}
const url = new URL('/o/oauth2/v2/auth', GOOGLE_OAUTH_BASE_URL);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.append(key, String(value));
}
});
return { url: url.toString(), codeVerifier };
}
/**
* Exchange authorization code for access token
*
* @param code Authorization code received from Google
* @param codeVerifier Code verifier for PKCE flow
*/
async exchangeCodeForToken(code, codeVerifier) {
const api = createFyncApi({
baseUrl: GOOGLE_OAUTH_BASE_URL,
});
const body = {
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: this.config.redirectUri,
};
if (codeVerifier) {
body.code_verifier = codeVerifier;
}
try {
const response = await api.post('/o/oauth2/token', body, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
return response;
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to exchange code for token: ${error.message}`);
}
throw error;
}
}
/**
* Refresh an existing access token using a refresh token
*/
async refreshToken(refreshToken) {
const api = createFyncApi({
baseUrl: GOOGLE_OAUTH_BASE_URL,
});
const body = {
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
};
try {
const response = await api.post('/o/oauth2/token', body, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
return response;
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to refresh token: ${error.message}`);
}
throw error;
}
}
/**
* Revoke an access token or refresh token
*/
async revokeToken(token) {
const tokenToRevoke = token || this.token;
if (!tokenToRevoke) {
throw new Error('No token provided');
}
const api = createFyncApi({
baseUrl: GOOGLE_OAUTH_BASE_URL,
});
const body = {
token: tokenToRevoke,
};
try {
await api.post('/o/oauth2/revoke', body, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to revoke token: ${error.message}`);
}
throw error;
}
}
/**
* Get authenticated user information from Google's userinfo endpoint
*/
async getUser() {
if (!this.token) {
throw new Error('Access token required. Use withToken() method first.');
}
const api = createFyncApi({
baseUrl: GOOGLE_API_BASE_URL,
auth: { type: 'bearer', token: this.token },
});
try {
const user = await api.get('/oauth2/v2/userinfo');
return user;
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get user info: ${error.message}`);
}
throw error;
}
}
/**
* Get information about the current access token
*/
async getTokenInfo() {
if (!this.token) {
throw new Error('Access token required. Use withToken() method first.');
}
const api = createFyncApi({
baseUrl: GOOGLE_API_BASE_URL,
});
try {
const tokenInfo = await api.get(`/oauth2/v1/tokeninfo?access_token=${this.token}`);
return tokenInfo;
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get token info: ${error.message}`);
}
throw error;
}
}
/**
* Check if the current token is valid
*/
async validateToken() {
if (!this.token) {
return false;
}
try {
await this.getTokenInfo();
return true;
}
catch {
return false;
}
}
/**
* Get the scopes associated with the current token
*/
async getTokenScopes() {
const tokenInfo = await this.getTokenInfo();
return tokenInfo.scope.split(' ').filter(scope => scope.length > 0);
}
/**
* Create a complete user profile with user info and token details
*/
async getCompleteProfile() {
const [user, tokenInfo] = await Promise.all([
this.getUser(),
this.getTokenInfo(),
]);
const scopes = tokenInfo.scope.split(' ').filter(scope => scope.length > 0);
return {
user,
tokenInfo,
scopes,
};
}
/**
* Check if the token has a specific scope
*/
async hasScope(scope) {
const scopes = await this.getTokenScopes();
return scopes.includes(scope);
}
/**
* Check if the token has all the required scopes
*/
async hasScopes(requiredScopes) {
const scopes = await this.getTokenScopes();
return requiredScopes.every(scope => scopes.includes(scope));
}
/**
* Get the remaining time until token expiration (in seconds)
*/
async getTokenExpirationTime() {
const tokenInfo = await this.getTokenInfo();
const now = Math.floor(Date.now() / 1000);
return Math.max(0, tokenInfo.exp - now);
}
/**
* Check if the token is expired or will expire within the given seconds
*/
async isTokenExpired(bufferSeconds = 300) {
const remainingTime = await this.getTokenExpirationTime();
return remainingTime <= bufferSeconds;
}
}
/**
* Factory function to create a Google OAuth instance
*
* @param config OAuth configuration
* @returns GoogleOAuth instance
*/
export function createGoogleOAuth(config) {
return new GoogleOAuth(config);
}
/**
* Functional factory for Google OAuth - preferred pattern
*
* @param config OAuth configuration
* @returns GoogleOAuth instance
*/
export function googleOAuth(config) {
return new GoogleOAuth(config);
}
//# sourceMappingURL=oauth.js.map