Skip to content

Google OAuth API

Handles Google OAuth 2.0 authentication for calendar integration.

Base Path: /api/auth/google

Authentication: Connect requires Bearer token; Callback is public (Google redirect).

Endpoints

MethodPathAuthDescription
GET/connectYesInitiate OAuth flow
GET/callbackNoOAuth callback handler

OAuth Flow

Initiate Connection

Starts the OAuth flow and returns the Google authorization URL.

GET /api/auth/google/connect?returnUrl=/p/my-podcast/settings/calendars

Headers Required:

Authorization: Bearer <user-jwt-token>

Query Parameters:

ParameterTypeRequiredDefaultDescription
returnUrlstringNo/settings/calendarsWhere to redirect after success

Response:

json
{
	"success": true,
	"authUrl": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=...&response_type=code&scope=...&access_type=offline&prompt=consent&state=..."
}

State Token Structure:

typescript
interface OAuthState {
	userId: string; // Authenticated user ID
	returnUrl: string; // Where to redirect after success
	nonce: string; // Random UUID for CSRF protection
}
// Encoded as Base64 JSON string

OAuth Callback

Handles the redirect from Google after user authorization.

GET /api/auth/google/callback?code={auth_code}&state={state}

Note: This endpoint is called directly by Google redirect, not by the client application.

Query Parameters:

ParameterTypeDescription
codestringAuthorization code from Google
statestringBase64-encoded state token
errorstringError code if user denied consent

Process:

  1. Handle Errors: Check for error parameter (user cancelled)
  2. Validate State: Decode and parse Base64 state token
  3. Exchange Code: Exchange authorization code for tokens via Google Token API
  4. Verify Refresh Token: Ensure refresh_token was returned
  5. Get User Info: Fetch Google user profile (id, email)
  6. List Calendars: Fetch available calendars to set defaults
  7. Store Connection: Create or update calendar_connections record
  8. Redirect: Send user back to returnUrl with success/error query param

Success Redirect:

{returnUrl}?connected=google

Error Redirects:

Error CodeCause
google_auth_deniedUser cancelled OAuth consent
missing_paramsMissing code or state parameter
invalid_stateState token decode/parse failed
no_refresh_tokenGoogle didn't return refresh token
server_configMissing Google OAuth credentials
db_errorDatabase save/update failed
oauth_failedGeneral OAuth error

Required OAuth Scopes

typescript
// src/lib/google-calendar/auth.ts
export const GOOGLE_CALENDAR_SCOPES = [
	'https://www.googleapis.com/auth/calendar.readonly', // Read calendars & events
	'https://www.googleapis.com/auth/calendar.events', // Create/modify events
	'https://www.googleapis.com/auth/userinfo.email' // Get account email
];
ScopePurpose
calendar.readonlyRead calendar list and FreeBusy info
calendar.eventsCreate/update/delete calendar events
userinfo.emailGet user's email address for display

Note: The userinfo.profile scope is NOT required - only email is used.

OAuth URL Generation

typescript
// src/lib/google-calendar/auth.ts
export function getGoogleAuthUrl(clientId: string, redirectUri: string, state: string): string {
	const params = new URLSearchParams({
		client_id: clientId,
		redirect_uri: redirectUri,
		response_type: 'code',
		scope: GOOGLE_CALENDAR_SCOPES.join(' '),
		access_type: 'offline', // Required for refresh_token
		prompt: 'consent', // Force consent to ensure refresh_token
		state
	});

	return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
}

Key Parameters:

  • access_type: 'offline' - Required to receive refresh_token
  • prompt: 'consent' - Forces consent screen to ensure refresh_token on reconnection

Token Exchange

typescript
// src/lib/google-calendar/auth.ts
export async function exchangeCodeForTokens(
	code: string,
	clientId: string,
	clientSecret: string,
	redirectUri: string
): Promise<GoogleTokens> {
	const response = await fetch('https://oauth2.googleapis.com/token', {
		method: 'POST',
		headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
		body: new URLSearchParams({
			code,
			client_id: clientId,
			client_secret: clientSecret,
			redirect_uri: redirectUri,
			grant_type: 'authorization_code'
		})
	});

	return response.json();
}

Token Response:

typescript
interface GoogleTokens {
	access_token: string; // Short-lived (1 hour)
	refresh_token?: string; // Long-lived (only on first auth or with prompt=consent)
	expires_in: number; // Seconds until access_token expires
	token_type: string; // Always "Bearer"
	scope: string; // Granted scopes
}

