Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions apps/web/src/app/api/integrations/google-calendar/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { NextResponse } from 'next/server';
import { db, googleCalendarConnections } from '@pagespace/db';
import { loggers } from '@pagespace/lib/server';
import { encrypt } from '@pagespace/lib';
import { OAuth2Client } from 'google-auth-library';
import crypto from 'crypto';

// State expiration: 10 minutes
const STATE_MAX_AGE_MS = 10 * 60 * 1000;

/**
* Handles Google Calendar OAuth callback.
* Exchanges authorization code for tokens and stores encrypted credentials.
*/
export async function GET(req: Request) {
const baseUrl = process.env.WEB_APP_URL || process.env.NEXTAUTH_URL || 'http://localhost:3000';

try {
// Validate required OAuth environment variables
if (
!process.env.GOOGLE_OAUTH_CLIENT_ID ||
!process.env.GOOGLE_OAUTH_CLIENT_SECRET ||
!process.env.OAUTH_STATE_SECRET
) {
loggers.auth.error('Missing required OAuth environment variables for Calendar callback');
return NextResponse.redirect(new URL('/settings?error=oauth_config', baseUrl));
}

const { searchParams } = new URL(req.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');

// Handle OAuth errors (user denied, etc.)
if (error) {
loggers.auth.warn('Google Calendar OAuth error', { error });
const errorParam = error === 'access_denied' ? 'access_denied' : 'oauth_error';
return NextResponse.redirect(
new URL(`/settings/integrations/google-calendar?error=${errorParam}`, baseUrl)
);
}

// Validate required parameters
if (!code || !state) {
loggers.auth.warn('Missing code or state in Calendar OAuth callback');
return NextResponse.redirect(
new URL('/settings/integrations/google-calendar?error=invalid_request', baseUrl)
);
}

// Validate and decode state parameter
let stateData: { userId: string; returnUrl: string; timestamp: number };
try {
const stateWithSignature = JSON.parse(Buffer.from(state, 'base64').toString('utf-8'));

if (!stateWithSignature.data || !stateWithSignature.sig) {
throw new Error('Invalid state structure');
}

// Verify signature
const expectedSignature = crypto
.createHmac('sha256', process.env.OAUTH_STATE_SECRET!)
.update(JSON.stringify(stateWithSignature.data))
.digest('hex');

if (stateWithSignature.sig !== expectedSignature) {
loggers.auth.warn('Google Calendar OAuth state signature mismatch');
return NextResponse.redirect(
new URL('/settings/integrations/google-calendar?error=invalid_state', baseUrl)
);
}

stateData = stateWithSignature.data;

// Check state expiration
if (Date.now() - stateData.timestamp > STATE_MAX_AGE_MS) {
loggers.auth.warn('Google Calendar OAuth state expired', { userId: stateData.userId });
return NextResponse.redirect(
new URL('/settings/integrations/google-calendar?error=state_expired', baseUrl)
);
}
} catch {
loggers.auth.warn('Failed to parse Google Calendar OAuth state');
return NextResponse.redirect(
new URL('/settings/integrations/google-calendar?error=invalid_state', baseUrl)
);
}

const { userId, returnUrl } = stateData;

// Build callback URL (must match what was used in connect)
const callbackUrl = `${baseUrl}/api/integrations/google-calendar/callback`;

// Exchange authorization code for tokens
const client = new OAuth2Client(
process.env.GOOGLE_OAUTH_CLIENT_ID,
process.env.GOOGLE_OAUTH_CLIENT_SECRET,
callbackUrl
);

const { tokens } = await client.getToken(code);

if (!tokens.access_token || !tokens.refresh_token) {
loggers.auth.error('Missing tokens from Google Calendar OAuth', {
hasAccessToken: !!tokens.access_token,
hasRefreshToken: !!tokens.refresh_token,
});
return NextResponse.redirect(
new URL('/settings/integrations/google-calendar?error=missing_tokens', baseUrl)
);
}

// Get user info from access token to verify identity
client.setCredentials(tokens);
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});

if (!userInfoResponse.ok) {
loggers.auth.error('Failed to fetch Google user info', {
status: userInfoResponse.status,
});
return NextResponse.redirect(
new URL('/settings/integrations/google-calendar?error=user_info_failed', baseUrl)
);
}

const userInfo = await userInfoResponse.json();
const googleEmail = userInfo.email;
const googleAccountId = userInfo.id;

if (!googleEmail || !googleAccountId) {
loggers.auth.error('Missing email or ID from Google user info');
return NextResponse.redirect(
new URL('/settings/integrations/google-calendar?error=user_info_incomplete', baseUrl)
);
}

// ZERO-TRUST: Encrypt tokens before storage
const encryptedAccessToken = await encrypt(tokens.access_token);
const encryptedRefreshToken = await encrypt(tokens.refresh_token);

// Calculate token expiration
const tokenExpiresAt = tokens.expiry_date
? new Date(tokens.expiry_date)
: new Date(Date.now() + 3600 * 1000); // Default 1 hour if not provided

// Upsert connection (one per user)
await db
.insert(googleCalendarConnections)
.values({
userId,
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
tokenExpiresAt,
googleEmail,
googleAccountId,
status: 'active',
statusMessage: null,
selectedCalendars: ['primary'], // Default to primary calendar
syncFrequencyMinutes: 15,
markAsReadOnly: true,
lastSyncAt: null,
lastSyncError: null,
syncCursor: null,
})
.onConflictDoUpdate({
target: googleCalendarConnections.userId,
set: {
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
tokenExpiresAt,
googleEmail,
googleAccountId,
status: 'active',
statusMessage: null,
updatedAt: new Date(),
},
});

loggers.auth.info('Google Calendar connected successfully', {
userId,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Redirect back to settings with success
const redirectUrl = new URL(returnUrl || '/settings/integrations/google-calendar', baseUrl);
redirectUrl.searchParams.set('connected', 'true');

return NextResponse.redirect(redirectUrl);
} catch (error) {
loggers.auth.error('Google Calendar OAuth callback error', error as Error);
return NextResponse.redirect(
new URL('/settings/integrations/google-calendar?error=unexpected', baseUrl)
);
}
}
125 changes: 125 additions & 0 deletions apps/web/src/app/api/integrations/google-calendar/connect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { z } from 'zod/v4';
import { db, users, eq } from '@pagespace/db';
import { loggers } from '@pagespace/lib/server';
import { checkDistributedRateLimit, DISTRIBUTED_RATE_LIMITS } from '@pagespace/lib/security';
import { authenticateRequestWithOptions, isAuthError, getClientIP } from '@/lib/auth';
import crypto from 'crypto';

const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: true };

