Skip to content

Commit

Permalink
gcloud service accounts
Browse files Browse the repository at this point in the history
  • Loading branch information
bmiddha committed Oct 12, 2024
1 parent bd307ce commit 1bd7f50
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 31 deletions.
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_API_KEY=
DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON=
5 changes: 3 additions & 2 deletions .github/workflows/sync.yml → .github/workflows/calsync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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. Credeintials -> 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.
1 change: 1 addition & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
34 changes: 24 additions & 10 deletions envConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
147 changes: 130 additions & 17 deletions gcal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
singleEvents: singleEvents.toString(),
timeMin: timeMin.toISOString(),
timeMax: timeMax.toISOString(),
maxResults: `${maxResults}`,
orderBy,
key: this.#apiKey,
};

const calendarRequestHeaders: Record<string, string> = {
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;
}
}
Expand Down

0 comments on commit 1bd7f50

Please sign in to comment.