feat: add Google and Microsoft calendar OAuth integration

Implement full OAuth 2.0 flow for Google Calendar and Microsoft
Outlook with client-side EncryptID encryption. Tokens are exchanged
server-side using provider secrets, passed to the client via a
one-time retrieval code, then encrypted with the user's passkey
(AES-256-GCM) and stored only in the browser. The server never
persists OAuth tokens.

New files:
- src/lib/oauth.ts — server-side OAuth helpers for both providers
- src/lib/pending-tokens.ts — ephemeral in-memory token store (60s TTL)
- src/app/api/auth/{google,microsoft}/* — OAuth authorize/callback/refresh routes
- src/app/api/auth/calendars/route.ts — list connected calendar sources
- src/hooks/useCalendarTokens.ts — client-side encrypted token management
- src/components/ConnectCalendarBanner.tsx — connect calendar prompt with status feedback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-21 21:19:45 +00:00
parent 2cc63c9b23
commit 8295febcce
12 changed files with 1138 additions and 1 deletions

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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)
)
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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)
)
}
}

View File

@ -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 */}
<div className="flex-1 flex flex-col overflow-hidden">
<Suspense fallback={null}>
<ConnectCalendarBanner />
</Suspense>
<CalendarHeader
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
sidebarOpen={sidebarOpen}

View File

@ -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: (
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="none">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
),
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: (
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="#0078D4">
<path d="M24 7.387v10.478c0 .23-.08.424-.238.576a.807.807 0 01-.588.234h-8.42v-6.56l1.56 1.14a.27.27 0 00.32 0l7.14-5.088c.08-.058.155-.058.226 0zM24 5.395c0-.07-.038-.15-.113-.234a.348.348 0 00-.238-.14h-.146L14.754 11.5l-1.504-1.09V4.5h10.4c.346 0 .566.1.7.297.1.197.15.398.15.598zM13.25 4.5v7.122L1.2 4.68c-.1-.06-.176-.03-.226.088A.756.756 0 00.9 5.1v14.287a.734.734 0 00.247.556c.165.15.354.227.566.227h11.537V12.68l-.34.244-.002.001-4.254 3.025a.27.27 0 01-.318 0L1.574 11.2v5.556a.368.368 0 01-.114.27.368.368 0 01-.272.114.379.379 0 01-.384-.384V8.92l6.762 4.808a.27.27 0 00.318 0L13.25 9.99V4.5z"/>
</svg>
),
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: <Calendar className="w-5 h-5 text-orange-500" />,
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 (
<div className="bg-green-50 dark:bg-green-950/40 border-b border-green-200 dark:border-green-800 px-4 py-3">
<div className="flex items-center gap-3 max-w-screen-xl mx-auto">
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
<p className="text-sm text-green-800 dark:text-green-200 flex-1">
Calendar connected! Credentials encrypted with your passkey and stored locally.
</p>
<button
onClick={() => setConnectStatus('idle')}
className="p-1 rounded hover:bg-green-100 dark:hover:bg-green-900 transition-colors"
>
<X className="w-4 h-4 text-green-600 dark:text-green-400" />
</button>
</div>
</div>
)
}
// Encrypting state
if (connectStatus === 'encrypting') {
return (
<div className="bg-blue-50 dark:bg-blue-950/40 border-b border-blue-200 dark:border-blue-800 px-4 py-3">
<div className="flex items-center gap-3 max-w-screen-xl mx-auto">
<Shield className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 animate-pulse" />
<p className="text-sm text-blue-800 dark:text-blue-200 flex-1">
Encrypting your calendar credentials with your passkey...
</p>
</div>
</div>
)
}
// Error/denied toast
if (connectStatus === 'error' || connectStatus === 'denied') {
return (
<div className="bg-red-50 dark:bg-red-950/40 border-b border-red-200 dark:border-red-800 px-4 py-3">
<div className="flex items-center gap-3 max-w-screen-xl mx-auto">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" />
<p className="text-sm text-red-800 dark:text-red-200 flex-1">
{connectStatus === 'denied'
? 'Calendar access was denied. You can try again anytime.'
: 'Failed to connect calendar. Please try again.'}
</p>
<button
onClick={() => setConnectStatus('idle')}
className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900 transition-colors"
>
<X className="w-4 h-4 text-red-600 dark:text-red-400" />
</button>
</div>
</div>
)
}
// Don't render while loading, if not authenticated, or if dismissed
if (loading || !isAuthenticated || dismissed) return null
return (
<div className="bg-blue-50 dark:bg-blue-950/40 border-b border-blue-200 dark:border-blue-800">
<div className="px-4 py-3 max-w-screen-xl mx-auto">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/60 flex items-center justify-center">
<Calendar className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Connect your calendar
</p>
<p className="text-xs text-blue-700 dark:text-blue-300 mt-0.5">
Sync events from Google Calendar, Outlook, or import via ICS. Credentials are encrypted locally with your passkey.
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => setShowProviders(!showProviders)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded-lg transition-colors"
>
Connect
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showProviders ? 'rotate-90' : ''}`} />
</button>
<button
onClick={handleDismiss}
className="p-1 rounded hover:bg-blue-100 dark:hover:bg-blue-900 transition-colors"
title="Dismiss"
>
<X className="w-4 h-4 text-blue-400 dark:text-blue-500" />
</button>
</div>
</div>
{showProviders && (
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{PROVIDERS.map((provider) => (
<a
key={provider.id}
href={provider.href || undefined}
onClick={
provider.isManual
? (e) => {
e.preventDefault()
alert('ICS import coming soon — use the sidebar "Add calendar" button for now.')
}
: undefined
}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 transition-colors cursor-pointer ${provider.color}`}
>
{provider.icon}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{provider.name}
</span>
</a>
))}
</div>
<div className="mt-2 flex items-center gap-1.5 text-xs text-blue-600 dark:text-blue-400">
<Shield className="w-3.5 h-3.5" />
Read-only access. Tokens are encrypted with your passkey (AES-256-GCM) and stored only in your browser.
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -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<void> {
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<StoredCalendarTokens | null> {
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<StoredCalendarTokens | null> {
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<string | null> {
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<string[]>(() => {
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,
}
}

245
src/lib/oauth.ts Normal file
View File

@ -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<Array<Record<string, unknown>>> {
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<Array<{
id: string
summary: string
description?: string
backgroundColor?: string
primary?: boolean
}>> {
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('')
}

39
src/lib/pending-tokens.ts Normal file
View File

@ -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<string, { data: unknown; expiresAt: number }>()
// 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
}