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
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /connect | Yes | Initiate OAuth flow |
GET | /callback | No | OAuth 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/calendarsHeaders Required:
Authorization: Bearer <user-jwt-token>Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
returnUrl | string | No | /settings/calendars | Where to redirect after success |
Response:
{
"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:
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 stringOAuth 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:
| Parameter | Type | Description |
|---|---|---|
code | string | Authorization code from Google |
state | string | Base64-encoded state token |
error | string | Error code if user denied consent |
Process:
- Handle Errors: Check for
errorparameter (user cancelled) - Validate State: Decode and parse Base64 state token
- Exchange Code: Exchange authorization code for tokens via Google Token API
- Verify Refresh Token: Ensure
refresh_tokenwas returned - Get User Info: Fetch Google user profile (id, email)
- List Calendars: Fetch available calendars to set defaults
- Store Connection: Create or update
calendar_connectionsrecord - Redirect: Send user back to
returnUrlwith success/error query param
Success Redirect:
{returnUrl}?connected=googleError Redirects:
| Error Code | Cause |
|---|---|
google_auth_denied | User cancelled OAuth consent |
missing_params | Missing code or state parameter |
invalid_state | State token decode/parse failed |
no_refresh_token | Google didn't return refresh token |
server_config | Missing Google OAuth credentials |
db_error | Database save/update failed |
oauth_failed | General OAuth error |
Required OAuth Scopes
// 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
];| Scope | Purpose |
|---|---|
calendar.readonly | Read calendar list and FreeBusy info |
calendar.events | Create/update/delete calendar events |
userinfo.email | Get user's email address for display |
Note: The userinfo.profile scope is NOT required - only email is used.
OAuth URL Generation
// 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 receiverefresh_tokenprompt: 'consent'- Forces consent screen to ensurerefresh_tokenon reconnection
Token Exchange
// 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:
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:
// 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
| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID | OAuth client ID from Google Cloud Console |
GOOGLE_CLIENT_SECRET | OAuth client secret |
PUBLIC_APP_URL | App base URL for redirect URI construction |
PUBLIC_SUPABASE_URL | Supabase project URL |
SUPABASE_SECRET_KEY | Supabase admin key (or SUPABASE_SERVICE_ROLE_KEY) |
Google Cloud Console Setup
- Create project in Google Cloud Console
- Enable Google Calendar API
- Configure OAuth consent screen:
- User type: External
- Scopes:
calendar.readonly,calendar.events,userinfo.email
- Create OAuth 2.0 credentials (Web application)
- Add authorized redirect URI:
https://app.podcasterplus.com/api/auth/google/callback
Error Handling
Callback Errors
| Scenario | Handling |
|---|---|
| User cancels OAuth | Redirect with ?error=google_auth_denied |
| Missing code/state | Redirect with ?error=missing_params |
| Invalid state token | Redirect with ?error=invalid_state |
| No refresh token | Redirect with ?error=no_refresh_token |
| Database error | Redirect with ?error=db_error |
| General OAuth error | Redirect with ?error=oauth_failed |
Connect Endpoint Errors
| Status | Error | Cause |
|---|---|---|
401 | Unauthorized | Missing or invalid Bearer token |
500 | Google OAuth not configured | Missing 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
const state: OAuthState = {
userId: user.id,
returnUrl,
nonce: crypto.randomUUID()
};
const stateString = btoa(JSON.stringify(state));Token Storage
- Only
refresh_tokenis stored in database (notaccess_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_urimust match exactly what's registered in Google Cloud Console
Token Refresh
After initial OAuth, tokens are refreshed automatically when needed:
// 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:
- User clicks "Reconnect" in calendar settings
- Client calls
GET /api/auth/google/connectwith Bearer token - Receives
authUrland redirects to Google prompt: 'consent'forces re-authorization- New
refresh_tokenis obtained and stored - Connection marked as
is_valid: true
TypeScript Client Usage
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;
}Related
- Calendars API - Managing connections after OAuth
- Availability API - Using calendars for availability
- Google Calendar Integration - Full integration docs