diff --git a/src/app/api/auth/calendars/route.ts b/src/app/api/auth/calendars/route.ts new file mode 100644 index 0000000..787ce4f --- /dev/null +++ b/src/app/api/auth/calendars/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth, isAuthed } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +/** + * GET /api/auth/calendars + * Returns the authenticated user's calendar sources from the DB. + * Note: OAuth tokens are stored client-side (encrypted by EncryptID), + * so this only returns source metadata — never credentials. + */ +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request) + if (!isAuthed(auth)) return auth + + const sources = await prisma.calendarSource.findMany({ + where: { createdById: auth.user.id }, + select: { + id: true, + name: true, + sourceType: true, + color: true, + isActive: true, + lastSyncedAt: true, + syncError: true, + }, + orderBy: { createdAt: 'desc' }, + }) + + return NextResponse.json({ + sources: sources.map((s: typeof sources[number]) => ({ + id: s.id, + name: s.name, + provider: s.sourceType, + color: s.color, + is_active: s.isActive, + last_synced_at: s.lastSyncedAt?.toISOString() || null, + has_error: !!s.syncError, + })), + }) + } catch (err) { + console.error('Connected calendars error:', err) + return NextResponse.json({ error: 'Failed to fetch connected calendars' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/google/authorize/route.ts b/src/app/api/auth/google/authorize/route.ts new file mode 100644 index 0000000..40ed099 --- /dev/null +++ b/src/app/api/auth/google/authorize/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth, isAuthed } from '@/lib/auth' +import { buildGoogleAuthUrl, generateStateToken } from '@/lib/oauth' +import { cookies } from 'next/headers' + +/** + * GET /api/auth/google/authorize + * Generates a Google OAuth URL and redirects the user. + * CSRF protection via a state token stored in a secure httpOnly cookie. + */ +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request) + if (!isAuthed(auth)) return auth + + const stateToken = generateStateToken() + + // Store state + user ID in a secure, httpOnly, short-lived cookie + const statePayload = JSON.stringify({ + csrf: stateToken, + userId: auth.user.id, + }) + + const cookieStore = await cookies() + cookieStore.set('oauth_state', statePayload, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 600, // 10 minutes + path: '/', + }) + + const authUrl = buildGoogleAuthUrl(stateToken) + return NextResponse.redirect(authUrl) + } catch (err) { + console.error('Google authorize error:', err) + const message = err instanceof Error ? err.message : 'OAuth configuration error' + + if (message.includes('not configured')) { + return NextResponse.json( + { error: 'Google Calendar integration is not yet configured' }, + { status: 503 } + ) + } + + return NextResponse.json({ error: 'Failed to initiate OAuth' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts new file mode 100644 index 0000000..920bbdb --- /dev/null +++ b/src/app/api/auth/google/callback/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server' +import { exchangeGoogleCode, fetchGoogleCalendars } from '@/lib/oauth' +import { storePendingToken } from '@/lib/pending-tokens' +import { cookies } from 'next/headers' + +/** + * GET /api/auth/google/callback + * Handles the OAuth callback from Google. + * Validates CSRF state, exchanges code for tokens, stores tokens in memory + * for one-time client retrieval, then redirects to calendar page. + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + + if (error) { + return NextResponse.redirect(new URL('/rcal/calendar?connect=denied', request.url)) + } + + if (!code || !state) { + return NextResponse.redirect( + new URL('/rcal/calendar?connect=error&reason=missing_params', request.url) + ) + } + + // Validate CSRF state token + const cookieStore = await cookies() + const stateCookie = cookieStore.get('oauth_state') + if (!stateCookie?.value) { + return NextResponse.redirect( + new URL('/rcal/calendar?connect=error&reason=expired_session', request.url) + ) + } + + let statePayload: { csrf: string; userId: string } + try { + statePayload = JSON.parse(stateCookie.value) + } catch { + return NextResponse.redirect( + new URL('/rcal/calendar?connect=error&reason=invalid_state', request.url) + ) + } + + if (statePayload.csrf !== state) { + return NextResponse.redirect( + new URL('/rcal/calendar?connect=error&reason=csrf_mismatch', request.url) + ) + } + + // Clear the state cookie + cookieStore.delete('oauth_state') + + // Exchange authorization code for tokens (server-side, uses client_secret) + const tokens = await exchangeGoogleCode(code) + + // Fetch the user's calendars to find the primary one + const calendars = await fetchGoogleCalendars(tokens.access_token) + + // Generate a one-time retrieval code (NOT the OAuth tokens themselves) + const retrievalCode = crypto.randomUUID() + + // Store tokens in memory for one-time pickup (60 second TTL) + storePendingToken(retrievalCode, { + provider: 'google', + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_in: tokens.expires_in, + calendars: calendars.map((c) => ({ + id: c.id, + name: c.summary, + color: c.backgroundColor, + primary: c.primary, + })), + }) + + // Redirect to client with only the retrieval code — no tokens in the URL + return NextResponse.redirect( + new URL(`/rcal/calendar?connect=pending&rc=${retrievalCode}`, request.url) + ) + } catch (err) { + console.error('Google callback error:', err) + return NextResponse.redirect( + new URL('/rcal/calendar?connect=error&reason=exchange_failed', request.url) + ) + } +} diff --git a/src/app/api/auth/google/refresh/route.ts b/src/app/api/auth/google/refresh/route.ts new file mode 100644 index 0000000..f111ba7 --- /dev/null +++ b/src/app/api/auth/google/refresh/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth, isAuthed } from '@/lib/auth' +import { refreshGoogleToken } from '@/lib/oauth' + +/** + * POST /api/auth/google/refresh + * Refreshes a Google access token using the refresh token. + * The client decrypts its stored refresh token and sends it here. + * Server uses the client_secret (from Infisical) to perform the refresh, + * returns the new access token. Neither token is persisted server-side. + */ +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request) + if (!isAuthed(auth)) return auth + + const { refresh_token } = await request.json() + if (!refresh_token || typeof refresh_token !== 'string') { + return NextResponse.json({ error: 'Missing refresh_token' }, { status: 400 }) + } + + const result = await refreshGoogleToken(refresh_token) + + return NextResponse.json({ + access_token: result.access_token, + expires_in: result.expires_in, + }) + } catch (err) { + console.error('Token refresh error:', err) + return NextResponse.json({ error: 'Failed to refresh token' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/google/retrieve/route.ts b/src/app/api/auth/google/retrieve/route.ts new file mode 100644 index 0000000..31bc81c --- /dev/null +++ b/src/app/api/auth/google/retrieve/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth, isAuthed } from '@/lib/auth' +import { getPendingToken } from '@/lib/pending-tokens' + +/** + * POST /api/auth/google/retrieve + * One-time retrieval of OAuth tokens after successful callback. + * Requires authentication. Returns tokens exactly once; they are deleted + * from server memory after retrieval. The client must encrypt them with + * EncryptID before storing. + */ +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request) + if (!isAuthed(auth)) return auth + + const { code } = await request.json() + if (!code || typeof code !== 'string') { + return NextResponse.json({ error: 'Missing retrieval code' }, { status: 400 }) + } + + const tokenData = getPendingToken(code) + if (!tokenData) { + return NextResponse.json( + { error: 'Token expired or already retrieved' }, + { status: 410 } + ) + } + + // Return tokens — client will encrypt with EncryptID immediately + return NextResponse.json(tokenData) + } catch (err) { + console.error('Token retrieval error:', err) + return NextResponse.json({ error: 'Failed to retrieve tokens' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/microsoft/authorize/route.ts b/src/app/api/auth/microsoft/authorize/route.ts new file mode 100644 index 0000000..ae3b799 --- /dev/null +++ b/src/app/api/auth/microsoft/authorize/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth, isAuthed } from '@/lib/auth' +import { buildMicrosoftAuthUrl, generateStateToken } from '@/lib/oauth' +import { cookies } from 'next/headers' + +/** + * GET /api/auth/microsoft/authorize + * Generates a Microsoft OAuth URL and redirects the user. + */ +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request) + if (!isAuthed(auth)) return auth + + const stateToken = generateStateToken() + + const statePayload = JSON.stringify({ + csrf: stateToken, + userId: auth.user.id, + }) + + const cookieStore = await cookies() + cookieStore.set('oauth_state', statePayload, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 600, + path: '/', + }) + + const authUrl = buildMicrosoftAuthUrl(stateToken) + return NextResponse.redirect(authUrl) + } catch (err) { + console.error('Microsoft authorize error:', err) + const message = err instanceof Error ? err.message : 'OAuth configuration error' + + if (message.includes('not configured')) { + return NextResponse.json( + { error: 'Microsoft Calendar integration is not yet configured' }, + { status: 503 } + ) + } + + return NextResponse.json({ error: 'Failed to initiate OAuth' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/microsoft/callback/route.ts b/src/app/api/auth/microsoft/callback/route.ts new file mode 100644 index 0000000..031e609 --- /dev/null +++ b/src/app/api/auth/microsoft/callback/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server' +import { exchangeMicrosoftCode } from '@/lib/oauth' +import { storePendingToken } from '@/lib/pending-tokens' +import { cookies } from 'next/headers' + +/** + * GET /api/auth/microsoft/callback + * Handles the OAuth callback from Microsoft. + * Tokens stored in memory for one-time client retrieval + EncryptID encryption. + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + + if (error) { + return NextResponse.redirect(new URL('/rcal/calendar?connect=denied', request.url)) + } + + if (!code || !state) { + return NextResponse.redirect( + new URL('/rcal/calendar?connect=error&reason=missing_params', request.url) + ) + } + + const cookieStore = await cookies() + const stateCookie = cookieStore.get('oauth_state') + if (!stateCookie?.value) { + return NextResponse.redirect( + new URL('/rcal/calendar?connect=error&reason=expired_session', request.url) + ) + } + + let statePayload: { csrf: string; userId: string } + try { + statePayload = JSON.parse(stateCookie.value) + } catch { + return NextResponse.redirect( + new URL('/rcal/calendar?connect=error&reason=invalid_state', request.url) + ) + } + + if (statePayload.csrf !== state) { + return NextResponse.redirect( + new URL('/rcal/calendar?connect=error&reason=csrf_mismatch', request.url) + ) + } + + cookieStore.delete('oauth_state') + + const tokens = await exchangeMicrosoftCode(code) + + const retrievalCode = crypto.randomUUID() + + storePendingToken(retrievalCode, { + provider: 'outlook', + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_in: tokens.expires_in, + calendars: [{ id: 'primary', name: 'Outlook Calendar', color: '#0078d4', primary: true }], + }) + + return NextResponse.redirect( + new URL(`/rcal/calendar?connect=pending&rc=${retrievalCode}`, request.url) + ) + } catch (err) { + console.error('Microsoft callback error:', err) + return NextResponse.redirect( + new URL('/rcal/calendar?connect=error&reason=exchange_failed', request.url) + ) + } +} diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx index 183e225..7db3c4a 100644 --- a/src/app/calendar/page.tsx +++ b/src/app/calendar/page.tsx @@ -1,10 +1,11 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, Suspense } from 'react' import { Calendar as CalendarIcon, MapPin, Clock, ZoomIn, ZoomOut, Link2, Unlink2 } from 'lucide-react' import { TemporalZoomController } from '@/components/calendar' import { CalendarHeader } from '@/components/calendar/CalendarHeader' import { CalendarSidebar } from '@/components/calendar/CalendarSidebar' +import { ConnectCalendarBanner } from '@/components/ConnectCalendarBanner' import { TabLayout } from '@/components/ui/TabLayout' import { TemporalTab } from '@/components/tabs/TemporalTab' import { SpatialTab } from '@/components/tabs/SpatialTab' @@ -76,6 +77,9 @@ export default function Home() { {/* Main content */}
+ + + setSidebarOpen(!sidebarOpen)} sidebarOpen={sidebarOpen} diff --git a/src/components/ConnectCalendarBanner.tsx b/src/components/ConnectCalendarBanner.tsx new file mode 100644 index 0000000..abb1c13 --- /dev/null +++ b/src/components/ConnectCalendarBanner.tsx @@ -0,0 +1,268 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Calendar, X, ChevronRight, AlertCircle, CheckCircle2, Shield } from 'lucide-react' +import { useSearchParams } from 'next/navigation' +import { + useCalendarTokens, + encryptAndStoreTokens, + getConnectedProviders, +} from '@/hooks/useCalendarTokens' + +const DISMISS_KEY = 'rcal-connect-banner-dismissed' + +const PROVIDERS = [ + { + id: 'google', + name: 'Google Calendar', + icon: ( + + + + + + + ), + href: '/rcal/api/auth/google/authorize', + color: 'hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-950/30', + }, + { + id: 'outlook', + name: 'Outlook / Microsoft 365', + icon: ( + + + + ), + href: '/rcal/api/auth/microsoft/authorize', + color: 'hover:border-sky-400 hover:bg-sky-50 dark:hover:bg-sky-950/30', + }, + { + id: 'ics', + name: 'ICS / iCal URL', + icon: , + href: null, + color: 'hover:border-orange-400 hover:bg-orange-50 dark:hover:bg-orange-950/30', + isManual: true, + }, +] as Array<{ + id: string + name: string + icon: React.ReactNode + href: string | null + color: string + isManual?: boolean +}> + +export function ConnectCalendarBanner() { + const [dismissed, setDismissed] = useState(true) + const [loading, setLoading] = useState(true) + const [showProviders, setShowProviders] = useState(false) + const [connectStatus, setConnectStatus] = useState< + 'idle' | 'success' | 'denied' | 'error' | 'encrypting' + >('idle') + const [isAuthenticated, setIsAuthenticated] = useState(false) + const searchParams = useSearchParams() + const { hasConnected, refresh: refreshProviders } = useCalendarTokens() + + // Check auth status + useEffect(() => { + fetch('/api/me') + .then((r) => r.json()) + .then((data) => { + setIsAuthenticated(!!data.authenticated) + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + // Handle OAuth callback — retrieve tokens and encrypt with EncryptID + useEffect(() => { + const connect = searchParams.get('connect') + const retrievalCode = searchParams.get('rc') + + if (connect === 'pending' && retrievalCode) { + setConnectStatus('encrypting') + // Retrieve tokens from server and encrypt locally + ;(async () => { + try { + const response = await fetch('/api/auth/google/retrieve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: retrievalCode }), + }) + + if (!response.ok) { + throw new Error(`Retrieval failed: ${response.status}`) + } + + const tokenData = await response.json() + + // Encrypt with EncryptID and store in localStorage + await encryptAndStoreTokens(tokenData.provider, tokenData) + + setConnectStatus('success') + refreshProviders() + window.history.replaceState({}, '', '/rcal/calendar') + + setTimeout(() => setConnectStatus('idle'), 5000) + } catch (err) { + console.error('Token encryption failed:', err) + setConnectStatus('error') + window.history.replaceState({}, '', '/rcal/calendar') + setTimeout(() => setConnectStatus('idle'), 5000) + } + })() + } else if (connect === 'denied') { + setConnectStatus('denied') + window.history.replaceState({}, '', '/rcal/calendar') + setTimeout(() => setConnectStatus('idle'), 5000) + } else if (connect === 'error') { + setConnectStatus('error') + window.history.replaceState({}, '', '/rcal/calendar') + setTimeout(() => setConnectStatus('idle'), 5000) + } + }, [searchParams, refreshProviders]) + + // Determine banner visibility + useEffect(() => { + if (!loading && isAuthenticated) { + const wasDismissed = localStorage.getItem(DISMISS_KEY) + const connected = getConnectedProviders().length > 0 + setDismissed(connected || !!wasDismissed) + } + }, [loading, isAuthenticated, hasConnected]) + + const handleDismiss = useCallback(() => { + setDismissed(true) + localStorage.setItem(DISMISS_KEY, 'true') + }, []) + + // Success toast + if (connectStatus === 'success') { + return ( +
+
+ +

+ Calendar connected! Credentials encrypted with your passkey and stored locally. +

+ +
+
+ ) + } + + // Encrypting state + if (connectStatus === 'encrypting') { + return ( +
+
+ +

