diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 1b3be084..830fb594 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/bun.lock b/bun.lock index 39dfe2ec..8d028fc3 100644 --- a/bun.lock +++ b/bun.lock @@ -225,7 +225,7 @@ "devDependencies": { "@repo/typescript-config": "workspace:*", "@types/node": "^22.9.0", - "typescript": "^5.8.3", + "typescript": "^5.9.2", }, }, "packages/google-tasks": { diff --git a/packages/api/src/providers/calendars/google-calendar.ts b/packages/api/src/providers/calendars/google-calendar.ts index 38ca56c0..6af81298 100644 --- a/packages/api/src/providers/calendars/google-calendar.ts +++ b/packages/api/src/providers/calendars/google-calendar.ts @@ -1,6 +1,10 @@ import { Temporal } from "temporal-polyfill"; -import { ConflictError, GoogleCalendar } from "@repo/google-calendar"; +import { + AuthenticationError, + ConflictError, + GoogleCalendar, +} from "@repo/google-calendar"; import { CALENDAR_DEFAULTS } from "../../constants/calendar"; import type { @@ -9,6 +13,7 @@ import type { CalendarFreeBusy, } from "../../interfaces"; import type { CreateEventInput, UpdateEventInput } from "../../schemas/events"; +import { refreshGoogleAccessToken } from "../../utils/token-refresh"; import { ProviderError } from "../lib/provider-error"; import { parseGoogleCalendarCalendarListEntry } from "./google-calendar/calendars"; import { @@ -21,16 +26,23 @@ import type { CalendarProvider, ResponseToEventInput } from "./interfaces"; interface GoogleCalendarProviderOptions { accessToken: string; + refreshToken: string; accountId: string; } export class GoogleCalendarProvider implements CalendarProvider { public readonly providerId = "google" as const; public readonly accountId: string; + private refreshToken: string; private client: GoogleCalendar; - constructor({ accessToken, accountId }: GoogleCalendarProviderOptions) { + constructor({ + accessToken, + refreshToken, + accountId, + }: GoogleCalendarProviderOptions) { this.accountId = accountId; + this.refreshToken = refreshToken; this.client = new GoogleCalendar({ accessToken, }); @@ -365,13 +377,57 @@ export class GoogleCalendarProvider implements CalendarProvider { operation: string, fn: () => Promise | T, context?: Record, + retryAttempt: number = 0, ): Promise { try { return await Promise.resolve(fn()); } catch (error: unknown) { - console.error(`Failed to ${operation}:`, error); + if (error instanceof AuthenticationError && retryAttempt === 0) { + console.warn( + `Authentication error in ${operation}, attempting token refresh for account ${this.accountId}`, + ); + + try { + const newAccessToken = await refreshGoogleAccessToken({ + accountId: this.accountId, + }); + + this.client = new GoogleCalendar({ + accessToken: newAccessToken, + }); + + console.info( + `Successfully refreshed token for account ${this.accountId}, retrying ${operation}`, + ); + return await this.withErrorHandler( + operation, + fn, + context, + retryAttempt + 1, + ); + } catch (refreshError) { + console.error( + `Failed to refresh token for account ${this.accountId}:`, + refreshError, + ); + throw new ProviderError( + refreshError as Error, + `${operation}_token_refresh`, + { + ...context, + originalError: error, + accountId: this.accountId, + }, + ); + } + } - throw new ProviderError(error as Error, operation, context); + console.error(`Failed to ${operation}:`, error); + throw new ProviderError(error as Error, operation, { + ...context, + accountId: this.accountId, + retryAttempt, + }); } } } diff --git a/packages/api/src/providers/calendars/microsoft-calendar.ts b/packages/api/src/providers/calendars/microsoft-calendar.ts index 5aeec4d5..5adf51be 100644 --- a/packages/api/src/providers/calendars/microsoft-calendar.ts +++ b/packages/api/src/providers/calendars/microsoft-calendar.ts @@ -17,6 +17,7 @@ import type { UpdateCalendarInput, } from "../../schemas/calendars"; import type { CreateEventInput, UpdateEventInput } from "../../schemas/events"; +import { refreshMicrosoftAccessToken } from "../../utils/token-refresh"; import { ProviderError } from "../lib/provider-error"; import type { CalendarProvider, ResponseToEventInput } from "./interfaces"; import { @@ -33,16 +34,23 @@ import { parseScheduleItem } from "./microsoft-calendar/freebusy"; interface MicrosoftCalendarProviderOptions { accessToken: string; + refreshToken: string; accountId: string; } export class MicrosoftCalendarProvider implements CalendarProvider { public readonly providerId = "microsoft" as const; public readonly accountId: string; + private refreshToken: string; private graphClient: Client; - constructor({ accessToken, accountId }: MicrosoftCalendarProviderOptions) { + constructor({ + accessToken, + refreshToken, + accountId, + }: MicrosoftCalendarProviderOptions) { this.accountId = accountId; + this.refreshToken = refreshToken; this.graphClient = Client.initWithMiddleware({ authProvider: { getAccessToken: async () => accessToken, @@ -302,13 +310,77 @@ export class MicrosoftCalendarProvider implements CalendarProvider { operation: string, fn: () => Promise | T, context?: Record, + retryAttempt: number = 0, ): Promise { try { return await Promise.resolve(fn()); } catch (error: unknown) { + const isAuthError = this.isAuthenticationError(error); + + if (isAuthError && retryAttempt === 0) { + console.warn( + `Authentication error in ${operation}, attempting token refresh for account ${this.accountId}`, + ); + + try { + const newAccessToken = await refreshMicrosoftAccessToken({ + accountId: this.accountId, + }); + + this.graphClient = Client.initWithMiddleware({ + authProvider: { + getAccessToken: async () => newAccessToken, + }, + }); + + console.info( + `Successfully refreshed token for account ${this.accountId}, retrying ${operation}`, + ); + return await this.withErrorHandler( + operation, + fn, + context, + retryAttempt + 1, + ); + } catch (refreshError) { + console.error( + `Failed to refresh token for account ${this.accountId}:`, + refreshError, + ); + throw new ProviderError( + refreshError as Error, + `${operation}_token_refresh`, + { + ...context, + originalError: error, + accountId: this.accountId, + }, + ); + } + } + console.error(`Failed to ${operation}:`, error); + throw new ProviderError(error as Error, operation, { + ...context, + accountId: this.accountId, + retryAttempt, + }); + } + } - throw new ProviderError(error as Error, operation, context); + private isAuthenticationError(error: unknown): boolean { + if (error && typeof error === "object") { + const err = error as any; + // Microsoft Graph errors typically have a status or code property + return ( + err.status === 401 || + err.code === 401 || + err.statusCode === 401 || + (err.message && err.message.includes("401")) || + (err.message && err.message.toLowerCase().includes("unauthorized")) || + (err.message && err.message.toLowerCase().includes("authentication")) + ); } + return false; } } diff --git a/packages/api/src/providers/conferencing/google-meet.ts b/packages/api/src/providers/conferencing/google-meet.ts index ad2d503d..98ad6ecc 100644 --- a/packages/api/src/providers/conferencing/google-meet.ts +++ b/packages/api/src/providers/conferencing/google-meet.ts @@ -7,6 +7,7 @@ import type { ConferencingProvider } from "./interfaces"; interface GoogleMeetProviderOptions { accessToken: string; + refreshToken: string; accountId: string; } @@ -15,8 +16,13 @@ export class GoogleMeetProvider implements ConferencingProvider { public readonly accountId: string; private client: GoogleCalendar; - constructor({ accessToken, accountId }: GoogleMeetProviderOptions) { + constructor({ + accessToken, + refreshToken, + accountId, + }: GoogleMeetProviderOptions) { this.accountId = accountId; + // Note: conferencing provider can use same refresh logic as calendar provider when needed this.client = new GoogleCalendar({ accessToken, }); diff --git a/packages/api/src/providers/conferencing/zoom.ts b/packages/api/src/providers/conferencing/zoom.ts index e621ac94..22ddcfd4 100644 --- a/packages/api/src/providers/conferencing/zoom.ts +++ b/packages/api/src/providers/conferencing/zoom.ts @@ -6,6 +6,7 @@ import type { ConferencingProvider } from "./interfaces"; interface ZoomProviderOptions { accessToken: string; + refreshToken: string; accountId?: string; // Unused but allows shared construction signature } @@ -13,8 +14,9 @@ export class ZoomProvider implements ConferencingProvider { public readonly providerId = "zoom" as const; private accessToken: string; - constructor({ accessToken }: ZoomProviderOptions) { + constructor({ accessToken, refreshToken }: ZoomProviderOptions) { this.accessToken = accessToken; + // Note: refreshToken is accepted but not used in this implementation } /** diff --git a/packages/api/src/providers/index.ts b/packages/api/src/providers/index.ts index f2ecf136..24f6b2a2 100644 --- a/packages/api/src/providers/index.ts +++ b/packages/api/src/providers/index.ts @@ -66,6 +66,7 @@ function accountToProvider< return new Provider({ accessToken: activeAccount.accessToken, + refreshToken: activeAccount.refreshToken, accountId: activeAccount.accountId, }); } @@ -117,6 +118,7 @@ export function accountToConferencingProvider( return new Provider({ accessToken: activeAccount.accessToken, + refreshToken: activeAccount.refreshToken, accountId: activeAccount.accountId, }); } diff --git a/packages/api/src/providers/interfaces.ts b/packages/api/src/providers/interfaces.ts index a81be318..6f93f48e 100644 --- a/packages/api/src/providers/interfaces.ts +++ b/packages/api/src/providers/interfaces.ts @@ -1,5 +1,6 @@ export interface ProviderConfig { accessToken: string; + refreshToken: string; accountId: string; } diff --git a/packages/api/src/providers/tasks/google-tasks.ts b/packages/api/src/providers/tasks/google-tasks.ts index 924c30a2..2dac6ff4 100644 --- a/packages/api/src/providers/tasks/google-tasks.ts +++ b/packages/api/src/providers/tasks/google-tasks.ts @@ -12,6 +12,7 @@ import type { TaskProvider } from "./interfaces"; interface GoogleTasksProviderOptions { accessToken: string; + refreshToken: string; accountId: string; } @@ -20,8 +21,13 @@ export class GoogleTasksProvider implements TaskProvider { public readonly accountId: string; private client: GoogleTasks; - constructor({ accessToken, accountId }: GoogleTasksProviderOptions) { + constructor({ + accessToken, + refreshToken, + accountId, + }: GoogleTasksProviderOptions) { this.accountId = accountId; + // Note: refreshToken accepted for interface compatibility, refresh logic can be added when needed this.client = new GoogleTasks({ accessToken, }); diff --git a/packages/api/src/utils/token-refresh.ts b/packages/api/src/utils/token-refresh.ts new file mode 100644 index 00000000..aa37284b --- /dev/null +++ b/packages/api/src/utils/token-refresh.ts @@ -0,0 +1,63 @@ +import { TRPCError } from "@trpc/server"; + +import { auth } from "@repo/auth/server"; + +interface RefreshTokenOptions { + accountId: string; + providerId: string; +} + +interface RefreshTokenOptionsWithDefault { + accountId: string; + providerId?: string; +} + +export async function refreshAccessToken({ + accountId, + providerId, +}: RefreshTokenOptions): Promise { + try { + const { accessToken } = await auth.api.getAccessToken({ + body: { + providerId, + accountId, + }, + }); + + if (!accessToken) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Failed to refresh access token. Please re-authenticate.", + }); + } + + console.info( + `Successfully refreshed ${providerId} access token for account ${accountId}`, + ); + return accessToken; + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + + console.error(`Error refreshing ${providerId} access token:`, error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to refresh access token", + }); + } +} + +export async function refreshGoogleAccessToken({ + accountId, + providerId = "google", +}: RefreshTokenOptionsWithDefault): Promise { + return refreshAccessToken({ accountId, providerId }); +} + +export async function refreshMicrosoftAccessToken({ + accountId, + providerId = "microsoft", +}: RefreshTokenOptionsWithDefault): Promise { + return refreshAccessToken({ accountId, providerId }); +}