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 && (
+
+
+
+
+ Read-only access. Tokens are encrypted with your passkey (AES-256-GCM) and stored only in your browser.
+
+
+ )}
+
+
+ )
+}
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
+}