diff --git a/.env.example b/.env.example index 3472694..5ad5abd 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ DISCORD_EVENTS_SYNC_DISCORD_GUILD_ID= DISCORD_EVENTS_SYNC_DISCORD_BOT_TOKEN= DISCORD_EVENTS_SYNC_DISCORD_APPLICATION_ID= DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_CALENDAR_ID= -DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_API_KEY= \ No newline at end of file +DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_API_KEY= +DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON= \ No newline at end of file diff --git a/.github/workflows/sync.yml b/.github/workflows/calsync.yml similarity index 80% rename from .github/workflows/sync.yml rename to .github/workflows/calsync.yml index d79ac78..a6d4304 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/calsync.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: jobs: - build: + calsync: runs-on: ubuntu-latest - name: sync + name: 🔁 Sync Events steps: - uses: acm-uic/calsync@main with: @@ -18,3 +18,4 @@ jobs: discord-application-id: ${{ secrets.DISCORD_EVENTS_SYNC_DISCORD_APPLICATION_ID }} google-calendar-id: ${{ secrets.DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_CALENDAR_ID }} google-api-key: ${{ secrets.DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_API_KEY }} + google-service-account-key-json: ${{ secrets.DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON }} diff --git a/README.md b/README.md index bd4268b..8930f5a 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ jobs: discord-bot-token: ${{ secrets.DISCORD_BOT_TOKEN }} # needs "bot" scope and "View Channels", "Manage Events", "Create Events" bot permissions. permissions=17600775980032 discord-application-id: ${{ secrets.DISCORD_APPLICATION_ID }} google-calendar-id: ${{ secrets.GOOGLE_CALENDAR_CALENDAR_ID }} - google-api-key: ${{ secrets.GOOGLE_CALENDAR_API_KEY }} + google-service-account-key-json: ${{ secrets.GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON }} # either use google-api-key or google-service-account-key-json + google-api-key: ${{ secrets.GOOGLE_CALENDAR_API_KEY }} # either use google-api-key or google-service-account-key-json ``` ## How does it work? @@ -47,3 +48,14 @@ variables or in `.env.` file in the root of the repository. ## Discord bot permissions The Discord bot needs "Read Messages/View Channels", "Manage Events" permissions. + +## Google Cloud setup + +1. Enable the Google Calendar API for the Google Cloud project. +2. Create a service account and download the JSON key. + 1. Credentials -> Create Credentials -> Service Account -> JSON key + 2. In Step 2 (Grant this service account access to project): Add "Service Account Token Creator" role + 3. After creation, click on the service account, go to "Keys" tab, and create a new JSON key. + 4. Save the JSON key as a secret in the GitHub repository. +3. Share the Google Calendar with the service account email. "Settings and sharing" -> "Share with specific people" -> + Add the service account email. Give it "See all event details" permission. diff --git a/action.yml b/action.yml index f67ac16..a82b281 100644 --- a/action.yml +++ b/action.yml @@ -28,3 +28,4 @@ runs: DISCORD_EVENTS_SYNC_DISCORD_APPLICATION_ID: ${{ inputs.discord-application-id }} DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_CALENDAR_ID: ${{ inputs.google-calendar-id }} DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_API_KEY: ${{ inputs.google-api-key }} + DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON: ${{ inputs.google-service-account-key-json }} diff --git a/envConfig.ts b/envConfig.ts index 128200b..5e42656 100644 --- a/envConfig.ts +++ b/envConfig.ts @@ -3,29 +3,43 @@ import "@std/dotenv/load"; /** Prefix used for environment variable config options */ const ENVIRONMENT_VARIABLE_PREFIX = "DISCORD_EVENTS_SYNC_"; -const getEnv = (name: string) => { +const getEnv = (name: string, required: boolean = true) => { const envName = ENVIRONMENT_VARIABLE_PREFIX + name; const value = Deno.env.get(envName); if (value) { return value; } - - const message = `Environment {${envName}} not found.`; - console.error(message); - throw new Error(message); + if (required) { + const message = `Environment {${envName}} not found.`; + throw new Error(message); + } }; export const loadEnvConfig = () => { + const serviceAccountKeyJson = getEnv( + "GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON", + false, + ); + + const apiKey = getEnv("GOOGLE_CALENDAR_API_KEY", false); + + if (!serviceAccountKeyJson && !apiKey) { + throw new Error( + `Either {${ENVIRONMENT_VARIABLE_PREFIX}GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON} or {${ENVIRONMENT_VARIABLE_PREFIX}GOOGLE_CALENDAR_API_KEY} must be provided.`, + ); + } + const config = { discord: { - guildId: getEnv("DISCORD_GUILD_ID"), - botToken: getEnv("DISCORD_BOT_TOKEN"), - applicationId: getEnv("DISCORD_APPLICATION_ID"), + guildId: getEnv("DISCORD_GUILD_ID")!, + botToken: getEnv("DISCORD_BOT_TOKEN")!, + applicationId: getEnv("DISCORD_APPLICATION_ID")!, }, googleCalendar: { - calendarId: getEnv("GOOGLE_CALENDAR_CALENDAR_ID"), - apiKey: getEnv("GOOGLE_CALENDAR_API_KEY"), + calendarId: getEnv("GOOGLE_CALENDAR_CALENDAR_ID")!, + ...(serviceAccountKeyJson ? { serviceAccountKeyJson: serviceAccountKeyJson! } : { apiKey: apiKey! }), }, }; + return config; }; diff --git a/gcal.ts b/gcal.ts index c30db18..1316aae 100644 --- a/gcal.ts +++ b/gcal.ts @@ -15,38 +15,151 @@ export interface IGetCalendarEventsParams { export class GoogleCalendarClient { #calendarId: string; - #apiKey: string; + #apiKey: string | undefined; + #serviceAccountKeyJson: + | { + private_key: string; + client_email: string; + } + | undefined; - constructor({ calendarId, apiKey }: { calendarId: string; apiKey: string }) { - this.#calendarId = calendarId; - this.#apiKey = apiKey; + constructor( + options: + | { calendarId: string; apiKey: string } + | { + calendarId: string; + serviceAccountKeyJson: string; + }, + ) { + if ("serviceAccountKeyJson" in options) { + const { calendarId, serviceAccountKeyJson } = options; + this.#calendarId = calendarId; + this.#serviceAccountKeyJson = JSON.parse(serviceAccountKeyJson); + } else { + const { calendarId, apiKey } = options; + this.#calendarId = calendarId; + this.#apiKey = apiKey; + } } - public async getEvents( - { singleEvents, timeMax, timeMin, maxResults, orderBy }: IGetCalendarEventsParams, - ) { + private async getAccessToken() { + if (!this.#serviceAccountKeyJson) { + throw new Error("Service Account Key JSON not provided."); + } + + const { client_email, private_key } = this.#serviceAccountKeyJson; + console.log(`Getting access token for ${client_email}`); + + const header = { alg: "RS256", typ: "JWT" }; + const now = Math.floor(Date.now() / 1000); + const claims = { + iss: client_email, + scope: "https://www.googleapis.com/auth/calendar.events.readonly", + aud: "https://oauth2.googleapis.com/token", + iat: now, + exp: now + 3600, + }; + + const encodeBase64Url = (data: string): string => { + return btoa(data) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + }; + const base64UrlHeader = encodeBase64Url(JSON.stringify(header)); + const base64UrlClaims = encodeBase64Url(JSON.stringify(claims)); + const unsignedJwt = `${base64UrlHeader}.${base64UrlClaims}`; + + const b64 = private_key + .replace(/-----\w+ PRIVATE KEY-----/g, "") + .replace(/\n/g, ""); + const binary = atob(b64); + const buffer = new ArrayBuffer(binary.length); + const view = new Uint8Array(buffer); + for (let i = 0; i < binary.length; i++) { + view[i] = binary.charCodeAt(i); + } + const cryptoKey = await crypto.subtle.importKey( + "pkcs8", + buffer, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + cryptoKey, + new TextEncoder().encode(unsignedJwt), + ); + const signedJwt = `${unsignedJwt}.${ + encodeBase64Url( + new Uint8Array(signature).reduce( + (str, byte) => str + String.fromCharCode(byte), + "", + ), + ) + }`; + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: signedJwt, + }), + }); + + const tokenResponse = await response.json(); + return tokenResponse as { + access_token: string; + expires_in: number; + token_type: string; + }; + } + + public async getEvents({ + singleEvents, + timeMax, + timeMin, + maxResults, + orderBy, + }: IGetCalendarEventsParams) { try { - const calendarRequestParams = { + const calendarRequestParams: Record = { singleEvents: singleEvents.toString(), timeMin: timeMin.toISOString(), timeMax: timeMax.toISOString(), maxResults: `${maxResults}`, orderBy, - key: this.#apiKey, }; + + const calendarRequestHeaders: Record = { + Referer: "discord-events-sync", + }; + + if (this.#serviceAccountKeyJson) { + const accessToken = await this.getAccessToken(); + calendarRequestHeaders[ + "Authorization" + ] = `${accessToken.token_type} ${accessToken.access_token}`; + } else if (this.#apiKey) { + calendarRequestParams["key"] = this.#apiKey; + } + const calendarResponse = await fetch( - `${API_BASE_URL}/calendars/${this.#calendarId}/events?${ - (new URLSearchParams(calendarRequestParams)).toString() - }`, - { headers: { Referer: "discord-events-sync" } }, + `${API_BASE_URL}/calendars/${this.#calendarId}/events?${new URLSearchParams(calendarRequestParams).toString()}`, + { headers: calendarRequestHeaders }, ); + if (!calendarResponse.ok) { + throw new Error( + `Error getting events from Google Calendar API. Status: ${calendarResponse.status}. Response: ${await calendarResponse + .text()}`, + ); + } const parsed = await calendarResponse.json(); - return parsed; } catch (e) { - console.error( - `Error getting events from Google Calendar API.`, - ); + console.error(`Error getting events from Google Calendar API.`); throw e; } }