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