+ Encrypting your calendar credentials with your passkey... +

+
+
+ ) + } + + // Error/denied toast + if (connectStatus === 'error' || connectStatus === 'denied') { + return ( +
+
+ +

+ {connectStatus === 'denied' + ? 'Calendar access was denied. You can try again anytime.' + : 'Failed to connect calendar. Please try again.'} +

+ +
+
+ ) + } + + // Don't render while loading, if not authenticated, or if dismissed + if (loading || !isAuthenticated || dismissed) return null + + return ( +
+
+
+
+ +
+ +
+

+ Connect your calendar +

+

+ Sync events from Google Calendar, Outlook, or import via ICS. Credentials are encrypted locally with your passkey. +

+
+ +
+ + +
+
+ + {showProviders && ( + + )} +
+
+ ) +} diff --git a/src/hooks/useCalendarTokens.ts b/src/hooks/useCalendarTokens.ts new file mode 100644 index 0000000..26dddd9 --- /dev/null +++ b/src/hooks/useCalendarTokens.ts @@ -0,0 +1,211 @@ +'use client' + +/** + * useCalendarTokens — Manages OAuth tokens encrypted with EncryptID. + * + * Tokens are encrypted client-side using the user's passkey-derived AES-256-GCM + * key and stored in localStorage. The server never persists tokens. + * + * Storage format per provider in localStorage: + * rcal_oauth_{provider} = JSON { ciphertext: base64, iv: base64 } + * + * Decrypted payload: + * { access_token, refresh_token, expires_at, calendars } + */ + +import { useState, useCallback } from 'react' +import { + getKeyManager, + encryptData, + decryptDataAsString, + bufferToBase64url, + base64urlToBuffer, +} from '@encryptid/sdk/client' + +const STORAGE_PREFIX = 'rcal_oauth_' + +export interface StoredCalendarTokens { + provider: string + access_token: string + refresh_token?: string + expires_at: number // Unix ms + calendars: Array<{ + id: string + name: string + color?: string + primary?: boolean + }> +} + +interface EncryptedBlob { + ciphertext: string // base64url + iv: string // base64url +} + +/** + * Encrypt OAuth tokens with the user's EncryptID key and store in localStorage. + */ +export async function encryptAndStoreTokens( + provider: string, + tokens: { + access_token: string + refresh_token?: string + expires_in: number + calendars: Array<{ id: string; name: string; color?: string; primary?: boolean }> + } +): Promise { + const km = getKeyManager() + if (!km.isInitialized()) { + throw new Error('EncryptID key manager not initialized — please sign in with your passkey') + } + + const keys = await km.getKeys() + + const payload: StoredCalendarTokens = { + provider, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: Date.now() + tokens.expires_in * 1000, + calendars: tokens.calendars, + } + + const encrypted = await encryptData(keys.encryptionKey, JSON.stringify(payload)) + + const blob: EncryptedBlob = { + ciphertext: bufferToBase64url(encrypted.ciphertext), + iv: bufferToBase64url(encrypted.iv.buffer as ArrayBuffer), + } + + localStorage.setItem(`${STORAGE_PREFIX}${provider}`, JSON.stringify(blob)) +} + +/** + * Decrypt stored tokens for a provider. + */ +export async function decryptStoredTokens(provider: string): Promise { + const stored = localStorage.getItem(`${STORAGE_PREFIX}${provider}`) + if (!stored) return null + + const km = getKeyManager() + if (!km.isInitialized()) return null + + try { + const keys = await km.getKeys() + const blob: EncryptedBlob = JSON.parse(stored) + + const decrypted = await decryptDataAsString(keys.encryptionKey, { + ciphertext: base64urlToBuffer(blob.ciphertext), + iv: new Uint8Array(base64urlToBuffer(blob.iv)), + }) + + return JSON.parse(decrypted) as StoredCalendarTokens + } catch (err) { + console.error(`Failed to decrypt ${provider} tokens:`, err) + return null + } +} + +/** + * Remove stored tokens for a provider. + */ +export function removeStoredTokens(provider: string): void { + localStorage.removeItem(`${STORAGE_PREFIX}${provider}`) +} + +/** + * Check which providers have stored (encrypted) tokens. + */ +export function getConnectedProviders(): string[] { + const providers: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key?.startsWith(STORAGE_PREFIX)) { + providers.push(key.slice(STORAGE_PREFIX.length)) + } + } + return providers +} + +/** + * Refresh an expired access token via the server. + * Decrypts refresh_token, sends to server, gets new access_token, + * re-encrypts and stores the updated tokens. + */ +export async function refreshAndUpdateTokens(provider: string): Promise { + const tokens = await decryptStoredTokens(provider) + if (!tokens?.refresh_token) return null + + try { + const response = await fetch(`/api/auth/${provider}/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: tokens.refresh_token }), + }) + + if (!response.ok) { + if (response.status === 401) { + // Refresh token revoked — remove stored tokens + removeStoredTokens(provider) + return null + } + throw new Error(`Refresh failed: ${response.status}`) + } + + const result = await response.json() + + // Re-encrypt with updated access token + await encryptAndStoreTokens(provider, { + access_token: result.access_token, + refresh_token: tokens.refresh_token, // Keep existing refresh token + expires_in: result.expires_in, + calendars: tokens.calendars, + }) + + return await decryptStoredTokens(provider) + } catch (err) { + console.error(`Failed to refresh ${provider} token:`, err) + return null + } +} + +/** + * Get a valid access token, refreshing if expired. + */ +export async function getValidAccessToken(provider: string): Promise { + let tokens = await decryptStoredTokens(provider) + if (!tokens) return null + + // If token expires within 2 minutes, refresh + if (tokens.expires_at < Date.now() + 120_000) { + tokens = await refreshAndUpdateTokens(provider) + if (!tokens) return null + } + + return tokens.access_token +} + +/** + * React hook for calendar token state. + */ +export function useCalendarTokens() { + const [connectedProviders, setConnectedProviders] = useState(() => { + if (typeof window === 'undefined') return [] + return getConnectedProviders() + }) + + const refresh = useCallback(() => { + setConnectedProviders(getConnectedProviders()) + }, []) + + const disconnect = useCallback((provider: string) => { + removeStoredTokens(provider) + setConnectedProviders(getConnectedProviders()) + }, []) + + return { + connectedProviders, + hasConnected: connectedProviders.length > 0, + refresh, + disconnect, + } +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts new file mode 100644 index 0000000..c755450 --- /dev/null +++ b/src/lib/oauth.ts @@ -0,0 +1,245 @@ +/** + * OAuth 2.0 helpers for calendar provider integrations. + * Server-side only — handles code exchange with provider secrets from Infisical. + * Tokens are returned to the client for EncryptID encryption; never persisted server-side. + */ + +// ── Google Calendar OAuth ── + +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token' + +const GOOGLE_SCOPES = [ + 'https://www.googleapis.com/auth/calendar.readonly', + 'https://www.googleapis.com/auth/calendar.events.readonly', +].join(' ') + +function getGoogleCredentials() { + const clientId = process.env.GOOGLE_CLIENT_ID + const clientSecret = process.env.GOOGLE_CLIENT_SECRET + const redirectUri = process.env.GOOGLE_OAUTH_REDIRECT_URI || + `${process.env.NEXT_PUBLIC_APP_URL || 'https://rspace.online/rcal'}/api/auth/google/callback` + + if (!clientId || !clientSecret) { + throw new Error('Google OAuth credentials not configured') + } + + return { clientId, clientSecret, redirectUri } +} + +/** + * Generate a Google OAuth 2.0 authorization URL. + * Includes a CSRF state token that must be verified on callback. + */ +export function buildGoogleAuthUrl(stateToken: string): string { + const { clientId, redirectUri } = getGoogleCredentials() + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: GOOGLE_SCOPES, + access_type: 'offline', + prompt: 'consent', + state: stateToken, + }) + + return `${GOOGLE_AUTH_URL}?${params.toString()}` +} + +/** + * Exchange an authorization code for tokens. Server-side only. + * Returns tokens that the client will encrypt with EncryptID. + */ +export async function exchangeGoogleCode(code: string): Promise<{ + access_token: string + refresh_token?: string + expires_in: number + token_type: string + scope: string +}> { + const { clientId, clientSecret, redirectUri } = getGoogleCredentials() + + const response = await fetch(GOOGLE_TOKEN_URL, { + 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', + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Google token exchange failed: ${error}`) + } + + return response.json() +} + +/** + * Refresh an access token using a refresh token. Server-side only. + * Called when the client sends an expired token for sync. + */ +export async function refreshGoogleToken(refreshToken: string): Promise<{ + access_token: string + expires_in: number + token_type: string + scope: string +}> { + const { clientId, clientSecret } = getGoogleCredentials() + + const response = await fetch(GOOGLE_TOKEN_URL, { + 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', + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Google token refresh failed: ${error}`) + } + + return response.json() +} + +/** + * Fetch events from Google Calendar using a user-provided access token. + * Token is ephemeral — passed per-request, never stored. + */ +export async function fetchGoogleCalendarEvents( + accessToken: string, + calendarId: string = 'primary', + timeMin?: string, + timeMax?: string, +): Promise>> { + const params = new URLSearchParams({ + singleEvents: 'true', + orderBy: 'startTime', + maxResults: '2500', + }) + if (timeMin) params.set('timeMin', timeMin) + if (timeMax) params.set('timeMax', timeMax) + + const response = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ) + + if (!response.ok) { + throw new Error(`Google Calendar API error: ${response.status}`) + } + + const data = await response.json() + return data.items || [] +} + +/** + * Fetch user's Google calendar list to let them choose which to sync. + */ +export async function fetchGoogleCalendars(accessToken: string): Promise> { + const response = await fetch( + 'https://www.googleapis.com/calendar/v3/users/me/calendarList', + { headers: { Authorization: `Bearer ${accessToken}` } } + ) + + if (!response.ok) { + throw new Error(`Failed to fetch Google calendars: ${response.status}`) + } + + const data = await response.json() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (data.items || []).map((cal: any) => ({ + id: cal.id as string, + summary: cal.summary as string, + description: cal.description as string | undefined, + backgroundColor: cal.backgroundColor as string | undefined, + primary: cal.primary as boolean | undefined, + })) +} + +// ── Outlook/Microsoft Calendar OAuth ── + +const MS_AUTH_URL = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize' +const MS_TOKEN_URL = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' + +const MS_SCOPES = ['Calendars.Read', 'offline_access'].join(' ') + +function getMicrosoftCredentials() { + const clientId = process.env.MICROSOFT_OAUTH_CLIENT_ID + const clientSecret = process.env.MICROSOFT_OAUTH_CLIENT_SECRET + const redirectUri = process.env.MICROSOFT_OAUTH_REDIRECT_URI || + `${process.env.NEXT_PUBLIC_APP_URL || 'https://rspace.online/rcal'}/api/auth/microsoft/callback` + + if (!clientId || !clientSecret) { + throw new Error('Microsoft OAuth credentials not configured') + } + + return { clientId, clientSecret, redirectUri } +} + +export function buildMicrosoftAuthUrl(stateToken: string): string { + const { clientId, redirectUri } = getMicrosoftCredentials() + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: MS_SCOPES, + state: stateToken, + response_mode: 'query', + }) + + return `${MS_AUTH_URL}?${params.toString()}` +} + +export async function exchangeMicrosoftCode(code: string): Promise<{ + access_token: string + refresh_token?: string + expires_in: number + token_type: string + scope: string +}> { + const { clientId, clientSecret, redirectUri } = getMicrosoftCredentials() + + const response = await fetch(MS_TOKEN_URL, { + 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', + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Microsoft token exchange failed: ${error}`) + } + + return response.json() +} + +// ── Utility: generate CSRF state token ── + +export function generateStateToken(): string { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('') +} diff --git a/src/lib/pending-tokens.ts b/src/lib/pending-tokens.ts new file mode 100644 index 0000000..7df9d97 --- /dev/null +++ b/src/lib/pending-tokens.ts @@ -0,0 +1,39 @@ +/** + * Temporary in-memory store for OAuth tokens pending client retrieval. + * Tokens live here for max 60 seconds before being picked up by the client, + * encrypted with EncryptID, and stored in the browser. + * NOT persisted — lives only in this server process's memory. + */ + +const pendingTokens = new Map() + +// Cleanup expired entries periodically +if (typeof setInterval !== 'undefined') { + setInterval(() => { + const now = Date.now() + pendingTokens.forEach((entry, key) => { + if (now > entry.expiresAt) pendingTokens.delete(key) + }) + }, 10_000) +} + +export function storePendingToken(code: string, data: unknown, ttlMs = 60_000): void { + pendingTokens.set(code, { + data, + expiresAt: Date.now() + ttlMs, + }) +} + +/** + * One-time retrieval — deletes the token after reading. + */ +export function getPendingToken(code: string): unknown | null { + const entry = pendingTokens.get(code) + if (!entry) return null + if (Date.now() > entry.expiresAt) { + pendingTokens.delete(code) + return null + } + pendingTokens.delete(code) + return entry.data +}