const connectSchema = z.object({
returnUrl: z.string().optional(),
});

/**
* Initiates Google Calendar OAuth flow.
* Requests calendar.readonly scope for importing calendar events.
* User must be authenticated before connecting calendar.
*/
export async function POST(req: Request) {
try {
// Validate required OAuth environment variables
if (
!process.env.GOOGLE_OAUTH_CLIENT_ID ||
!process.env.GOOGLE_OAUTH_CLIENT_SECRET ||
!process.env.OAUTH_STATE_SECRET
) {
loggers.auth.error('Missing required OAuth environment variables for Calendar connect');
return Response.json({ error: 'OAuth not configured' }, { status: 500 });
}

// ZERO-TRUST: User must be authenticated with valid session
const auth = await authenticateRequestWithOptions(req, AUTH_OPTIONS);
if (isAuthError(auth)) return auth.error;
const userId = auth.userId;

// Fetch user for email hint
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, email: true },
});

if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 });
}

let body;
try {
body = await req.json();
} catch {
body = {};
}
const validation = connectSchema.safeParse(body);

if (!validation.success) {
return Response.json({ errors: validation.error.flatten().fieldErrors }, { status: 400 });
}

const { returnUrl } = validation.data;

// Rate limiting by user ID (more restrictive for integration connections)
const clientIP = getClientIP(req);
const rateLimit = await checkDistributedRateLimit(
`gcal:connect:user:${userId}`,
DISTRIBUTED_RATE_LIMITS.LOGIN
);

if (!rateLimit.allowed) {
return Response.json(
{ error: 'Too many connection attempts. Please try again later.', retryAfter: rateLimit.retryAfter },
{ status: 429 }
);
}

// Build callback URL for calendar-specific OAuth
const baseUrl = process.env.WEB_APP_URL || process.env.NEXTAUTH_URL || 'http://localhost:3000';
const callbackUrl = `${baseUrl}/api/integrations/google-calendar/callback`;

// Create signed state to prevent CSRF and preserve context
const stateData = {
userId,
returnUrl: returnUrl || '/settings/integrations/google-calendar',
timestamp: Date.now(),
};

const statePayload = JSON.stringify(stateData);
const signature = crypto
.createHmac('sha256', process.env.OAUTH_STATE_SECRET!)
.update(statePayload)
.digest('hex');

const stateWithSignature = JSON.stringify({ data: stateData, sig: signature });
const stateParam = Buffer.from(stateWithSignature).toString('base64');

// Generate OAuth URL with calendar scope
// Using include_granted_scopes for incremental authorization
const params = new URLSearchParams({
client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!,
redirect_uri: callbackUrl,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/calendar.readonly',
access_type: 'offline', // Required for refresh token
prompt: 'consent', // Force consent to get refresh token
include_granted_scopes: 'true', // Incremental auth
state: stateParam,
});

// If user has Google account linked, hint to use that account
if (user.email) {
params.set('login_hint', user.email);
}

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

loggers.auth.info('Google Calendar OAuth initiated', {
userId,
email: user.email,
clientIP,
});

return Response.json({ url: oauthUrl });
} catch (error) {
loggers.auth.error('Google Calendar connect error', error as Error);
return Response.json({ error: 'An unexpected error occurred' }, { status: 500 });
}
}
Loading