diff --git a/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts b/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts index 9ced4dd7a..9cb2ac468 100644 --- a/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts +++ b/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts @@ -5,7 +5,12 @@ import test from 'ava'; import { createModule } from '../../../__tests__/create-module'; import { Mockers } from '../../../__tests__/mocks'; -import { CalendarProviderRequestError, CryptoHelper } from '../../../base'; +import { + CalendarProviderRequestError, + Config, + CryptoHelper, + GraphqlBadRequest, +} from '../../../base'; import { ConfigModule } from '../../../base/config'; import { ServerConfigModule } from '../../../core/config'; import type { @@ -89,6 +94,7 @@ const calendarService = module.get(CalendarService); const calendarCronJobs = module.get(CalendarCronJobs); const providerFactory = module.get(CalendarProviderFactory); const models = module.get(Models); +const config = module.get(Config); module.get(CryptoHelper).onConfigInit(); const createAccount = async ( @@ -168,6 +174,7 @@ const createSubscription = async ( }; test.afterEach.always(() => { + config.calendar.google.allowNewAccounts = true; mock.reset(); module.queue.add.resetHistory(); module.queue.remove.resetHistory(); @@ -202,6 +209,48 @@ test('listAccounts includes calendars count', async t => { t.is(counts.get(accountB.id), 1); }); +test('assertCanLinkProvider blocks new google calendar accounts when disabled', async t => { + config.calendar.google.allowNewAccounts = false; + const user = await module.create(Mockers.User); + + const error = await t.throwsAsync( + calendarService.assertCanLinkProvider(user.id, CalendarProviderName.Google) + ); + t.true(error instanceof GraphqlBadRequest); + t.is( + (error as GraphqlBadRequest).data?.code, + 'calendar_provider_link_disabled' + ); +}); + +test('assertCanLinkProvider allows users with an existing google calendar account', async t => { + config.calendar.google.allowNewAccounts = false; + const user = await module.create(Mockers.User); + await createAccount(user.id); + + await t.notThrowsAsync( + calendarService.assertCanLinkProvider(user.id, CalendarProviderName.Google) + ); +}); + +test('handleOAuthCallback does not persist new google account when linking is disabled', async t => { + config.calendar.google.allowNewAccounts = false; + const provider = new MockCalendarProvider(); + providerFactory.register(provider); + const user = await module.create(Mockers.User); + + const error = await t.throwsAsync( + calendarService.handleOAuthCallback({ + provider: CalendarProviderName.Google, + code: 'code', + redirectUri: 'https://example.com/callback', + userId: user.id, + }) + ); + t.true(error instanceof GraphqlBadRequest); + t.is((await models.calendarAccount.listByUser(user.id)).length, 0); +}); + test('syncSubscription resets invalid sync token and maps events', async t => { const user = await module.create(Mockers.User); const account = await createAccount(user.id); diff --git a/packages/backend/server/src/plugins/calendar/config.ts b/packages/backend/server/src/plugins/calendar/config.ts index 4136d2298..4ccf29b45 100644 --- a/packages/backend/server/src/plugins/calendar/config.ts +++ b/packages/backend/server/src/plugins/calendar/config.ts @@ -4,6 +4,7 @@ import { defineModuleConfig, JSONSchema } from '../../base'; export interface CalendarGoogleConfig { enabled: boolean; + allowNewAccounts?: boolean; clientId: string; clientSecret: string; externalWebhookUrl?: string; @@ -46,6 +47,7 @@ const schema: JSONSchema = { type: 'object', properties: { enabled: { type: 'boolean' }, + allowNewAccounts: { type: 'boolean' }, clientId: { type: 'string' }, clientSecret: { type: 'string' }, externalWebhookUrl: { type: 'string' }, @@ -86,6 +88,7 @@ defineModuleConfig('calendar', { desc: 'Google Calendar integration config', default: { enabled: false, + allowNewAccounts: true, clientId: '', clientSecret: '', externalWebhookUrl: '', @@ -95,6 +98,7 @@ defineModuleConfig('calendar', { schema, shape: z.object({ enabled: z.boolean(), + allowNewAccounts: z.boolean().optional(), clientId: z.string(), clientSecret: z.string(), externalWebhookUrl: z diff --git a/packages/backend/server/src/plugins/calendar/controller.ts b/packages/backend/server/src/plugins/calendar/controller.ts index 3ae2dceb8..a0aea8e04 100644 --- a/packages/backend/server/src/plugins/calendar/controller.ts +++ b/packages/backend/server/src/plugins/calendar/controller.ts @@ -46,6 +46,8 @@ export class CalendarController { throw new UnknownOauthProvider({ name: providerName }); } + await this.calendar.assertCanLinkProvider(user.id, providerName); + const state = await this.oauth.saveOAuthState({ provider: providerName, userId: user.id, diff --git a/packages/backend/server/src/plugins/calendar/resolver.ts b/packages/backend/server/src/plugins/calendar/resolver.ts index 134c751fd..271e38b4d 100644 --- a/packages/backend/server/src/plugins/calendar/resolver.ts +++ b/packages/backend/server/src/plugins/calendar/resolver.ts @@ -38,7 +38,12 @@ export class CalendarServerConfigResolver { @ResolveField(() => [CalendarProviderName]) calendarProviders() { - return this.providerFactory.providers; + return this.providerFactory.providers.filter(provider => { + return ( + provider !== CalendarProviderName.Google || + this.config.calendar.google.allowNewAccounts !== false + ); + }); } @ResolveField(() => [CalendarCalDAVProviderPresetObjectType]) @@ -166,6 +171,8 @@ export class CalendarMutationResolver { throw new AuthenticationRequired(); } + await this.calendar.assertCanLinkProvider(user.id, input.provider); + const state = await this.oauth.saveOAuthState({ provider: input.provider, userId: user.id, diff --git a/packages/backend/server/src/plugins/calendar/service.ts b/packages/backend/server/src/plugins/calendar/service.ts index 1bf357d01..2785215bb 100644 --- a/packages/backend/server/src/plugins/calendar/service.ts +++ b/packages/backend/server/src/plugins/calendar/service.ts @@ -154,6 +154,11 @@ export class CalendarService { const provider = this.requireProvider(params.provider); const tokens = await provider.exchangeCode(params.code, params.redirectUri); const profile = await provider.getAccountProfile(tokens.accessToken); + await this.assertCanPersistProviderAccount( + params.userId, + params.provider, + profile.providerAccountId + ); const account = await this.models.calendarAccount.upsert({ userId: params.userId, @@ -565,6 +570,19 @@ export class CalendarService { return true; } + async assertCanLinkProvider(userId: string, provider: CalendarProviderName) { + if (this.canCreateNewAccounts(provider)) { + return; + } + + const accounts = await this.models.calendarAccount.listByUser(userId); + if (accounts.some(account => account.provider === provider)) { + return; + } + + throw this.providerLinkDisabledError(provider); + } + getAuthUrl( provider: CalendarProviderName, state: string, @@ -580,6 +598,41 @@ export class CalendarService { return instance.getAuthUrl(state, redirectUri); } + private async assertCanPersistProviderAccount( + userId: string, + provider: CalendarProviderName, + providerAccountId: string + ) { + if (this.canCreateNewAccounts(provider)) { + return; + } + + const account = await this.models.calendarAccount.getByProviderAccount( + userId, + provider, + providerAccountId + ); + if (account) { + return; + } + + throw this.providerLinkDisabledError(provider); + } + + private canCreateNewAccounts(provider: CalendarProviderName) { + return ( + provider !== CalendarProviderName.Google || + this.config.calendar.google.allowNewAccounts !== false + ); + } + + private providerLinkDisabledError(provider: CalendarProviderName) { + return new GraphqlBadRequest({ + code: 'calendar_provider_link_disabled', + message: `${provider} calendar account linking is disabled.`, + }); + } + private async syncWithProvider(params: { provider: CalendarProvider; subscriptionId: string;