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:
parent
2cc63c9b23
commit
8295febcce
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
'use client'
|
'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 { Calendar as CalendarIcon, MapPin, Clock, ZoomIn, ZoomOut, Link2, Unlink2 } from 'lucide-react'
|
||||||
import { TemporalZoomController } from '@/components/calendar'
|
import { TemporalZoomController } from '@/components/calendar'
|
||||||
import { CalendarHeader } from '@/components/calendar/CalendarHeader'
|
import { CalendarHeader } from '@/components/calendar/CalendarHeader'
|
||||||
import { CalendarSidebar } from '@/components/calendar/CalendarSidebar'
|
import { CalendarSidebar } from '@/components/calendar/CalendarSidebar'
|
||||||
|
import { ConnectCalendarBanner } from '@/components/ConnectCalendarBanner'
|
||||||
import { TabLayout } from '@/components/ui/TabLayout'
|
import { TabLayout } from '@/components/ui/TabLayout'
|
||||||
import { TemporalTab } from '@/components/tabs/TemporalTab'
|
import { TemporalTab } from '@/components/tabs/TemporalTab'
|
||||||
import { SpatialTab } from '@/components/tabs/SpatialTab'
|
import { SpatialTab } from '@/components/tabs/SpatialTab'
|
||||||
|
|
@ -76,6 +77,9 @@ export default function Home() {
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ConnectCalendarBanner />
|
||||||
|
</Suspense>
|
||||||
<CalendarHeader
|
<CalendarHeader
|
||||||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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('')
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue