/**
* Google Identity Provider
*
* Implements Layer 1 authentication using Google OAuth 2.0.
* Provides user identity (email, name, picture) for MCP client authentication.
*
* This module consolidates all Google-specific OAuth logic:
* - Configuration constants (endpoints, scopes)
* - Token exchange and refresh
* - User info fetching
*
* Usage:
* import { GOOGLE_CONFIG, fetchGoogleUserInfo, ... } from '../auth/identity/google';
*/
import type { IdentityTokens, UserIdentity, IdentityProviderConfig } from './types';
// =============================================================================
// Configuration
// =============================================================================
/**
* Google OAuth configuration
*/
export const GOOGLE_CONFIG: IdentityProviderConfig = {
providerName: 'google',
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
userInfoEndpoint: 'https://www.googleapis.com/oauth2/v2/userinfo',
revokeEndpoint: 'https://oauth2.googleapis.com/revoke',
defaultScopes: ['openid', 'email', 'profile'],
supportsOfflineAccess: true,
supportsPKCE: true,
requiresClientSecret: true,
};
/**
* Default scopes for identity-only authentication
*/
export const GOOGLE_IDENTITY_SCOPES = 'openid email profile';
// =============================================================================
// Authorization URL
// =============================================================================
/**
* Constructs a Google OAuth authorization URL
*
* @param params - OAuth parameters
* @returns Full authorization URL
*/
export function buildGoogleAuthUrl(params: {
clientId: string;
redirectUri: string;
scope: string;
state: string;
accessType?: 'online' | 'offline';
prompt?: 'none' | 'consent' | 'select_account';
}): string {
const url = new URL(GOOGLE_CONFIG.authorizationEndpoint);
url.searchParams.set('client_id', params.clientId);
url.searchParams.set('redirect_uri', params.redirectUri);
url.searchParams.set('scope', params.scope);
url.searchParams.set('state', params.state);
url.searchParams.set('response_type', 'code');
url.searchParams.set('access_type', params.accessType ?? 'offline');
// Force consent to ensure refresh token on first auth
if (params.prompt) {
url.searchParams.set('prompt', params.prompt);
} else if (params.accessType === 'offline') {
url.searchParams.set('prompt', 'consent');
}
return url.href;
}
/**
* Legacy function for backward compatibility
* @deprecated Use buildGoogleAuthUrl instead
*/
export function getUpstreamAuthorizeUrl({
upstream_url,
client_id,
scope,
redirect_uri,
state,
}: {
upstream_url: string;
client_id: string;
scope: string;
redirect_uri: string;
state?: string;
}): string {
const upstream = new URL(upstream_url);
upstream.searchParams.set('client_id', client_id);
upstream.searchParams.set('redirect_uri', redirect_uri);
upstream.searchParams.set('scope', scope);
if (state) upstream.searchParams.set('state', state);
upstream.searchParams.set('response_type', 'code');
upstream.searchParams.set('access_type', 'offline');
upstream.searchParams.set('prompt', 'consent');
return upstream.href;
}
// =============================================================================
// Token Exchange
// =============================================================================
/**
* Token response from Google OAuth
*/
export interface GoogleTokenResponse {
accessToken: string;
refreshToken?: string;
expiresAt: number;
}
/**
* Exchange authorization code for tokens
*
* @param params - Token exchange parameters
* @returns Tokens or error response
*/
export async function exchangeGoogleCode(params: {
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
}): Promise<[IdentityTokens, null] | [null, Response]> {
const resp = await fetch(GOOGLE_CONFIG.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: params.clientId,
client_secret: params.clientSecret,
code: params.code,
redirect_uri: params.redirectUri,
grant_type: 'authorization_code',
}).toString(),
});
if (!resp.ok) {
const errorText = await resp.text();
console.error('Google token exchange failed:', errorText);
return [null, new Response('Failed to fetch access token', { status: 500 })];
}
const body = (await resp.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
scope?: string;
error?: string;
};
if (body.error) {
console.error('Google OAuth error:', body.error);
return [null, new Response(`OAuth error: ${body.error}`, { status: 400 })];
}
if (!body.access_token) {
return [null, new Response('Missing access token', { status: 400 })];
}
if (body.refresh_token) {
console.log('OAuth: Refresh token obtained');
} else {
console.warn('OAuth: No refresh token received (user may need to re-consent)');
}
return [
{
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
idToken: body.id_token,
scope: body.scope,
},
null,
];
}
/**
* Legacy function for backward compatibility
* @deprecated Use exchangeGoogleCode instead
*/
export async function fetchUpstreamAuthToken({
client_id,
client_secret,
code,
redirect_uri,
upstream_url,
}: {
code: string | undefined;
upstream_url: string;
client_secret: string;
redirect_uri: string;
client_id: string;
}): Promise<[GoogleTokenResponse, null] | [null, Response]> {
if (!code) {
return [null, new Response('Missing code', { status: 400 })];
}
const resp = await fetch(upstream_url, {
body: new URLSearchParams({
client_id,
client_secret,
code,
redirect_uri,
grant_type: 'authorization_code',
}).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
});
if (!resp.ok) {
const errorText = await resp.text();
console.error('Google token exchange failed:', errorText);
return [null, new Response('Failed to fetch access token', { status: 500 })];
}
const body = (await resp.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
error?: string;
};
if (body.error) {
console.error('Google OAuth error:', body.error);
return [null, new Response(`OAuth error: ${body.error}`, { status: 400 })];
}
const accessToken = body.access_token;
if (!accessToken) {
return [null, new Response('Missing access token', { status: 400 })];
}
if (body.refresh_token) {
console.log('OAuth: Refresh token obtained');
} else {
console.warn('OAuth: No refresh token received (user may need to re-consent)');
}
return [
{
accessToken,
refreshToken: body.refresh_token,
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
},
null,
];
}
// =============================================================================
// Token Refresh
// =============================================================================
/**
* Refresh an access token using a refresh token
*
* @param params - Refresh parameters
* @returns New tokens or null on error
*/
export async function refreshGoogleToken(params: {
clientId: string;
clientSecret: string;
refreshToken: string;
}): Promise<IdentityTokens | null> {
const resp = await fetch(GOOGLE_CONFIG.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: params.clientId,
client_secret: params.clientSecret,
refresh_token: params.refreshToken,
grant_type: 'refresh_token',
}).toString(),
});
if (!resp.ok) {
console.error('Token refresh failed:', await resp.text());
return null;
}
const body = (await resp.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
};
if (!body.access_token) {
console.error('Token refresh: no access_token in response');
return null;
}
return {
accessToken: body.access_token,
refreshToken: body.refresh_token || params.refreshToken,
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
idToken: body.id_token,
};
}
/**
* Legacy function for backward compatibility
* @deprecated Use refreshGoogleToken instead
*/
export async function refreshAccessToken({
client_id,
client_secret,
refresh_token,
}: {
client_id: string;
client_secret: string;
refresh_token: string;
}): Promise<GoogleTokenResponse | null> {
const resp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id,
client_secret,
refresh_token,
grant_type: 'refresh_token',
}).toString(),
});
if (!resp.ok) {
console.error('Token refresh failed:', await resp.text());
return null;
}
const body = (await resp.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!body.access_token) {
console.error('Token refresh: no access_token in response');
return null;
}
return {
accessToken: body.access_token,
refreshToken: body.refresh_token || refresh_token,
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
};
}
// =============================================================================
// User Info
// =============================================================================
/**
* Fetch user info from Google
*
* @param accessToken - Valid access token
* @returns Normalized user identity or null on error
*/
export async function fetchGoogleUserInfo(
accessToken: string
): Promise<UserIdentity | null> {
const resp = await fetch(GOOGLE_CONFIG.userInfoEndpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!resp.ok) {
console.error('Failed to fetch Google user info:', await resp.text());
return null;
}
const data = (await resp.json()) as {
id: string;
email: string;
name?: string;
picture?: string;
verified_email?: boolean;
};
return {
id: data.id,
email: data.email,
name: data.name,
picture: data.picture,
provider: 'google',
emailVerified: data.verified_email,
raw: data,
};
}