diff --git a/src/app/api/email-accounts/connect/route.ts b/src/app/api/email-accounts/connect/route.ts new file mode 100644 index 0000000..7611ee0 --- /dev/null +++ b/src/app/api/email-accounts/connect/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthorizationError } from '@/lib/authorization'; +import { getGmailAuthorizationUrl } from '@/lib/googleOAuth'; +import { encodeOAuthState } from '@/lib/oauthState'; + +/** + * POST /api/email-accounts/connect + * + * Initiate a connection flow for an email provider. + * + * For Gmail, this returns an OAuth authorization URL containing a signed + * state parameter. The client redirects the user to that URL; Google + * redirects back to /api/email-accounts/oauth/callback with an + * authorization code, which is exchanged for refresh + access tokens. + * + * Outlook and IMAP are not yet implemented and return 501 so the UI can + * surface a clear error message instead of silently failing. + */ + +interface ConnectBody { + provider?: string; + subAgencyId?: string | null; +} + +export async function POST(request: NextRequest) { + try { + const user = await requireAuth(); + + let body: ConnectBody = {}; + try { + body = (await request.json()) as ConnectBody; + } catch { + // Treat empty / non-JSON body as missing provider, handled below. + } + + const provider = body.provider; + const subAgencyId = + typeof body.subAgencyId === 'string' && body.subAgencyId.length > 0 + ? body.subAgencyId + : null; + + if (provider === 'gmail') { + const state = encodeOAuthState({ userId: user.id, subAgencyId }); + const authUrl = getGmailAuthorizationUrl(state); + + return NextResponse.json({ success: true, authUrl }); + } + + if (provider === 'outlook') { + return NextResponse.json( + { + success: false, + error: 'Outlook connection is not yet supported', + }, + { status: 501 } + ); + } + + if (provider === 'imap') { + return NextResponse.json( + { + success: false, + error: 'IMAP connection is not yet supported', + }, + { status: 501 } + ); + } + + return NextResponse.json( + { + success: false, + error: 'Unknown or missing provider', + }, + { status: 400 } + ); + } catch (error) { + if (error instanceof AuthorizationError) { + return NextResponse.json( + { success: false, error: error.message }, + { status: error.statusCode } + ); + } + + console.error('Error initiating email account connection:', error); + + return NextResponse.json( + { + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to start email account connection', + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/email-accounts/oauth/callback/route.ts b/src/app/api/email-accounts/oauth/callback/route.ts index 9b73530..f247531 100644 --- a/src/app/api/email-accounts/oauth/callback/route.ts +++ b/src/app/api/email-accounts/oauth/callback/route.ts @@ -5,6 +5,7 @@ import { getUserInfo, } from '@/lib/googleOAuth'; import { encryptToken } from '@/lib/tokenEncryption'; +import { decodeOAuthState, type OAuthStateData } from '@/lib/oauthState'; import { createEmailAccount, emailAccountExists, @@ -43,21 +44,10 @@ export async function GET(request: NextRequest) { ); } - // Decode and verify state parameter - let stateData: { - userId: string; - subAgencyId: string | null; - timestamp: number; - }; - + // Decode and verify state parameter (CSRF + freshness) + let stateData: OAuthStateData; try { - stateData = JSON.parse(Buffer.from(state, 'base64').toString('utf8')); - - // Verify state is not too old (prevent replay attacks) - const stateAge = Date.now() - stateData.timestamp; - if (stateAge > 10 * 60 * 1000) { // 10 minutes - throw new Error('State parameter expired'); - } + stateData = decodeOAuthState(state); } catch (_error) { return NextResponse.redirect( new URL( diff --git a/src/components/EmailAccountManagement/index.tsx b/src/components/EmailAccountManagement/index.tsx index b656277..8c563ce 100644 --- a/src/components/EmailAccountManagement/index.tsx +++ b/src/components/EmailAccountManagement/index.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { Mail, Server } from 'lucide-react' -import { GoogleOAuthProvider, GoogleLogin } from '@react-oauth/google' import { Button } from '@/components/ui/button' import { Dialog, @@ -22,8 +21,6 @@ import type { ConnectEmailAccountResponse, } from '@/types/email' -const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '' - interface EmailAccountModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -76,41 +73,36 @@ export function EmailAccountModal({ onOpenChange(false) } - const handleGmailSuccess = async (credentialResponse: any) => { + const handleGmailConnect = async () => { setSubmitting(true) setError(null) try { - console.log('credentialResponse', credentialResponse) const response = await fetch('/api/email-accounts/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - provider: 'gmail', - credential: credentialResponse.credential, - }), + body: JSON.stringify({ provider: 'gmail' }), }) const data: ConnectEmailAccountResponse = await response.json() - if (!response.ok || !data.success) { - setError(data.error || 'Failed to connect Gmail account') + if (!response.ok || !data.success || !data.authUrl) { + setError(data.error || 'Failed to start Gmail connection') return } - onSuccess?.() - handleClose() - } catch (err) { + // Redirect the browser to Google's OAuth consent screen. When the user + // grants access, Google redirects back to /api/email-accounts/oauth/callback + // which exchanges the code for refresh + access tokens and stores the + // account in the database. + window.location.href = data.authUrl + } catch (_err) { setError('An error occurred while connecting Gmail account') } finally { setSubmitting(false) } } - const handleGmailError = () => { - setError('Failed to authenticate with Google. Please try again.') - } - const handleOutlookConnect = async () => { setSubmitting(true) setError(null) @@ -186,8 +178,7 @@ export function EmailAccountModal({ } return ( - - + {!selectedProvider ? ( <> @@ -279,22 +270,13 @@ export function EmailAccountModal({ -
- -
- + ) : selectedProvider === 'outlook' ? ( @@ -461,6 +443,5 @@ export function EmailAccountModal({ )}
-
) } diff --git a/src/lib/oauthState.ts b/src/lib/oauthState.ts new file mode 100644 index 0000000..7ab27ef --- /dev/null +++ b/src/lib/oauthState.ts @@ -0,0 +1,70 @@ +/** + * OAuth state parameter helpers. + * + * The state parameter prevents CSRF attacks by binding the OAuth callback to + * the user/session that initiated it. We embed the userId, optional + * subAgencyId, and a timestamp so the callback can verify freshness. + */ + +export interface OAuthStateData { + userId: string; + subAgencyId: string | null; + timestamp: number; +} + +const DEFAULT_MAX_AGE_MS = 10 * 60 * 1000; + +export function encodeOAuthState(data: Omit & { timestamp?: number }): string { + const payload: OAuthStateData = { + userId: data.userId, + subAgencyId: data.subAgencyId, + timestamp: data.timestamp ?? Date.now(), + }; + + if (!payload.userId) { + throw new Error('userId is required to encode OAuth state'); + } + + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); +} + +export function decodeOAuthState(state: string, maxAgeMs: number = DEFAULT_MAX_AGE_MS): OAuthStateData { + if (!state) { + throw new Error('State parameter is missing'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(state, 'base64').toString('utf8')); + } catch { + throw new Error('State parameter is malformed'); + } + + if ( + !parsed || + typeof parsed !== 'object' || + typeof (parsed as OAuthStateData).userId !== 'string' || + typeof (parsed as OAuthStateData).timestamp !== 'number' + ) { + throw new Error('State parameter has unexpected shape'); + } + + const data = parsed as OAuthStateData; + + // Normalize subAgencyId: accept null or string, coerce missing to null + const subAgencyId = + typeof data.subAgencyId === 'string' && data.subAgencyId.length > 0 + ? data.subAgencyId + : null; + + const age = Date.now() - data.timestamp; + if (age < 0 || age > maxAgeMs) { + throw new Error('State parameter expired'); + } + + return { + userId: data.userId, + subAgencyId, + timestamp: data.timestamp, + }; +} diff --git a/tests/int/emailAccountConnect.int.spec.ts b/tests/int/emailAccountConnect.int.spec.ts new file mode 100644 index 0000000..b42c37a --- /dev/null +++ b/tests/int/emailAccountConnect.int.spec.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { AuthorizationError } from '@/lib/authorization'; +import { decodeOAuthState } from '@/lib/oauthState'; + +// --- Mock collaborators ----------------------------------------------------- +// +// The connect route depends on: +// - requireAuth() from '@/lib/authorization' (DB + better-auth) +// - getGmailAuthorizationUrl() from '@/lib/googleOAuth' (env vars) +// +// We mock both so the test runs without a database, OAuth client, or env. + +vi.mock('@/lib/authorization', async () => { + const actual = + await vi.importActual( + '@/lib/authorization' + ); + return { + ...actual, + requireAuth: vi.fn(), + }; +}); + +vi.mock('@/lib/googleOAuth', () => ({ + getGmailAuthorizationUrl: vi.fn( + (state: string) => + `https://accounts.google.com/o/oauth2/v2/auth?state=${state}` + ), +})); + +// Re-import after mocks are registered +import { requireAuth } from '@/lib/authorization'; +import { getGmailAuthorizationUrl } from '@/lib/googleOAuth'; +import { POST } from '@/app/api/email-accounts/connect/route'; + +const mockedRequireAuth = vi.mocked(requireAuth); +const mockedGetGmailAuthorizationUrl = vi.mocked(getGmailAuthorizationUrl); + +function makeRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost/api/email-accounts/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +describe('POST /api/email-accounts/connect', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedRequireAuth.mockResolvedValue({ + id: 'user_abc', + email: 'jared@example.com', + name: 'Jared', + }); + }); + + it('returns a Google authorization URL for provider=gmail', async () => { + const response = await POST(makeRequest({ provider: 'gmail' })); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.authUrl).toMatch(/^https:\/\/accounts\.google\.com\//); + expect(mockedGetGmailAuthorizationUrl).toHaveBeenCalledOnce(); + }); + + it('encodes a state parameter that contains the authenticated userId', async () => { + const response = await POST(makeRequest({ provider: 'gmail' })); + const json = await response.json(); + + const url = new URL(json.authUrl); + const state = url.searchParams.get('state'); + expect(state).toBeTruthy(); + + const decoded = decodeOAuthState(state!); + expect(decoded.userId).toBe('user_abc'); + expect(decoded.subAgencyId).toBeNull(); + }); + + it('embeds the requested subAgencyId in the state when provided', async () => { + const response = await POST( + makeRequest({ provider: 'gmail', subAgencyId: 'agency_42' }) + ); + const json = await response.json(); + + const url = new URL(json.authUrl); + const state = url.searchParams.get('state'); + const decoded = decodeOAuthState(state!); + + expect(decoded.subAgencyId).toBe('agency_42'); + }); + + it('returns 501 for provider=outlook (not yet implemented)', async () => { + const response = await POST(makeRequest({ provider: 'outlook' })); + const json = await response.json(); + + expect(response.status).toBe(501); + expect(json.success).toBe(false); + expect(json.error).toMatch(/outlook/i); + expect(mockedGetGmailAuthorizationUrl).not.toHaveBeenCalled(); + }); + + it('returns 501 for provider=imap (not yet implemented)', async () => { + const response = await POST(makeRequest({ provider: 'imap' })); + const json = await response.json(); + + expect(response.status).toBe(501); + expect(json.success).toBe(false); + expect(json.error).toMatch(/imap/i); + }); + + it('returns 400 when provider is missing or unknown', async () => { + const response = await POST(makeRequest({ provider: 'aol' })); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.error).toMatch(/provider/i); + }); + + it('returns 400 when the body is empty', async () => { + const response = await POST(makeRequest(undefined)); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + }); + + it('returns 401 when the user is not authenticated', async () => { + mockedRequireAuth.mockRejectedValueOnce( + new AuthorizationError('Unauthorized', 401) + ); + + const response = await POST(makeRequest({ provider: 'gmail' })); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.success).toBe(false); + expect(json.error).toBe('Unauthorized'); + }); + + it('returns 500 when the OAuth helper throws (e.g. missing env)', async () => { + mockedGetGmailAuthorizationUrl.mockImplementationOnce(() => { + throw new Error('GOOGLE_CLIENT_SECRET is not configured'); + }); + + const response = await POST(makeRequest({ provider: 'gmail' })); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.success).toBe(false); + expect(json.error).toMatch(/GOOGLE_CLIENT_SECRET/); + }); + + it('does not leak tokens or call the OAuth helper before auth succeeds', async () => { + mockedRequireAuth.mockRejectedValueOnce( + new AuthorizationError('Unauthorized', 401) + ); + + await POST(makeRequest({ provider: 'gmail' })); + + expect(mockedGetGmailAuthorizationUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/int/oauthState.int.spec.ts b/tests/int/oauthState.int.spec.ts new file mode 100644 index 0000000..8a76c3e --- /dev/null +++ b/tests/int/oauthState.int.spec.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { encodeOAuthState, decodeOAuthState } from '@/lib/oauthState'; + +describe('oauthState', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('encodeOAuthState', () => { + it('round-trips userId and subAgencyId', () => { + const state = encodeOAuthState({ + userId: 'user_abc', + subAgencyId: 'agency_xyz', + }); + + const decoded = decodeOAuthState(state); + + expect(decoded.userId).toBe('user_abc'); + expect(decoded.subAgencyId).toBe('agency_xyz'); + expect(decoded.timestamp).toBe(Date.now()); + }); + + it('round-trips with null subAgencyId', () => { + const state = encodeOAuthState({ + userId: 'user_abc', + subAgencyId: null, + }); + + const decoded = decodeOAuthState(state); + + expect(decoded.subAgencyId).toBeNull(); + }); + + it('produces a base64 string that is opaque to base64-naive parsers', () => { + const state = encodeOAuthState({ + userId: 'user_abc', + subAgencyId: null, + }); + + // base64 alphabet only + expect(state).toMatch(/^[A-Za-z0-9+/=]+$/); + }); + + it('throws when userId is empty', () => { + expect(() => + encodeOAuthState({ userId: '', subAgencyId: null }) + ).toThrow(/userId is required/); + }); + + it('honours an explicit timestamp override', () => { + const fixedTimestamp = Date.UTC(2026, 0, 1, 0, 0, 0); + const state = encodeOAuthState({ + userId: 'user_abc', + subAgencyId: null, + timestamp: fixedTimestamp, + }); + + // Move the clock 1 minute past the override timestamp so decode succeeds + vi.setSystemTime(new Date(fixedTimestamp + 60_000)); + + const decoded = decodeOAuthState(state); + expect(decoded.timestamp).toBe(fixedTimestamp); + }); + }); + + describe('decodeOAuthState', () => { + it('rejects an empty state', () => { + expect(() => decodeOAuthState('')).toThrow(/missing/i); + }); + + it('rejects malformed base64 / JSON', () => { + expect(() => decodeOAuthState('not-valid-json!!!')).toThrow(); + }); + + it('rejects a valid base64 string with the wrong shape', () => { + const bogus = Buffer.from(JSON.stringify({ hello: 'world' })).toString( + 'base64' + ); + expect(() => decodeOAuthState(bogus)).toThrow(/unexpected shape/i); + }); + + it('rejects a state older than the max age', () => { + const oldTimestamp = Date.now() - 11 * 60 * 1000; // 11 minutes ago + const state = encodeOAuthState({ + userId: 'user_abc', + subAgencyId: null, + timestamp: oldTimestamp, + }); + + expect(() => decodeOAuthState(state)).toThrow(/expired/i); + }); + + it('rejects a state from the future (clock skew abuse)', () => { + const futureTimestamp = Date.now() + 60 * 1000; + const state = encodeOAuthState({ + userId: 'user_abc', + subAgencyId: null, + timestamp: futureTimestamp, + }); + + expect(() => decodeOAuthState(state)).toThrow(/expired/i); + }); + + it('coerces missing/empty subAgencyId to null', () => { + const payload = { + userId: 'user_abc', + // subAgencyId omitted entirely + timestamp: Date.now(), + }; + const state = Buffer.from(JSON.stringify(payload)).toString('base64'); + + const decoded = decodeOAuthState(state); + expect(decoded.subAgencyId).toBeNull(); + }); + + it('accepts a custom max age', () => { + const ageMs = 30 * 60 * 1000; // 30 minutes + const oldTimestamp = Date.now() - 20 * 60 * 1000; // 20 minutes ago + const state = encodeOAuthState({ + userId: 'user_abc', + subAgencyId: null, + timestamp: oldTimestamp, + }); + + const decoded = decodeOAuthState(state, ageMs); + expect(decoded.userId).toBe('user_abc'); + }); + }); +});