Database Record Creation

On successful OAuth, a calendar_connections record is created or updated:

typescript
// src/api/routes/auth/google.ts
const insertData = {
	user_id: state.userId,
	provider: 'google',
	provider_account_id: googleUser.id, // Google account ID
	provider_account_email: googleUser.email, // Display email
	refresh_token: tokens.refresh_token,
	// Default to primary calendar for both availability and events
	calendars_for_availability: primaryCalendar ? [primaryCalendar.id] : [],
	calendar_for_events: primaryCalendar?.id || null,
	is_valid: true,
	last_validated_at: new Date().toISOString()
};

On Reconnection: If the same Google account is already connected for this user, the existing record is updated rather than creating a duplicate.

Environment Variables

VariableDescription
GOOGLE_CLIENT_IDOAuth client ID from Google Cloud Console
GOOGLE_CLIENT_SECRETOAuth client secret
PUBLIC_APP_URLApp base URL for redirect URI construction
PUBLIC_SUPABASE_URLSupabase project URL
SUPABASE_SECRET_KEYSupabase admin key (or SUPABASE_SERVICE_ROLE_KEY)

Google Cloud Console Setup

  1. Create project in Google Cloud Console
  2. Enable Google Calendar API
  3. Configure OAuth consent screen:
    • User type: External
    • Scopes: calendar.readonly, calendar.events, userinfo.email
  4. Create OAuth 2.0 credentials (Web application)
  5. Add authorized redirect URI: https://app.podcasterplus.com/api/auth/google/callback

Error Handling

Callback Errors

ScenarioHandling
User cancels OAuthRedirect with ?error=google_auth_denied
Missing code/stateRedirect with ?error=missing_params
Invalid state tokenRedirect with ?error=invalid_state
No refresh tokenRedirect with ?error=no_refresh_token
Database errorRedirect with ?error=db_error
General OAuth errorRedirect with ?error=oauth_failed

Connect Endpoint Errors

StatusErrorCause
401UnauthorizedMissing or invalid Bearer token
500Google OAuth not configuredMissing GOOGLE_CLIENT_ID

Security Considerations

State Token (CSRF Protection)

The state parameter prevents CSRF attacks:

  • Generated server-side with authenticated user's ID
  • Includes random nonce (UUID) for uniqueness
  • Validated on callback to ensure request originated from this app
typescript
const state: OAuthState = {
	userId: user.id,
	returnUrl,
	nonce: crypto.randomUUID()
};
const stateString = btoa(JSON.stringify(state));

Token Storage

  • Only refresh_token is stored in database (not access_token)
  • Access tokens are obtained on-demand via refresh
  • Tokens are encrypted at rest by Supabase
  • Never exposed in API responses

HTTPS Only

  • OAuth requires HTTPS redirect URIs in production
  • redirect_uri must match exactly what's registered in Google Cloud Console

Token Refresh

After initial OAuth, tokens are refreshed automatically when needed:

typescript
// src/lib/google-calendar/auth.ts
export async function refreshAccessToken(
	refreshToken: string,
	clientId: string,
	clientSecret: string
): Promise<{ access_token: string; expires_in: number }> {
	const response = await fetch('https://oauth2.googleapis.com/token', {
		method: 'POST',
		headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
		body: new URLSearchParams({
			refresh_token: refreshToken,
			client_id: clientId,
			client_secret: clientSecret,
			grant_type: 'refresh_token'
		})
	});

	return response.json();
	// Note: refresh_token is NOT returned on refresh
}

Reconnection Flow

Users can reconnect to fix invalid tokens or update permissions:

  1. User clicks "Reconnect" in calendar settings
  2. Client calls GET /api/auth/google/connect with Bearer token
  3. Receives authUrl and redirects to Google
  4. prompt: 'consent' forces re-authorization
  5. New refresh_token is obtained and stored
  6. Connection marked as is_valid: true

TypeScript Client Usage

typescript
import { createApiClient } from '$api/client';

const client = createApiClient(fetch);

// Initiate OAuth flow
const connectRes = await client.api.auth.google.connect.$get(
	{ query: { returnUrl: '/p/my-podcast/settings/calendars' } },
	{ headers: { Authorization: `Bearer ${token}` } }
);

if (connectRes.ok) {
	const { authUrl } = await connectRes.json();
	// Redirect browser to authUrl
	window.location.href = authUrl;
}

Internal documentation - Not for public distribution