Search code examples
node.jstypescriptgoogle-calendar-api

Using the Google Calendar API with a service account in Nodejs


I would like to build and app with the following functionality:

Whenever a user's google calendar is modified (event created, deleted, edited) get an update with the event details.

For this I realised that I need OAuth, so that I can subscribe to the watch endpoint of Google Calendar with the auth token.

As for context, I implemented this on the frontend (svelte) (scroll down to see code), which works well!

After this I ask a user to add the service account's email address to the calendar he would like to share with me.

  1. Go to google calendar
  2. Under "My Calendars" select the calendar and triple dot -> "settings and sharing"
  3. Scroll down to "Add people and groups", and add the service account email as an editor.

Theoretically, the service account now has access to the user's google calendar. But when I try to check if I have access from the server:

 const jwtClient = new google.auth.JWT(
    serviceAccountKey.client_email,
    './service_account.json',
    serviceAccountKey.private_key,
    ['https://www.googleapis.com/auth/calendar']
  );
  const calendarApi = google.calendar({ version: 'v3', auth: jwtClient });

  const checkCalendarAccess = async (calendarId) => {
    try {
      const response = await calendarApi.acl.list({ calendarId: calendarId });
      const aclEntries = response.data.items;
      return aclEntries;
    } catch (error) {
      console.error('Error checking calendar access:', error);
      throw error;
    }
  };
  const hasAccess = await checkCalendarAccess(calendarId);

  console.log(`Service account has access to the user's calendar: ${JSON.stringify(hasAccess, null, 2)}`);

  res.sendStatus(200);

I'm gettig a 403 Forbidden Error. I do not understand why this is the case or if I'm missing something.

The full GAxiosError:

{
>    response: {
>      config: {
>        url: 'https://www.googleapis.com/calendar/v3/calendars/peterszabototh%40gmail.com/acl',
>        method: 'GET',
>        userAgentDirectives: [Array],
>        paramsSerializer: [Function (anonymous)],
>        headers: [Object],
>        params: {},
>        validateStatus: [Function (anonymous)],
>        retry: true,
>        responseType: 'json',
>        retryConfig: [Object]
>      },
>      data: { error: [Object] },
>      headers: {
>        'alt-svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000',
>        'cache-control': 'no-cache, no-store, max-age=0, must-revalidate',
>        connection: 'close',
>        'content-encoding': 'gzip',
>        'content-type': 'application/json; charset=UTF-8',
>        date: 'Mon, 08 May 2023 17:28:19 GMT',
>        expires: 'Mon, 01 Jan 1990 00:00:00 GMT',
>        pragma: 'no-cache',
>        server: 'ESF',
>        'transfer-encoding': 'chunked',
>        vary: 'Origin, X-Origin, Referer',
>        'x-content-type-options': 'nosniff',
>        'x-frame-options': 'SAMEORIGIN',
>        'x-xss-protection': '0'
>      },
>      status: 403,
>      statusText: 'Forbidden',
>      request: {
>        responseURL: 'https://www.googleapis.com/calendar/v3/calendars/peterszabototh%40gmail.com/acl'
>      }
>    },
>    config: {
>      url: 'https://www.googleapis.com/calendar/v3/calendars/peterszabototh%40gmail.com/acl',
>      method: 'GET',
>      userAgentDirectives: [ [Object] ],
>      paramsSerializer: [Function (anonymous)],
>      headers: {
>        'x-goog-api-client': 'gdcl/6.0.4 gl-node/19.6.1 auth/8.8.0',
>        'Accept-Encoding': 'gzip',
>        'User-Agent': 'google-api-nodejs-client/6.0.4 (gzip)',
>        Authorization: 'Bearer XXXX',
>        Accept: 'application/json'
>      },
>      params: {},
>      validateStatus: [Function (anonymous)],
>      retry: true,
>      responseType: 'json',
>      retryConfig: {
>        currentRetryAttempt: 0,
>        retry: 3,
>        httpMethodsToRetry: [Array],
>        noResponseRetries: 2,
>        statusCodesToRetry: [Array]
>      }
>    },
>    code: 403,
>    errors: [ { domain: 'global', reason: 'forbidden', message: 'Forbidden' } ]
>  }

OAuth and Frontend Code

  1. Login
 const { auth } = initFirebase();

    const provider = new GoogleAuthProvider();
    provider.addScope('https://www.googleapis.com/auth/calendar');

    try {
      const result = await signInWithPopup(auth, provider);
      const credential = GoogleAuthProvider.credentialFromResult(result);

      oAuthCredentialStore.set(credential);

    } catch (error) {
      console.error('Error during Google Sign-In:', error);
    }
  };

  1. Sub to Calendar:
  const subToCalendar = async () => {
    const { auth } = initFirebase();
    const email = auth.currentUser.email;
    const url = `https://www.googleapis.com/calendar/v3/calendars/${email}/events/watch`;

    let oAuthCredential: OAuthCredential;

    oAuthCredentialStore.subscribe((value) => {
      oAuthCredential = value;

      if (oAuthCredential) {
        console.log(oAuthCredential.accessToken);
        if (oAuthCredential.accessToken === null) {
          auth.signOut();
        }
      }
    });

    try {
      await axios.post(
        url,
        {
          id: auth.currentUser.uid,
          type: 'web_hook',
          address: 'https://europe-west1-calsync-3f581.cloudfunctions.net/calendarWatch',
        },
        {
          headers: {
            Authorization: `Bearer ${oAuthCredential.accessToken}`,
          },
        }
      );
    } catch (error) {
      ...
    }
  }


Solution

  • The error 403: Forbidden means that the account is trying to perform an action that is not allowed to, in this case it is trying to access the sharing details/Access permissions of a specific calendar, in order to do that you'll need to update the permission to "Make changes and manage sharing":

    enter image description here

    That way the service account will be able to access the calendar's access control lists as intended in your code. If you don't want to use that access level and use "Make changes to events" instead you could use calendars.get():

    const checkCalendarAccess = async (calendarId) => {
        try {
            const response = await calendarApi.calendars.get({ calendarId: calendarId });
            const userCalendar = response.data;
            return userCalendar;
        } catch (error) {
            console.error('Error checking calendar access:', error);
            throw error;
        }
    };
    

    response.data will return Error 404 if the account doesn't have access, it will return a calendar resource if it has access, like this one:

    {
      kind: 'calendar#calendar',
      etag: '"jjjjjjZZZZZZZXXXXXXXKKKKKK"',
      id: '[email protected]',
      summary: 'Main calendar',
      timeZone: 'America/Los_Angeles'
    }